feat: add self-operated analytics and enforce Lastleg module boundaries

This commit is contained in:
2026-10-12 20:27:35 +08:00
parent c06e788b0f
commit a1f1d93902
174 changed files with 13514 additions and 490 deletions
+12
View File
@@ -218,6 +218,12 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9210
LINGXING_WMS_ENABLED: ${LINGXING_WMS_ENABLED:-false}
LINGXING_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
LINGXING_ENVIRONMENT_LABEL: ${DEPLOY_ENV:-test}
LINGXING_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
LINGXING_OMS_APP_KEY: ${LINGXING_OMS_APP_KEY:-}
LINGXING_OMS_APP_SECRET: ${LINGXING_OMS_APP_SECRET:-}
BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false}
BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-}
BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-}
@@ -282,6 +288,12 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9303
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
SELFOP_DAILY_REQUEST_BUDGET: ${SELFOP_DAILY_REQUEST_BUDGET:-0}
SELFOP_MIN_REQUEST_INTERVAL_MS: ${SELFOP_MIN_REQUEST_INTERVAL_MS:-5000}
SELFOP_SALES_PUBLICATION_ENABLED: ${SELFOP_SALES_PUBLICATION_ENABLED:-false}
EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000}
EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000}
EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60}
+11
View File
@@ -21,6 +21,17 @@ IMAGE_TAG=
FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads
CORS_ALLOWED_ORIGINS=
INTERNAL_SERVICE_TOKEN=
# 领星唯一账号;凭据仅注入 integration-service。
LINGXING_OMS_APP_KEY=
LINGXING_OMS_APP_SECRET=
LINGXING_WMS_ENABLED=false
SELFOP_COLLECTION_MODE=OFF
SELFOP_SOURCE_ACCOUNT=lingxing-primary
# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。
SELFOP_DAILY_REQUEST_BUDGET=0
SELFOP_MIN_REQUEST_INTERVAL_MS=5000
SELFOP_EXECUTION_ENABLED=false
SELFOP_SALES_PUBLICATION_ENABLED=false
REDIS_PASSWORD=
AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
AUTH_DB_USERNAME=
+5 -3
View File
@@ -22,6 +22,8 @@
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、稳定任务号、阶段进度/心跳和旧版替代时间;库存 run 增加每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。本批只交付文件与离线验证,不执行 SQL、不部署;真实采集模式默认 OFF。
## 唯一入口与区域边界
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
@@ -29,7 +31,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 181 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 194 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -51,7 +53,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/194`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -67,7 +69,7 @@
| 所属库 | 保留内容 | 不包含 |
|---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、417 个权限、598 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、419 个权限、600 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -8313,4 +8313,22 @@ SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND r.status='ACTIVE' AND r.deleted=0;
-- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables;
+303 -1
View File
@@ -3908,7 +3908,309 @@ CREATE TABLE ll_procurement_closure_item (
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables;
USE baiyue_lastleg;
-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。
CREATE TABLE ll_selfop_outbound_discovery_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖',
catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖',
next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧',
next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次',
active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务',
lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者',
lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务',
request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正',
time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正',
min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置',
api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据',
api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除',
api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_discovery_account (source_account),
CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域';
CREATE TABLE ll_selfop_sync_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥',
scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单',
scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合',
business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准',
source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区',
schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行',
stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整',
warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表',
initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月',
initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间',
discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界',
discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点',
fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加',
published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号',
rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1',
invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻',
published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次',
lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁',
lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验',
known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标',
known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识',
known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间',
known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间',
history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界',
history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界',
history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置',
history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻',
review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正',
review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正',
review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family),
KEY idx_selfop_scope_lease (lease_until_utc),
CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针';
CREATE TABLE ll_selfop_sync_task (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识',
execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承',
validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空',
validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器',
upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游',
discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定',
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号',
routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷',
scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要',
status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功',
apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE',
affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补',
apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间',
apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数',
next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻',
requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计',
result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空',
progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合',
progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_task_no (task_no),
UNIQUE KEY uk_selfop_task_request (source_account,request_key),
KEY idx_selfop_task_poll (status,next_retry_at_utc,id),
KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code),
KEY idx_selfop_task_apply (apply_state,id),
KEY idx_selfop_task_scope_list (scope_id,created_at,id),
KEY idx_selfop_task_account_list (source_account,task_role,created_at,id),
CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求';
CREATE TABLE ll_selfop_sync_run (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_id BIGINT NOT NULL COMMENT '本次执行请求',
execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本',
scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合',
attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增',
scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要',
scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版',
observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围',
fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本',
base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本',
reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实',
inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空',
inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存',
status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头',
last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实',
fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间',
fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空',
build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔',
finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间',
published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间',
superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置',
page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数',
source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空',
unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数',
progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写',
heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进',
coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断',
covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界',
covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点',
complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含',
complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含',
applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空',
calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号',
error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息',
error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no),
UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date),
KEY idx_selfop_run_scope (scope_id,id),
KEY idx_selfop_run_status (status,updated_at),
CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')),
CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头';
CREATE TABLE ll_selfop_sync_stage (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试',
stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页',
last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源',
observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓',
object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK',
object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对',
first_page_no INT NOT NULL COMMENT '首次发现页码',
observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻',
content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要',
payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段',
before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空',
validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常',
validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash),
KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id),
KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence),
KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status),
CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据';
CREATE TABLE ll_selfop_outbound_order (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份',
source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示',
source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码',
source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代',
source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代',
source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区',
outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空',
business_date DATE NULL COMMENT '已核准业务时区下的出库日',
business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日',
sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1',
revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数',
content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要',
last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试',
last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no),
KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id),
KEY idx_selfop_order_run (last_run_id),
CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实';
CREATE TABLE ll_selfop_outbound_item (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
order_id BIGINT NOT NULL COMMENT '对应外部出库事实',
sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku',
product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示',
actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值',
quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order_sku (order_id,sku_code),
KEY idx_selfop_item_sku (sku_code,order_id),
CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细';
CREATE TABLE ll_selfop_inventory_snapshot (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存',
product_name VARCHAR(255) NULL COMMENT '来源名称',
physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空',
available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结',
frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空',
transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存',
source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据',
observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间',
quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生',
quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality),
KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细';
CREATE TABLE ll_selfop_sku_daily_sales (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
business_date DATE NOT NULL COMMENT '已核准业务日',
outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负',
outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负',
quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date),
KEY idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code),
CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实';
CREATE TABLE ll_selfop_sku_profile (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称',
quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标',
first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据',
earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移',
evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据',
evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空',
last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code),
KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 194 AS initialized_tables;
CREATE TABLE IF NOT EXISTS ll_appointment_impact (
+2
View File
@@ -56,3 +56,5 @@
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
平台自营销量、产品库存与采集:正式入口为 ../docs/自营业务/README.md;适配从 integration-service/AGENTS.md 进入,持久化、指标和平台运行查询从 lastleg-service/AGENTS.md 进入 selfoperated/analytics/,权限源为 iam/src/main/resources/db/selfoperated-analytics-permissions.sql。Admin 从 ../apps/admin-web/src/modules/self-operated/AGENTS.md 进入;与客户物流订单和仓储作业库存分开。
+1
View File
@@ -17,6 +17,7 @@
| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 |
| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` |
| 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` |
| 平台自营领星统计权限 | `src/main/resources/db/selfoperated-analytics-permissions.sql`;自营业务下销量分析、库存分析、数据采集菜单资源同步国内统一空库基线;三个页面和所有只读接口统一使用 `platform:self-operated-analytics:list`,采集写入独立使用 `platform:self-operated-analytics:sync`。菜单资源不替代服务端读写授权。 |
| 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` |
| 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 |
| 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` |
@@ -0,0 +1,17 @@
-- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3));
+2 -2
View File
@@ -1,3 +1,3 @@
# Copy to .env.local for local WMS connectivity verification.
LINGXING_WMS_APP_KEY=
LINGXING_WMS_APP_SECRET=
LINGXING_OMS_APP_KEY=
LINGXING_OMS_APP_SECRET=
+1
View File
@@ -9,6 +9,7 @@
- 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java`
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
- 领星 WMS 自研系统出库只读查询:`controller/LingxingInternalController.java`、`provider/lingxing/`、`dto/lingxing/`、`config/LingxingWmsProperties.java`;内部契约 `openapi/lingxing-wms-v1.yaml` 位于 `src/main/resources/`。内部提供出库单页摘要、独立按单定位、出库详情、仓库目录及单仓产品库存单页查询;统一账号节流、有限退避和严格分页校验,保留未知数量与源时间原值。不编排定时采集、多页库存同步或业务持久化;本地隔离启动与环境变量入口见 `README.md`,真实调用默认关闭。
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
- 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md`
+102
View File
@@ -0,0 +1,102 @@
# Integration 本地领星 WMS 查询
本入口用于在现有 Java 服务内验证 WMS 只读调用。仅返回单页出库摘要,不启动月度采集、库存全量同步、定时任务或数据库写入。业务方案见[领星一件代发数据统计一期方案](../../docs/自营业务/领星一件代发数据统计一期方案.md)。
后续开发交接见[接口联调手册](../../docs/自营业务/领星WMS接口联调手册.md):已另行用只读 HTTP 验证仓库发现、一件代发详情和按仓产品库存全分页。此处现有 Java 接口仍只有出库摘要;库存内部接口、分仓任务和持久化尚待开发,不能以外部直连成功推定 Java 功能已经具备。
## 凭据与开关
从 WMS 管理后台的“个人中心 → API 信息 → 自研系统”获取 `appKey` 和 `appSecret`,填写本目录 Git 忽略的 `.env.local`,格式参考 [.env.example](.env.example)。这两个值分别加载为 `LINGXING_OMS_APP_KEY` 和 `LINGXING_OMS_APP_SECRET` 进程环境变量,不提交真实值。Spring 本身不会自动读取 `.env.local`,应在启动 Java 的同一终端加载。该凭据属于 OMP 服务商内部系统对接,不使用 OMS 货主或 WMS 智能设备协议。
正式应用配置默认 `LINGXING_WMS_ENABLED=false`。下面的本地命令显式启用适配器,为该本地实例生成内部调用令牌。不要复用共享环境令牌。
测试部署通过 `deploy/docker-compose.yml` 将 `LINGXING_OMS_APP_KEY`、`LINGXING_OMS_APP_SECRET` 传入 Integration 容器。现有 Jenkins 下游部署任务读取 `firstleg-test-env` Secret File;可将这两项加入该文件,或在实际执行部署的下游服务任务中以凭据绑定注入同名环境变量。只在上游总控任务设置变量不会自动传给下游任务。变量命名采用用户指定的 `OMS`,接口协议仍为当前 WMS 自研系统(OMP);真实凭据是否有该接口权限仍待联调核验。不要将密钥作为普通构建参数或打印完整 Compose 配置。
2026-10-12 用户确认唯一账号、所有源时间按北京时间、数量按 SKU 件数。Lastleg 应用配置使用 `source-account=lingxing-primary`(内部稳定标识,不是 appKey)、`source-query-timezone=Asia/Shanghai`、`quantity-unit=PCS`,分仓配置的源时区默认 `Asia/Shanghai`;数量仍保留十进制精度。仓库编码须在获得真实仓库目录后配置到 `baiyue.self-operated-analytics.warehouses`,不预填虚构仓库。`SELFOP_EXECUTION_ENABLED` 和 `SELFOP_SALES_PUBLICATION_ENABLED` 均默认关闭。
## 本地启动
先在后端仓库根目录执行构建,包含相关模块的编译与测试:
```powershell
mvn -pl integration-service -am package
```
然后在同一目录的 PowerShell 中执行。只加载明确允许的两个名称,不执行环境文件内的代码:
```powershell
$credentialNames = @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET')
foreach ($line in [IO.File]::ReadAllLines((Join-Path (Get-Location) 'integration-service/.env.local'))) {
if ($line -match '^\s*(LINGXING_OMS_APP_KEY|LINGXING_OMS_APP_SECRET)\s*=(.*)$') {
$credentialName = $matches[1]
$credentialValue = $matches[2].Trim()
if ($credentialValue.Length -ge 2 -and
(($credentialValue.StartsWith('"') -and $credentialValue.EndsWith('"')) -or
($credentialValue.StartsWith("'") -and $credentialValue.EndsWith("'")))) {
$credentialValue = $credentialValue.Substring(1, $credentialValue.Length - 2)
}
[Environment]::SetEnvironmentVariable($credentialName, $credentialValue, 'Process')
}
}
foreach ($credentialName in $credentialNames) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($credentialName, 'Process'))) {
throw "Missing credential: $credentialName"
}
}
$env:INTERNAL_SERVICE_TOKEN = [guid]::NewGuid().ToString()
$localInternalToken = $env:INTERNAL_SERVICE_TOKEN
$env:INTEGRATION_LOCAL_PORT = '19210'
$buildJar = (Resolve-Path 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar').Path
$localJava = Start-Process -FilePath (Get-Command java).Source -WindowStyle Hidden -PassThru `
-ArgumentList @('-jar', ('"' + $buildJar + '"'),
'--spring.config.location=classpath:/application-lingxing-local.yml',
'--baiyue.integration.lingxing-wms.enabled=true') `
-RedirectStandardOutput 'integration-service/target/lingxing-local.stdout.log' `
-RedirectStandardError 'integration-service/target/lingxing-local.stderr.log'
```
该配置仅监听 `127.0.0.1`,关闭 Nacos Config、Discovery、服务注册及 Spring 定时任务,不导入共享环境配置。若端口占用,选择其他本地端口并同步替换后续 URL,不停止未知进程。确认 `http://127.0.0.1:19210/actuator/health` 返回 `UP` 后再查询。
## 读取一条出库单
在保留上述变量的同一 PowerShell 中执行一次:
```powershell
$businessZone = [TimeZoneInfo]::FindSystemTimeZoneById('China Standard Time')
$queryEnd = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $businessZone)
$query = @{
startTime = $queryEnd.AddMonths(-1).ToString('yyyy-MM-ddTHH:mm:ss')
endTime = $queryEnd.ToString('yyyy-MM-ddTHH:mm:ss')
current = 1
size = 1
} | ConvertTo-Json -Compress
$result = Invoke-RestMethod -Method Post `
-Uri 'http://127.0.0.1:19210/internal/integrations/lingxing/wms/outbound-orders/query' `
-Headers @{'X-Internal-Service-Token' = $localInternalToken} `
-ContentType 'application/json' -Body $query
$result | ConvertTo-Json -Depth 6
```
返回摘要只包含出库单号、来源单号、货主编码、仓库编码、状态、创建时间和出库时间,不返回收件人或地址。业务 `code="200"` 且 `records` 包含一条才表示已读到样本;空页表示调用成功但未取得样本;`B0001`/`S0503` 表示明确失败,保留 `errorCode`、来源数值错误码及请求标识排查,不能当作零销量。
当前调用 OMP `POST /openapi/v2/order/delivery/page`,固定 `timeType=outboundTime`、`status=100` 已出库,窗口最多向前一个自然月。该本地保护不代表供应商历史上限;单页样本也不代表整月采集完成。订单详情、全量库存、增量任务和统计落库仍按业务方案后续实现。
测试完毕,在同一终端停止本次启动的进程,并清理当前终端注入的变量:
```powershell
if (-not $localJava.HasExited) { Stop-Process -Id $localJava.Id }
foreach ($credentialName in @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET', 'INTERNAL_SERVICE_TOKEN')) {
[Environment]::SetEnvironmentVariable($credentialName, $null, 'Process')
}
$localInternalToken = $null
$credentialValue = $null
```
## 接口与验证
- 2026-10-12 已通过本地 Java 实例调用 OMP WMS 出库分页,按最近 1 个月、`current=1`、`size=1` 成功返回 1 条已出库摘要;无内部令牌请求被拒绝。验证后本地进程已停止。该结果仅证明当前只读链路可用,不代表全量采集或库存同步完成。
- 实测成功响应为数值 `code=200`,但缺少官方示例及 Schema 中的 `success`。客户端以成功码和有效分页结构共同判定;`success` 出现时仍须为布尔 `true`。此差异已加入回归测试,失败响应不转换为空页。
- 内部前缀为 `POST /internal/integrations/lingxing/wms`,提供 `/outbound-orders/query`、`/outbound-orders/locate`、`/outbound-orders/detail`、`/warehouses/query`、`/product-inventory/query`;仅接受 `X-Internal-Service-Token`,不经公网 Gateway。各请求只返回一页或一单,多页与业务版本归 Lastleg 编排。定位不传时间/状态;产品库存必须指定仓库、保留全部品质和零库存。源时间原值与 BigDecimal 数量未知值保留,不推断时区或单位。
- [OpenAPI](src/main/resources/openapi/lingxing-wms-v1.yaml)定义输入、分页摘要和失败结构。
- 从后端根执行 `mvn -pl integration-service -am test` 与 `node --test scripts/gateway-route-contract.test.mjs`。
- 本地模拟 HTTP 单测验证签名、请求体、敏感信息隔离和错误处理;不能代替真实领星样本验证。官方入口为[服务商内部系统对接](https://apidoc-omp.xlwms.com/)与[一件代发出库单 WMS 分页查询](https://apidoc-omp.xlwms.com/reference/post_openapi-v2-order-delivery-page-1)。
@@ -0,0 +1,35 @@
package com.baiyue.integration.config;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
/** An operator-attested exclusive window; it is not a distributed account lock. */
@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress")
public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode,
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
@DefaultValue("5m") Duration cooldownFallback) {
public LingxingEgressProperties {
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
environmentLabel = environmentLabel == null ? "test" : environmentLabel;
minStartInterval = minStartInterval == null ? Duration.ofSeconds(5) : minStartInterval;
cooldownFallback = cooldownFallback == null ? Duration.ofMinutes(5) : cooldownFallback;
if (minStartInterval.compareTo(Duration.ofSeconds(5)) < 0 || minStartInterval.compareTo(Duration.ofMinutes(5)) > 0
|| cooldownFallback.compareTo(Duration.ofSeconds(5)) < 0 || cooldownFallback.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("领星出口间隔至少5秒,冷却配置须为有界正值");
}
if (budgetAuthorityBaseUri != null && (budgetAuthorityBaseUri.getHost() == null
|| !("http".equals(budgetAuthorityBaseUri.getScheme()) || "https".equals(budgetAuthorityBaseUri.getScheme()))
|| budgetAuthorityBaseUri.getUserInfo() != null || budgetAuthorityBaseUri.getQuery() != null
|| budgetAuthorityBaseUri.getFragment() != null || !budgetAuthorityBaseUri.getPath().isEmpty())) {
throw new IllegalArgumentException("领星预算授权地址必须为受控内部服务源地址");
}
}
@Override public String toString() {
return "LingxingEgressProperties[collectionMode=" + collectionMode + ", environmentLabel=" + environmentLabel + "]";
}
}
@@ -13,18 +13,15 @@ public record LingxingWmsProperties(
String appKey,
String appSecret,
@DefaultValue("5s") Duration connectTimeout,
@DefaultValue("15s") Duration requestTimeout,
@DefaultValue("1s") Duration minRequestInterval
@DefaultValue("15s") Duration requestTimeout
) {
public LingxingWmsProperties {
baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri;
connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout;
requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout;
minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval;
if (connectTimeout.isZero() || connectTimeout.isNegative()
|| requestTimeout.isZero() || requestTimeout.isNegative()
|| minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间");
|| requestTimeout.isZero() || requestTimeout.isNegative()) {
throw new IllegalArgumentException("领星 WMS 超时必须为正数");
}
String host = baseUri.getHost();
boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host)
@@ -5,6 +5,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.core.JsonParser;
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
@@ -25,19 +30,76 @@ import org.springframework.web.bind.annotation.RestController;
public class LingxingInternalController {
private final InternalServiceTokenGuard tokenGuard;
private final LingxingWmsClient client;
private final ObjectMapper mapper;
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) {
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client, ObjectMapper mapper) {
this.tokenGuard = tokenGuard;
this.client = client;
this.mapper = mapper;
}
@Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要")
@PostMapping("/outbound-orders/query")
public ApiResponse<PageResult> query(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody QueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryPage(request));
return ApiResponse.success(client.queryPage(request, context(context)));
}
@Operation(summary = "读取正常仓库目录单页")
@PostMapping("/warehouses/query")
public ApiResponse<QueryPage<Warehouse>> warehouses(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody WarehouseQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryWarehouses(request, context(context)));
}
@Operation(summary = "按单号定位单页,不附加时间或状态筛选")
@PostMapping("/outbound-orders/locate")
public ApiResponse<PageResult> locate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody LocateRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.locate(request, context(context)));
}
@Operation(summary = "按仓库、货主、发货单号读取完整 SKU 详情")
@PostMapping("/outbound-orders/detail")
public ApiResponse<OrderDetail> detail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody DetailRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryDetail(request, context(context)));
}
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
@PostMapping("/product-inventory/query")
public ApiResponse<QueryPage<ProductStock>> inventory(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody InventoryQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryInventory(request, context(context)));
}
private EgressContext context(String header) {
if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126))
throw invalidContext();
try {
EgressContext context = mapper.readerFor(EgressContext.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
DeserializationFeature.FAIL_ON_TRAILING_TOKENS).with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION).readValue(header);
if (context == null || !context.wellFormed()) throw invalidContext();
return context;
} catch (Exception ignored) { throw invalidContext(); }
}
private static LingxingWmsException invalidContext() {
return new LingxingWmsException("LINGXING_WMS_EGRESS_CONTEXT_INVALID", "领星安全出口控制上下文缺失或无效", false, null, null);
}
@ExceptionHandler(LingxingWmsException.class)
@@ -46,10 +108,11 @@ public class LingxingInternalController {
ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR;
return ApiResponse.of(code, exception.getMessage(),
new ProviderFailure(exception.code(), exception.retryable(),
exception.upstreamStatus(), exception.sourceCode()),
exception.upstreamStatus(), exception.sourceCode(), exception.egress() == null
? new AttemptMetadata(null, 0, false, null) : exception.egress(), exception.denial()),
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
}
public record ProviderFailure(String errorCode, boolean retryable,
Integer upstreamStatus, Integer sourceCode) { }
Integer upstreamStatus, Integer sourceCode, AttemptMetadata egress, AuthorizationDenial denial) { }
}
@@ -0,0 +1,31 @@
package com.baiyue.integration.dto.lingxing;
import java.time.Instant;
/** Control identities are supplied by the collector and revalidated by its durable authority. */
public final class LingxingEgressDtos {
private LingxingEgressDtos() { }
public enum CollectionMode { OFF, VALIDATION, BUSINESS }
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY }
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
String sessionId, String attemptId, String exclusiveGrantId) {
public boolean wellFormed() {
return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS)
&& identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128)
&& identifier(attemptId, 128) && identifier(exclusiveGrantId, 128);
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
}
public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { }
public record AttemptPermit(EgressContext context, Operation operation, String requestHash,
int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed,
boolean budgetConsumed) { }
public record CompletionRequest(EgressContext context, Operation operation, String requestHash,
int actualHttpAttempts, String errorCode, Integer upstreamStatus,
Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { }
public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused,
Instant cooldownUntil) { }
public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { }
}
@@ -3,10 +3,14 @@ package com.baiyue.integration.dto.lingxing;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
public final class LingxingWmsDtos {
private LingxingWmsDtos() { }
@@ -14,12 +18,38 @@ public final class LingxingWmsDtos {
public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime,
@Min(1) int current, @Min(1) @Max(100) int size) { }
// Preserve source time strings until the account's source timezone has been verified.
public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo,
@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotBlank @Size(max=64) String customerCode,
@NotBlank @Size(max=128) String deliveryNo) { }
public record InventoryQueryRequest(@NotBlank @Size(max=64) String warehouseCode,
@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record Warehouse(String whCode, String whNameCn, Integer warehouseType,
Integer status, String timeZone) { }
public record SkuLine(String productSku, String productName, BigDecimal actualDeliveryQty,
BigDecimal deliveryQty) { }
public record OrderDetail(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String orderCreateTime, String outboundTime,
List<SkuLine> skuVOList, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public OrderDetail { skuVOList = List.copyOf(skuVOList); }
}
public record ProductStock(String customerCode, String whCode, String productSku, String productName,
Integer productQuality, BigDecimal stkQty, BigDecimal freezeQty,
BigDecimal useStkQty, BigDecimal transitQty, String innerBatchNo,
String outBatchNo, String uniqueKey4tenant) { }
public record QueryPage<T>(List<T> records, Long total, int current, int size, int pages,
int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public QueryPage { records = List.copyOf(records); }
}
// Preserve source strings; the collector interprets confirmed Asia/Shanghai source time.
public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String createTime, String outboundTime) { }
public record PageResult(List<OrderSummary> records, Long total, int current, int size,
int sourceCode, Instant observedAt) {
int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public PageResult { records = List.copyOf(records); }
}
}
@@ -0,0 +1,9 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */
public interface LingxingBudgetAuthority {
AttemptPermit authorize(AuthorizationRequest request);
void complete(CompletionRequest request);
}
@@ -0,0 +1,11 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */
public final class LingxingBudgetDeniedException extends RuntimeException {
private final AuthorizationDenial denial;
public LingxingBudgetDeniedException(AuthorizationDenial denial) {
super("领星持久预算授权拒绝"); this.denial = denial;
}
public AuthorizationDenial denial() { return denial; }
}
@@ -0,0 +1,131 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Instant;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/** Local serialization supplements, never replaces, the externally attested window and durable budget. */
@Component
public final class LingxingEgressGate {
private final LingxingEgressProperties properties;
private final LingxingBudgetAuthority authority;
private final Clock clock;
private final Waiter waiter;
private final Semaphore inFlight = new Semaphore(1, true);
private volatile boolean paused;
private volatile boolean recoverableRatePause;
private Instant lastStart;
private Instant cooldownUntil;
@Autowired public LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority) {
this(properties, authority, Clock.systemUTC(), Thread::sleep);
}
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
}
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
try { return guarded(context, operation, hash, call); }
catch (LingxingWmsException error) {
if (error.egress() == null) error.withEgress(new AttemptMetadata(
context != null && identifier(context.attemptId(), 128) ? context.attemptId() : null, 0, paused, cooldownUntil));
throw error;
}
}
private <T> T guarded(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
check(context);
if (operation == null || hash == null || !hash.matches("[a-f0-9]{64}")) throw failure("EGRESS_CONTEXT_INVALID");
boolean acquired = false;
try {
while (!(acquired = inFlight.tryAcquire(100, TimeUnit.MILLISECONDS))) check(context);
check(context);
if (lastStart != null) {
Instant next = lastStart.plus(properties.minStartInterval());
long wait = java.time.Duration.between(clock.instant(), next).toMillis();
if (wait > 0) waiter.sleep(wait + 1);
}
check(context);
AuthorizationRequest request = new AuthorizationRequest(context, operation, hash);
AttemptPermit permit;
try { permit = authority.authorize(request); }
catch (LingxingBudgetDeniedException knownDecision) {
throw failure("BUDGET_AUTHORIZATION_DENIED").withDenial(knownDecision.denial());
}
catch (RuntimeException error) { recoverableRatePause = false; paused = true; throw failure("BUDGET_AUTHORITY_UNAVAILABLE"); }
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|| permit.expiresAt().isAfter(properties.externalExclusiveUntil())) {
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
}
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state.
// Time alone never clears the pause. VALIDATION and uncertain callbacks cannot reach this branch.
if (paused && recoverableRatePause && properties.collectionMode() == CollectionMode.BUSINESS) {
paused = false; recoverableRatePause = false; cooldownUntil = null;
}
AttemptHandle handle = new AttemptHandle(context, permit.expiresAt());
T result = null;
LingxingWmsException failure = null;
try { result = call.run(handle); }
catch (LingxingWmsException error) { failure = error; }
catch (RuntimeException error) { failure = failure("LOCAL_PROCESSING_FAILED"); }
CompletionRequest completion = new CompletionRequest(context, operation, hash, handle.actualHttpAttempts,
failure == null ? null : failure.code(), failure == null ? Integer.valueOf(200) : failure.upstreamStatus(),
failure == null ? Integer.valueOf(200) : failure.sourceCode(), paused, cooldownUntil);
try { authority.complete(completion); }
catch (RuntimeException error) {
recoverableRatePause = false; paused = true;
// Unconfirmed completion must remain distinguishable even when the provider already failed.
failure = failure("BUDGET_COMPLETION_UNCONFIRMED");
}
if (failure != null) throw failure.withEgress(handle.metadata());
return result;
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); throw failure("INTERRUPTED");
} finally { if (acquired) inFlight.release(); }
}
private void check(EgressContext context) {
if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF");
if (properties.collectionMode() == CollectionMode.BUSINESS && !"production".equals(properties.environmentLabel()))
throw failure("BUSINESS_ENVIRONMENT_REJECTED");
if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS
|| cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED");
if (context == null || context.executionPurpose() != properties.collectionMode()
|| !context.wellFormed()
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
private static LingxingWmsException failure(String code) {
return new LingxingWmsException("LINGXING_WMS_" + code, "领星安全出口拒绝请求(" + code + ")", false, null, null);
}
public final class AttemptHandle {
private final EgressContext context;
private final Instant expiresAt;
private int actualHttpAttempts;
private AttemptHandle(EgressContext context, Instant expiresAt) { this.context = context; this.expiresAt = expiresAt; }
/** Immediately before send, after signing. One permit can never send twice. */
public void startHttp() {
check(context);
if (actualHttpAttempts != 0 || !clock.instant().isBefore(expiresAt)) throw failure("ATTEMPT_NOT_SENDABLE");
actualHttpAttempts = 1; lastStart = clock.instant();
}
public void rateLimited(Instant retryAfter) {
Instant fallback = clock.instant().plus(properties.cooldownFallback());
cooldownUntil = retryAfter != null && retryAfter.isAfter(fallback) ? retryAfter : fallback;
recoverableRatePause = properties.collectionMode() == CollectionMode.BUSINESS;
paused = true;
}
public AttemptMetadata metadata() { return new AttemptMetadata(context.attemptId(), actualHttpAttempts, paused, cooldownUntil); }
}
@FunctionalInterface interface Waiter { void sleep(long millis) throws InterruptedException; }
@FunctionalInterface public interface AuthorizedCall<T> { T run(AttemptHandle handle); }
}
@@ -0,0 +1,73 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */
@Component
public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority {
private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/";
private final LingxingEgressProperties properties;
private final ObjectMapper mapper;
private final String internalToken;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER).build();
public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.properties = properties; this.mapper = mapper; this.internalToken = internalToken;
}
@Override public AttemptPermit authorize(AuthorizationRequest request) {
JsonNode data = post("authorize", request);
if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber()
|| data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean()
|| !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean()
|| !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject()
|| !data.path("expiresAt").isTextual()) throw unavailable();
try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); }
catch (Exception ignored) { throw unavailable(); }
}
@Override public void complete(CompletionRequest request) {
JsonNode data = post("complete", request);
if (!data.isBoolean() || !data.booleanValue()) throw unavailable();
}
private JsonNode post(String suffix, Object payload) {
if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank()
|| internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable();
try {
HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix))
.timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken)
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) throw unavailable();
JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body());
if (root == null || !root.isObject() || !root.path("code").isTextual()
|| !root.hasNonNull("data")) throw unavailable();
if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) {
JsonNode data = root.get("data");
if (!data.isObject() || !data.path("errorCode").isTextual()
|| !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")
|| !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable();
AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper));
throw new LingxingBudgetDeniedException(denial);
}
if (!"200".equals(root.path("code").textValue())) throw unavailable();
return root.get("data");
} catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision;
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); throw unavailable();
} catch (Exception ignored) { throw unavailable(); }
}
private static LingxingWmsException unavailable() {
return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null);
}
}
@@ -4,10 +4,21 @@ import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import java.util.function.BiFunction;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.function.Function;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -25,7 +36,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Semaphore;
@Component
public final class LingxingWmsClient {
@@ -37,49 +47,174 @@ public final class LingxingWmsClient {
private final LingxingWmsSigner signer;
private final HttpClient httpClient;
private final Clock clock;
private final Semaphore concurrency = new Semaphore(1);
private long lastRequestNanos;
private boolean hasRequested;
private final LingxingEgressGate egressGate;
@Autowired
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) {
this(properties, mapper, Clock.systemUTC());
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) {
this(properties, mapper, Clock.systemUTC(), egressGate);
}
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) {
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock, LingxingEgressGate egressGate) {
this.properties = properties;
this.mapper = mapper;
this.mapper = mapper.copy().disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
this.clock = clock;
signer = new LingxingWmsSigner(mapper);
this.egressGate = egressGate;
signer = new LingxingWmsSigner(this.mapper);
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout())
.followRedirects(HttpClient.Redirect.NEVER).build();
}
public PageResult queryPage(QueryRequest request) {
if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false);
if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false);
public PageResult queryPage(QueryRequest request, EgressContext context) {
validate(request);
if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true);
try {
reserveRequest();
Map<String, Object> data = new LinkedHashMap<>();
data.put("current", request.current());
data.put("size", request.size());
Map<String, Object> data = pagination(request.current(), request.size());
data.put("timeType", "outboundTime");
data.put("startTime", SOURCE_TIME.format(request.startTime()));
data.put("endTime", SOURCE_TIME.format(request.endTime()));
data.put("status", 100);
var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant());
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH))
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_QUERY,
(json, metadata) -> orders(json, request.current(), request.size(), null, metadata));
}
public PageResult locate(LocateRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.deliveryNo(), 128);
validatePagination(request.current(), request.size(), 1000);
Map<String, Object> data = pagination(request.current(), request.size());
data.put("deliveryNo", request.deliveryNo());
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_LOCATE,
(json, metadata) -> orders(json, request.current(), request.size(), request.deliveryNo(), metadata));
}
public QueryPage<Warehouse> queryWarehouses(WarehouseQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
validatePagination(request.current(), request.size(), 1000);
return execute("/openapi/v2/warehouse/page", pagination(request.current(), request.size()), context, Operation.WAREHOUSES,
(data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, row ->
new Warehouse(requiredText(row, "whCode", 64), optionalText(row, "whNameCn"),
requiredInt(row.get("warehouseType"), "WAREHOUSE_TYPE"), requiredInt(row.get("status"), "STATUS"),
optionalText(row, "timeZone"))));
}
public OrderDetail queryDetail(DetailRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64);
requireInput(request.customerCode(), 64);
requireInput(request.deliveryNo(), 128);
Map<String, Object> body = new LinkedHashMap<>();
body.put("whCode", request.warehouseCode());
body.put("customerCode", request.customerCode());
body.put("deliveryNo", request.deliveryNo());
return execute("/openapi/v2/order/delivery/detail", body, context, Operation.OUTBOUND_DETAIL,
(data, metadata) -> detail(data, request, metadata));
}
private OrderDetail detail(JsonNode data, DetailRequest request, AttemptMetadata metadata) {
String deliveryNo = requiredText(data, "deliveryNo", 128);
String customer = requiredText(data, "customerCode", 64);
String warehouse = requiredText(data, "whCode", 64);
if (!deliveryNo.equals(request.deliveryNo()) || !customer.equals(request.customerCode())
|| !warehouse.equals(request.warehouseCode())) throw invalidResponse("DETAIL_IDENTITY_MISMATCH", 200);
JsonNode rows = data.get("skuVOList");
if (rows == null || !rows.isArray() || rows.isEmpty()) throw invalidResponse("SKU_LIST_INVALID", 200);
List<SkuLine> lines = new ArrayList<>();
for (JsonNode row : rows) {
if (!row.isObject()) throw invalidResponse("SKU_ROW_INVALID", 200);
lines.add(new SkuLine(requiredText(row, "productSku", 128), optionalText(row, "productName"),
quantity(row.get("actualDeliveryQty")), quantity(row.get("deliveryQty"))));
}
return new OrderDetail(deliveryNo, optionalText(data, "sourceNo"), customer, warehouse,
requiredInt(data.get("status"), "STATUS"), optionalText(data, "orderCreateTime"),
optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata);
}
public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64);
validatePagination(request.current(), request.size(), 1000);
Map<String, Object> body = pagination(request.current(), request.size());
body.put("whCode", request.warehouseCode());
body.put("isHideInventory", 0);
return execute("/openapi/v2/stock/stockSku/page", body, context, Operation.PRODUCT_INVENTORY,
(data, metadata) -> inventory(data, request, metadata));
}
private QueryPage<ProductStock> inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) {
Set<List<Object>> keys = new HashSet<>();
return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> {
String customer = requiredText(row, "customerCode", 64);
String warehouse = requiredText(row, "whCode", 64);
String sku = requiredText(row, "productSku", 128);
int quality = requiredInt(row.get("productQuality"), "PRODUCT_QUALITY");
if (!warehouse.equals(request.warehouseCode())) throw invalidResponse("INVENTORY_WAREHOUSE_MISMATCH", 200);
if (!keys.add(List.of(customer, warehouse, sku, quality))) throw invalidResponse("DUPLICATE_INVENTORY_KEY", 200);
return new ProductStock(customer, warehouse, sku, optionalText(row, "productName"), quality,
quantity(row.get("stkQty")), quantity(row.get("freezeQty")), quantity(row.get("useStkQty")),
quantity(row.get("transitQty")), optionalText(row, "innerBatchNo"), optionalText(row, "outBatchNo"),
optionalText(row, "uniqueKey4tenant"));
});
}
private <T> T execute(String path, Map<String, Object> data, EgressContext context, Operation operation,
BiFunction<JsonNode, AttemptMetadata, T> projection) {
if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false);
if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false);
if (context != null && context.executionPurpose() == CollectionMode.VALIDATION
&& data.containsKey("size") && (int)data.get("size") > 10) throw badRequest();
String hash;
try {
hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
(path + "\n" + mapper.writer().without(SerializationFeature.INDENT_OUTPUT,
SerializationFeature.WRAP_ROOT_VALUE).writeValueAsString(data)).getBytes(StandardCharsets.UTF_8)));
} catch (Exception ignored) { throw failure("REQUEST_HASH_FAILED", "领星请求授权摘要生成失败", false); }
return egressGate.execute(context, operation, hash, handle -> {
var signed = signer.sign(properties.appKey(), properties.appSecret(), path, data, clock.instant());
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(path))
.timeout(properties.requestTimeout()).header("Accept", "application/json")
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build();
handle.startHttp();
HttpResponse<String> response = send(httpRequest);
if (response.statusCode() == 429) handle.rateLimited(retryAfter(response));
validateStatus(response.statusCode());
return page(response.body(), request);
} finally {
concurrency.release();
JsonNode json;
try { json = envelope(response.body()); }
catch (LingxingWmsException error) {
if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) {
handle.rateLimited(retryAfter(response));
}
throw error;
}
return projection.apply(json, handle.metadata());
});
}
private Instant retryAfter(HttpResponse<?> response) {
String value = response.headers().firstValue("Retry-After").orElse(null);
if (value == null || value.length() > 128) return null;
try {
if (value.matches("[0-9]{1,9}")) return clock.instant().plusSeconds(Long.parseLong(value));
Instant time = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
return time.isAfter(clock.instant()) ? time : null;
} catch (RuntimeException ignored) { return null; }
}
private static Map<String, Object> pagination(int current, int size) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("current", current);
data.put("size", size);
return data;
}
private static void validatePagination(int current, int size, int maximum) {
if (current < 1 || size < 1 || size > maximum) throw badRequest();
}
private static void requireInput(String value, int maximum) {
if (value == null || value.isBlank() || value.length() > maximum) throw badRequest();
}
private static LingxingWmsException badRequest() {
return failure("INVALID_REQUEST", "领星 WMS 查询参数不符合内部契约", false);
}
private void validate(QueryRequest request) {
@@ -92,14 +227,6 @@ public final class LingxingWmsClient {
}
}
private void reserveRequest() {
long now = System.nanoTime();
if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) {
throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true);
}
hasRequested = true;
lastRequestNanos = now;
}
private HttpResponse<String> send(HttpRequest request) {
try {
@@ -115,7 +242,7 @@ public final class LingxingWmsClient {
}
private void validateStatus(int status) {
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null);
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流,账号已暂停", false, status, null);
if (status == 401 || status == 403) {
throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null);
}
@@ -124,10 +251,11 @@ public final class LingxingWmsClient {
}
}
private PageResult page(String body, QueryRequest request) {
private JsonNode envelope(String body) {
JsonNode root;
try {
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body);
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS,
DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body);
} catch (JsonProcessingException exception) {
throw invalidResponse("JSON", null);
}
@@ -153,23 +281,65 @@ public final class LingxingWmsClient {
}
JsonNode data = root.get("data");
if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200);
return data;
}
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) {
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", true, metadata, record -> {
String deliveryNo = requiredText(record, "deliveryNo", 128);
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
int status = requiredInt(record.get("status"), "STATUS");
if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200);
return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64),
requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"));
});
return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(),
page.sourceSize(), 200, page.observedAt(), metadata);
}
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) {
JsonNode records = data.get("records");
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
Long total = optionalTotal(data.get("total"));
if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200);
verifyPage(data.get("current"), request.current(), "CURRENT");
verifyPage(data.get("size"), request.size(), "SIZE");
List<OrderSummary> summaries = new ArrayList<>();
if (total == null) throw invalidResponse("TOTAL_MISSING", 200);
int sourceCurrent = requiredInt(data.get(currentField), "CURRENT");
int sourceSize = requiredInt(data.get(sizeField), "SIZE");
int pages = requiredInt(data.get("pages"), "PAGES");
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
long expectedPages = total / size + (total % size == 0 ? 0 : 1);
if (pages < 0 || pages != expectedPages || (total == 0 && current != 1)
|| (total > 0 && current > pages)) throw invalidResponse("PAGES_MISMATCH", 200);
long expectedRows = total == 0 ? 0 : Math.min(size, total - (long)(current - 1) * size);
if (records.size() != expectedRows) throw invalidResponse("PAGE_ROW_COUNT_MISMATCH", 200);
List<T> summaries = new ArrayList<>();
for (JsonNode record : records) {
if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200);
String deliveryNo = optionalText(record, "deliveryNo");
if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200);
summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"),
optionalText(record, "whCode"), optionalStatus(record.get("status")),
optionalText(record, "createTime"), optionalText(record, "outboundTime")));
if (!record.isObject()) throw invalidResponse("ROW_TYPE_" + nodeType(record), 200);
summaries.add(projection.apply(record));
}
return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant());
return new QueryPage<>(summaries, total, current, size, pages, sourceCurrent, sourceSize, 200, clock.instant(), metadata);
}
private static String requiredText(JsonNode row, String field, int maximum) {
String text = optionalText(row, field);
if (text == null || text.isBlank() || text.length() > maximum) throw invalidResponse("IDENTITY_INVALID", 200);
return text;
}
private static int requiredInt(JsonNode value, String stage) {
if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
return value.intValue();
}
private static BigDecimal quantity(JsonNode value) {
if (missingNumber(value)) return null;
if (value.isNumber()) return value.decimalValue();
// Nonempty malformed quantities are a contract failure, never invented zero.
throw invalidResponse("QUANTITY_TYPE_" + nodeType(value), 200);
}
private static String optionalText(JsonNode record, String field) {
@@ -190,14 +360,6 @@ public final class LingxingWmsClient {
return value.textValue();
}
private static Integer optionalStatus(JsonNode value) {
if (missingNumber(value)) return null;
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200);
}
return value.intValue();
}
private static Long optionalTotal(JsonNode value) {
if (missingNumber(value)) return null;
if (!value.isIntegralNumber() || !value.canConvertToLong()) {
@@ -207,14 +369,6 @@ public final class LingxingWmsClient {
return value.longValue();
}
private static void verifyPage(JsonNode value, int expected, String stage) {
if (missingNumber(value)) return;
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
}
if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200);
}
private static Integer diagnosticCode(JsonNode value) {
if (value == null) return null;
if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue();
@@ -237,6 +391,7 @@ public final class LingxingWmsClient {
}
private static LingxingWmsException sourceRejected(int code) {
if (code == 31001 || code == 429) return upstreamFailure("SOURCE_RATE_LIMITED", "领星 WMS 来源限流,账号已暂停", false, 200, code);
if (code == 11008) {
return upstreamFailure("AUTHORIZATION_REJECTED",
"领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code);
@@ -244,11 +399,11 @@ public final class LingxingWmsClient {
if (code == 11006) {
return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code);
}
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code);
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", false, 200, code);
}
private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) {
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")",
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合接口契约(" + stage + ")",
false, 200, sourceCode);
}
@@ -1,4 +1,6 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
/** Only fixed messages and numeric upstream metadata may leave the adapter. */
public final class LingxingWmsException extends RuntimeException {
@@ -6,6 +8,8 @@ public final class LingxingWmsException extends RuntimeException {
private final boolean retryable;
private final Integer upstreamStatus;
private final Integer sourceCode;
private AttemptMetadata egress;
private AuthorizationDenial denial;
public LingxingWmsException(String code, String message, boolean retryable,
Integer upstreamStatus, Integer sourceCode) {
@@ -21,4 +25,8 @@ public final class LingxingWmsException extends RuntimeException {
public boolean retryable() { return retryable; }
public Integer upstreamStatus() { return upstreamStatus; }
public Integer sourceCode() { return sourceCode; }
public AttemptMetadata egress() { return egress; }
public LingxingWmsException withEgress(AttemptMetadata metadata) { this.egress = metadata; return this; }
public AuthorizationDenial denial() { return denial; }
public LingxingWmsException withDenial(AuthorizationDenial metadata) { this.denial = metadata; return this; }
}
@@ -37,11 +37,20 @@ baiyue:
lingxing-wms:
enabled: ${LINGXING_WMS_ENABLED:false}
base-uri: https://api.xlwms.com
app-key: ${LINGXING_WMS_APP_KEY:}
app-secret: ${LINGXING_WMS_APP_SECRET:}
app-key: ${LINGXING_OMS_APP_KEY:}
app-secret: ${LINGXING_OMS_APP_SECRET:}
connect-timeout: 5s
request-timeout: 15s
min-request-interval: 1s
egress:
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
min-start-interval: 5s
cooldown-fallback: 5m
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
# Missing proof/authority fails closed even when the old enabled switch is true.
baidu-ocr:
enabled: false
amazon-sp-api:
@@ -38,11 +38,20 @@ baiyue:
lingxing-wms:
enabled: ${LINGXING_WMS_ENABLED:false}
base-uri: https://api.xlwms.com
app-key: ${LINGXING_WMS_APP_KEY:}
app-secret: ${LINGXING_WMS_APP_SECRET:}
app-key: ${LINGXING_OMS_APP_KEY:}
app-secret: ${LINGXING_OMS_APP_SECRET:}
connect-timeout: 5s
request-timeout: 15s
min-request-interval: 1s
egress:
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
min-start-interval: 5s
cooldown-fallback: 5m
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
# Missing proof/authority fails closed even when the old enabled switch is true.
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
api-key: ${BAIDU_OCR_API_KEY:}
@@ -2,14 +2,77 @@ openapi: 3.0.3
info:
title: Lingxing WMS Internal Read API
version: 1.0.0
description: Read-only provider adapter. No business scheduling, persistence, inventory mutation or public Gateway route.
description: >-
Single-page or single-order adapter. No scheduling, business persistence or public Gateway route.
Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone.
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
account window; BUSINESS requires environmentLabel exactly production. All five endpoints apply the same gate.
Missing exclusive proof, persistent account budget authorization or control context fails closed before egress.
At most one account request is in flight and HTTP request starts are separated by at least 5 seconds.
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries;
429 is never retried, immediately pauses the account and returns persistent cooldown metadata.
VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil;
the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is
recoverable only within the original valid exclusive window, after both the persistently returned
cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new
strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state.
Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost
or malformed authorization and uncertain completion hold an unrecoverable local pause until
operations verify persistent account state. Completion failure never automatically recovers.
A well-formed explicit authorization denial is deterministic and does not cause a permanent pause.
sourceAccount defaults to lingxing-primary and budgetAuthorityBaseUri defaults internally to
http://lastleg-service:9303; neither default enables egress.
x-lastleg-egress-coordination:
description: >-
Integration calls these Lastleg endpoints with X-Internal-Service-Token. These are dependencies,
not Integration Controller paths. Lastleg prepares attemptId server-side and binds context and operation
before authorization. authorize only consumes an already prepared attempt, first binds the opaque
requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings.
A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true
and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil,
and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and
AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose
a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress;
pending or ambiguous attempts remain conservatively consumed, including after restart.
authorize:
method: POST
path: /internal/lastleg/self-operated-analytics/egress/authorize
requestSchema: '#/components/schemas/AuthorizationRequest'
responseSchema: '#/components/schemas/AttemptPermitResponse'
complete:
method: POST
path: /internal/lastleg/self-operated-analytics/egress/complete
requestSchema: '#/components/schemas/CompletionRequest'
responseSchema: '#/components/schemas/CompletionResponse'
security:
- InternalServiceToken: []
paths:
/internal/integrations/lingxing/wms/warehouses/query:
post:
summary: Query one page of normal warehouses
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Calls /openapi/v2/warehouse/page without filters. Maps page/pageSize into current/size; preserves original metadata.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/WarehouseQueryRequest'}
responses:
'200':
description: Standard envelope with WarehousePage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/WarehouseResponse'}
/internal/integrations/lingxing/wms/outbound-orders/query:
post:
summary: Query one page of completed WMS outbound summaries by outbound time
description: Calls OMP /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. Source time strings are preserved. This is a single page, not a complete sales report. It uses the self-developed-system API authorization in the WMS management console.
summary: Query one page of completed outbound orders by source outbound time
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Calls /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. No external warehouse filter. At current=1,size=100 only, records=[],total=0,pages=0,sourceCurrent=1,sourceSize=10 is accepted and internal size remains 100. All other pagination metadata is strictly required. No completeness claim across pages.
requestBody:
required: true
content:
@@ -17,65 +80,363 @@ paths:
schema: {$ref: '#/components/schemas/QueryRequest'}
responses:
'200':
description: Standard envelope; code 200 for success, A0401 for rejected internal identity, A0422 for bean validation, B0001 or S0503 for provider/configuration failures. Failures never return a successful empty page.
description: Standard envelope with OrderPage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/Response'}
schema: {$ref: '#/components/schemas/OrderResponse'}
/internal/integrations/lingxing/wms/outbound-orders/locate:
post:
summary: Locate one page by delivery number without time or status filters
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Sends only current,size,deliveryNo to delivery/page. Every returned deliveryNo must match exactly. Caller reads all required pages and verifies original customer identity and uniqueness before accepting the current warehouse. Missing results do not prove cancellation.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/LocateRequest'}
responses:
'200':
description: Standard envelope with OrderPage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/OrderResponse'}
/internal/integrations/lingxing/wms/outbound-orders/detail:
post:
summary: Read one order using warehouse, customer and delivery identifiers
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Maps warehouseCode to whCode for delivery/detail. All three returned identifiers must match. Returns only skuVOList; packageList is never added to quantities. Duplicate SKU lines remain separate for caller normalization. Missing quantities remain null; no quantity unit is inferred.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/DetailRequest'}
responses:
'200':
description: Standard envelope with OrderDetail or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/DetailResponse'}
/internal/integrations/lingxing/wms/product-inventory/query:
post:
summary: Query one page of all qualities and customers in a required warehouse
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Calls stock/stockSku/page with whCode and isHideInventory=0. No SKU, quality, customer or quantity filters. Warehouse mismatch and duplicate business keys within a page fail closed. Caller validates cross-page keys and counts. Unknown quantities stay null, negative quantities remain evidence, stockCompare is not accumulated.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InventoryQueryRequest'}
responses:
'200':
description: Standard envelope with InventoryPage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/InventoryResponse'}
components:
parameters:
LingxingEgressContext:
name: X-Lingxing-Egress-Context
in: header
required: true
description: >-
JSON control context; no credentials. Maximum 2048 characters, all printable ASCII (32 through 126).
Unknown fields, duplicate fields and trailing JSON are rejected. Identifiers are strings supplied
by the server-side coordinator and do not confer authorization by themselves.
content:
application/json:
schema: {$ref: '#/components/schemas/EgressContext'}
securitySchemes:
InternalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
schemas:
EgressContext:
type: object
additionalProperties: false
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
properties:
sourceAccount: {type: string, minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Server-configured account alias, never App Key or Secret.}
executionPurpose: {type: string, enum: [VALIDATION, BUSINESS]}
rootTaskId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable root control identifier; child tasks share root budget.}
sessionId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable session control identifier; reopening or restart does not reset it.}
attemptId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Prepared unique single-attempt control identifier; duplicate authorization is refused.}
exclusiveGrantId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Must equal the valid externalExclusiveGrantId configured server-side.}
EgressRequestHash:
type: string
pattern: '^[0-9a-f]{64}$'
description: >-
Lowercase hexadecimal SHA256 of UTF-8 bytes of OMP path, one LF character and compact
insertion-ordered external business JSON. Lastleg treats this digest as opaque and binds
it on the first authorization of an already prepared attempt.
example: fa60a92c78b4803878930e5914e6db03591acc31b7c09abad53c91912fa3d627
x-preimage-example: |-
/openapi/v2/stock/stockSku/page
{"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0}
EgressOperation:
type: string
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY]
EgressMetadata:
type: object
required: [attemptId, actualHttpAttempts, accountPaused]
properties:
attemptId: {type: string, nullable: true, description: Null only if no valid control context was accepted.}
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1, description: Supplier HTTP attempts actually started, including failed calls; authorization callbacks do not count.}
accountPaused: {type: boolean}
cooldownUntil: {type: string, format: date-time, nullable: true, description: Account cooldown deadline to persist; valid supplier retry time must not be shortened.}
AuthorizationRequest:
type: object
required: [context, operation, requestHash]
properties:
context: {$ref: '#/components/schemas/EgressContext'}
operation: {$ref: '#/components/schemas/EgressOperation'}
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
AuthorizationDenial:
type: object
required: [errorCode, sampleDone]
properties:
errorCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'}
sampleDone: {type: boolean, description: Strict JSON boolean; validation budget normal completion is distinct from a failure.}
waitUntil: {type: string, format: date-time, nullable: true, description: Nullable ISO-8601 Instant from the persistent authority.}
AuthorizationDenialResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
code: {type: string, enum: [B0001]}
data: {$ref: '#/components/schemas/AuthorizationDenial'}
AttemptPermit:
type: object
required: [context, operation, requestHash, allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed]
properties:
context: {$ref: '#/components/schemas/EgressContext'}
operation: {$ref: '#/components/schemas/EgressOperation'}
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
allowedAttempts: {type: integer, enum: [1]}
expiresAt: {type: string, format: date-time, description: Must be after now and no later than now plus 30 seconds and configured externalExclusiveUntil.}
exclusiveConfirmed: {type: boolean, enum: [true]}
budgetConsumed: {type: boolean, enum: [true], description: Lastleg has atomically consumed persistent account and root/session budget before returning this permit.}
CompletionRequest:
type: object
required: [context, operation, requestHash, actualHttpAttempts, accountPaused]
properties:
context: {$ref: '#/components/schemas/EgressContext'}
operation: {$ref: '#/components/schemas/EgressOperation'}
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1}
errorCode: {type: string, nullable: true, description: Sanitized local error code; null on success.}
upstreamStatus: {type: integer, nullable: true}
sourceCode: {type: integer, nullable: true}
accountPaused: {type: boolean, description: True on 429 or explicit provider rate limiting; all account request families must pause.}
cooldownUntil: {type: string, format: date-time, nullable: true}
AttemptPermitResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
code: {type: string, enum: ['200', B0001]}
data:
oneOf:
- {$ref: '#/components/schemas/AttemptPermit'}
- {$ref: '#/components/schemas/AuthorizationDenial'}
description: Code 200 requires AttemptPermit; code B0001 requires AuthorizationDenial. Lost or malformed responses are uncertain and fail closed with a local pause.
CompletionResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data: {type: boolean, enum: [true], description: Successful acknowledgement confirms durable completion and cooldown persistence; any other result fails closed.}
WarehouseQueryRequest:
type: object
required: [current, size]
properties:
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
QueryRequest:
type: object
required: [startTime, endTime, current, size]
properties:
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime; must precede endTime and must not be earlier than endTime minus one calendar month. This is a local guard.}
endTime: {type: string, example: '2026-10-12T08:00:00', description: Source local datetime.}
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime. Must precede endTime and be no earlier than endTime minus one calendar month. No source timezone inference.}
endTime: {type: string, example: '2026-10-12T08:00:00'}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 100, description: Local request cap; not a claim about the provider quota. Use 1 for connectivity verification.}
size: {type: integer, minimum: 1, maximum: 100, description: Local request cap, not supplier quota.}
LocateRequest:
type: object
required: [deliveryNo, current, size]
properties:
deliveryNo: {type: string, minLength: 1, maxLength: 128}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
DetailRequest:
type: object
required: [warehouseCode, customerCode, deliveryNo]
properties:
warehouseCode: {type: string, minLength: 1, maxLength: 64}
customerCode: {type: string, minLength: 1, maxLength: 64}
deliveryNo: {type: string, minLength: 1, maxLength: 128}
InventoryQueryRequest:
type: object
required: [warehouseCode, current, size]
properties:
warehouseCode: {type: string, minLength: 1, maxLength: 64}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
Pagination:
type: object
required: [total, current, size, pages, sourceCurrent, sourceSize, sourceCode, observedAt, egress]
properties:
egress: {$ref: '#/components/schemas/EgressMetadata'}
total: {type: string, pattern: '^[0-9]+$', description: Required provider total. Long follows platform string serialization.}
current: {type: integer}
size: {type: integer}
pages: {type: integer, minimum: 0}
sourceCurrent: {type: integer}
sourceSize: {type: integer}
sourceCode: {type: integer, enum: [200]}
observedAt: {type: string, format: date-time}
OrderSummary:
type: object
required: [deliveryNo]
required: [deliveryNo, customerCode, whCode, status]
properties:
deliveryNo: {type: string}
sourceNo: {type: string, nullable: true}
customerCode: {type: string, nullable: true}
whCode: {type: string, nullable: true}
status: {type: integer, nullable: true}
customerCode: {type: string}
whCode: {type: string}
status: {type: integer}
createTime: {type: string, nullable: true}
outboundTime: {type: string, nullable: true}
PageResult:
Warehouse:
type: object
required: [records, current, size, sourceCode, observedAt]
required: [whCode, warehouseType, status]
properties:
whCode: {type: string}
whNameCn: {type: string, nullable: true}
warehouseType: {type: integer}
status: {type: integer}
timeZone: {type: string, nullable: true, description: Warehouse metadata only.}
SkuLine:
type: object
required: [productSku]
properties:
productSku: {type: string}
productName: {type: string, nullable: true}
actualDeliveryQty: {type: number, nullable: true, description: BigDecimal actual quantity, unknown is null.}
deliveryQty: {type: number, nullable: true, description: Forecast only; never a replacement for actual quantity.}
OrderDetail:
type: object
required: [deliveryNo, customerCode, whCode, status, skuVOList, sourceCode, observedAt, egress]
properties:
egress: {$ref: '#/components/schemas/EgressMetadata'}
deliveryNo: {type: string}
sourceNo: {type: string, nullable: true}
customerCode: {type: string}
whCode: {type: string}
status: {type: integer}
orderCreateTime: {type: string, nullable: true}
outboundTime: {type: string, nullable: true}
skuVOList: {type: array, minItems: 1, items: {$ref: '#/components/schemas/SkuLine'}}
sourceCode: {type: integer, enum: [200]}
observedAt: {type: string, format: date-time}
ProductStock:
type: object
required: [customerCode, whCode, productSku, productQuality]
properties:
customerCode: {type: string}
whCode: {type: string}
productSku: {type: string}
productName: {type: string, nullable: true}
productQuality: {type: integer}
stkQty: {type: number, nullable: true}
freezeQty: {type: number, nullable: true}
useStkQty: {type: number, nullable: true}
transitQty: {type: number, nullable: true}
innerBatchNo: {type: string, nullable: true}
outBatchNo: {type: string, nullable: true}
uniqueKey4tenant: {type: string, nullable: true}
OrderPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}}
total: {type: string, pattern: '^[0-9]+$', nullable: true, description: Provider total only when supplied. Java Long follows the platform JSON string convention.}
current: {type: integer}
size: {type: integer}
sourceCode: {type: integer}
observedAt: {type: string, format: date-time}
WarehousePage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/Warehouse'}}
InventoryPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/ProductStock'}}
ProviderFailure:
type: object
required: [errorCode, retryable]
required: [errorCode, retryable, egress]
properties:
egress: {$ref: '#/components/schemas/EgressMetadata'}
denial:
nullable: true
allOf:
- {$ref: '#/components/schemas/AuthorizationDenial'}
description: Well-formed deterministic authority denial, alongside egress; null for other failures.
errorCode: {type: string}
retryable: {type: boolean}
retryable: {type: boolean, description: Caller scheduling hint only; never authorizes implicit retries or reuse of an attempt. False for 429 and validation session failure.}
upstreamStatus: {type: integer, nullable: true}
sourceCode: {type: integer, nullable: true}
Response:
Envelope:
type: object
required: [code, message, data, timestamp]
properties:
code: {type: string}
message: {type: string, description: Sanitized local message; never the raw provider response.}
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/PageResult'}
- {$ref: '#/components/schemas/ProviderFailure'}
code: {type: string, description: '200 success; A0401 identity; A0422 validation; B0001 or S0503 provider/configuration failure.'}
message: {type: string, description: Sanitized fixed local message, never raw supplier content.}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
OrderResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/OrderPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
WarehouseResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/WarehousePage'}
- {$ref: '#/components/schemas/ProviderFailure'}
DetailResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/OrderDetail'}
- {$ref: '#/components/schemas/ProviderFailure'}
InventoryResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/InventoryPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
@@ -9,6 +9,8 @@ import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
@@ -35,6 +37,7 @@ class LingxingInternalControllerTest {
private static final String BODY = """
{"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1}
""";
private static final String CONTEXT = "{\"sourceAccount\":\"demo\",\"executionPurpose\":\"VALIDATION\",\"rootTaskId\":\"root-1\",\"sessionId\":\"session-1\",\"attemptId\":\"attempt-1\",\"exclusiveGrantId\":\"grant-1\"}";
private final String token = UUID.randomUUID().toString();
private LingxingWmsClient client;
private MockMvc mvc;
@@ -49,7 +52,7 @@ class LingxingInternalControllerTest {
try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) {
context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder);
}
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client))
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client, builder.build()))
.setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build()))
.setControllerAdvice(new GlobalExceptionHandler()).build();
}
@@ -64,13 +67,73 @@ class LingxingInternalControllerTest {
verifyNoInteractions(client);
}
@ParameterizedTest
@CsvSource(value = {
"/warehouses/query|{\"current\":1,\"size\":1000}",
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":1000}"
}, delimiter = '|')
void everyNewEndpointRequiresInternalIdentity(String suffix, String request) throws Exception {
String path = "/internal/integrations/lingxing/wms" + suffix;
for (String invalidToken : new String[] {"", "wrong", "Bearer user-jwt"}) {
mvc.perform(post(path).header("X-Internal-Service-Token", invalidToken)
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
}
verifyNoInteractions(client);
}
@ParameterizedTest
@CsvSource(value = {
"/outbound-orders/query|{\"startTime\":\"2026-09-12T08:00:00\",\"endTime\":\"2026-10-12T08:00:00\",\"current\":1,\"size\":1}",
"/warehouses/query|{\"current\":1,\"size\":10}",
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":10}"
}, delimiter = '|')
void everyEndpointRejectsMissingControlContextBeforeProvider(String suffix, String request) throws Exception {
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"))
.andExpect(jsonPath("$.data.egress.actualHttpAttempts").value(0));
verifyNoInteractions(client);
}
@Test
void controlHeaderRejectsUnknownDuplicateAndTrailingFields() throws Exception {
for (String header : new String[] {
CONTEXT.replace("\"sourceAccount\":", "\"extra\":1,\"sourceAccount\":"),
CONTEXT.replace("\"sourceAccount\":", "\"sourceAccount\":\"other\",\"sourceAccount\":"),
CONTEXT + " {}", " ", "x".repeat(2049), "{}", "null",
CONTEXT.replace("VALIDATION", "OFF"), CONTEXT.replace("root-1", "root with spaces")
}) {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", header)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"));
}
verifyNoInteractions(client);
}
@Test
void inventoryRequiresWarehouseAndDetailRequiresAllThreeIdentifiers() throws Exception {
for (String suffix : new String[] {"/product-inventory/query", "/outbound-orders/detail"}) {
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix)
.header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT).contentType(MediaType.APPLICATION_JSON)
.content("{\"current\":1,\"size\":1000,\"deliveryNo\":\"DO-DEMO\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
}
verifyNoInteractions(client);
}
@Test
void authenticatedReadReturnsOnlyOutboundSummary() throws Exception {
when(client.queryPage(any())).thenReturn(new PageResult(
when(client.queryPage(any(), any())).thenReturn(new PageResult(
List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100,
"2026-10-01 08:00:00", "2026-10-02 08:00:00")),
1L, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z")));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1"))
@@ -80,7 +143,7 @@ class LingxingInternalControllerTest {
@Test
void invalidPageCannotReachProvider() throws Exception {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0")))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
verifyNoInteractions(client);
@@ -88,9 +151,9 @@ class LingxingInternalControllerTest {
@Test
void providerFailureRemainsAnExplicitBusinessFailure() throws Exception {
when(client.queryPage(any())).thenThrow(new LingxingWmsException(
when(client.queryPage(any(), any())).thenThrow(new LingxingWmsException(
"LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED"))
@@ -0,0 +1,226 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.UnaryOperator;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LingxingEgressGateTest {
static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
static final String HASH = "a".repeat(64);
private final MutableClock clock = new MutableClock(NOW);
private final LedgerAuthority ledger = new LedgerAuthority(clock);
private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); }
static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); }
static LingxingEgressProperties props(CollectionMode mode, String environment) {
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null);
}
@Test void failureCompletionPreservesNullableTransportAndSourceCodes() {
for (Integer status : new Integer[] {null, 503}) {
var gate = gate();
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("nullable-" + status),
Operation.WAREHOUSES, HASH, handle -> {
handle.startHttp();
throw new LingxingWmsException("LINGXING_WMS_IO_FAILED", "固定失败摘要", true, status, null);
}));
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
var completion = ledger.completions.get(ledger.completions.size() - 1);
assertThat(completion.actualHttpAttempts()).isEqualTo(1);
assertThat(completion.upstreamStatus()).isEqualTo(status);
assertThat(completion.sourceCode()).isNull();
}
}
@Test void defaultOffAndNonProductionBusinessNeverAuthorize() {
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null);
assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF);
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF");
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "test"), ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> business.execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED");
assertThat(ledger.authorizations).isZero();
}
@Test void proofAndContextAreServerBoundBeforeAuthorization() {
var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", "a", "client-claimed-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "bad session", "a", "demo-grant"));
for (var c : contexts) assertThrows(LingxingWmsException.class, () -> gate().execute(c, Operation.WAREHOUSES, HASH, h -> true));
clock.advanceMillis(3600000);
assertThrows(LingxingWmsException.class, () -> gate().execute(context("a"), Operation.WAREHOUSES, HASH, h -> true));
assertThat(ledger.authorizations).isZero();
}
@Test void budgetPermitMustBeOneConsumedExclusiveUnexpiredAndExactlyBound() {
List<UnaryOperator<AttemptPermit>> mutations = List.of(
p -> null,
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 2, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant().plusSeconds(31), true, true),
p -> new AttemptPermit(context("different"), p.operation(), p.requestHash(), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), Operation.OUTBOUND_DETAIL, p.requestHash(), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), "b".repeat(64), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), false, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), true, false));
var calls = new AtomicInteger(); int n = 0;
for (var mutation : mutations) {
ledger.mutation = mutation; var gate = gate(); var c = context("attempt-" + ++n);
assertThatThrownBy(() -> gate.execute(c, Operation.WAREHOUSES, HASH, h -> calls.incrementAndGet())).hasMessageContaining("BUDGET_PERMIT_INVALID");
assertThatThrownBy(() -> gate.execute(context("next-" + c.attemptId()), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
}
assertThat(calls).hasValue(0); assertThat(ledger.completions).isEmpty();
}
@Test void everyAttemptIsPersistentlyConsumedAndCannotReplayEvenAcrossGateInstances() {
var c = context("single-use");
assertThat(gate().execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
assertThat(ledger.consumed).containsExactly("single-use");
var second = gate(); var sends = new AtomicInteger();
assertThatThrownBy(() -> second.execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return sends.incrementAndGet(); })).hasMessageContaining("BUDGET_AUTHORITY_UNAVAILABLE");
assertThat(sends).hasValue(0); assertThat(ledger.completions).hasSize(1);
}
@Test void handleCanStartOnlyOnceAndExpiredHandleCannotSend() {
var error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("once"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.startHttp(); return true; }));
assertThat(error.code()).endsWith("ATTEMPT_NOT_SENDABLE");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("expires"), Operation.WAREHOUSES, HASH,
h -> { clock.advanceMillis(30000); h.startHttp(); return true; }));
assertThat(error.egress().actualHttpAttempts()).isZero();
assertThat(ledger.completions).extracting(CompletionRequest::actualHttpAttempts).containsExactly(1, 0);
}
@Test void authorizationAndCompletionFailuresPauseAllFurtherCalls() {
ledger.failAuthorize = true; var gate = gate();
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("unavailable"), Operation.WAREHOUSES, HASH, h -> true));
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isTrue();
assertThatThrownBy(() -> gate.execute(context("next"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
ledger.failAuthorize = false; ledger.failComplete = true; var fresh = gate();
error = assertThrows(LingxingWmsException.class, () -> fresh.execute(context("callback"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
assertThat(error.code()).endsWith("BUDGET_COMPLETION_UNCONFIRMED");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
assertThatThrownBy(() -> fresh.execute(context("after-callback"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
}
@Test void startsAreSerializedAndSpacedByAtLeastFiveSeconds() throws Exception {
var gate = gate(); var executor = Executors.newFixedThreadPool(2);
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
var starts = new CopyOnWriteArrayList<Instant>();
try {
var first = executor.submit(() -> gate.execute(context("first"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); starts.add(clock.instant()); entered.countDown();
try { if (!release.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); }
catch (InterruptedException e) { throw new IllegalStateException(e); }
return true;
}));
assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue();
var secondReady = new CountDownLatch(1);
var second = executor.submit(() -> { secondReady.countDown(); return gate.execute(context("second"), Operation.OUTBOUND_DETAIL, HASH,
h -> { h.startHttp(); starts.add(clock.instant()); return true; }); });
assertThat(secondReady.await(2, TimeUnit.SECONDS)).isTrue();
assertThat(ledger.authorizations).isEqualTo(1); assertThat(second.isDone()).isFalse();
release.countDown(); assertThat(first.get(2, TimeUnit.SECONDS)).isTrue(); assertThat(second.get(2, TimeUnit.SECONDS)).isTrue();
assertThat(starts).hasSize(2); assertThat(Duration.between(starts.get(0), starts.get(1))).isGreaterThanOrEqualTo(Duration.ofSeconds(5));
assertThat(ledger.completions).hasSize(2);
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() {
assertThatThrownBy(() -> new LingxingEgressProperties(null, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class);
assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
}
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {
var gate = gate(); ledger.denial = new AuthorizationDenial("VALIDATION_TOTAL_LIMIT", true, null);
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("denied"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
assertThat(error.denial()).isEqualTo(ledger.denial);
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isFalse();
assertThat(ledger.consumed).isEmpty(); assertThat(ledger.completions).isEmpty();
ledger.denial = null;
assertThat(gate.execute(context("fresh-session-attempt"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
}
private static EgressContext businessContext(String attempt) {
return new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", attempt, "demo-grant");
}
private LingxingWmsException rateLimit() { return new LingxingWmsException("RATE_LIMIT", "synthetic rate limit", false, 429, null); }
@Test void businessCooldownRequiresFreshDurablePermitBeforeRecovery() {
var gate = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("limited"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(clock.instant().plusSeconds(600)); throw rateLimit();
}));
int used = ledger.authorizations;
assertThatThrownBy(() -> gate.execute(businessContext("too-early"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(used);
clock.advanceMillis(600000); ledger.denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, clock.instant().plusSeconds(60));
var denied = assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("still-denied"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; }));
assertThat(denied.egress().actualHttpAttempts()).isZero(); assertThat(denied.egress().accountPaused()).isTrue();
ledger.denial = null;
var accepted = gate.execute(businessContext("accepted"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return h.metadata(); });
assertThat(accepted.actualHttpAttempts()).isEqualTo(1); assertThat(accepted.accountPaused()).isFalse(); assertThat(accepted.cooldownUntil()).isNull();
assertThat(ledger.completions).hasSize(2);
}
@Test void committedRateCompletionWithLostAckReturnsUncertaintyInsteadOfRecoverableRateError() {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"production"),ledger,clock,clock::advanceMillis);
ledger.failComplete=true;
var error=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("rate-lost-ack"),Operation.WAREHOUSES,HASH,handle->{
handle.startHttp();handle.rateLimited(clock.instant().plusSeconds(600));throw rateLimit();
}));
assertThat(error.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);assertThat(error.egress().accountPaused()).isTrue();
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(600));
assertThat(ledger.completions).singleElement().satisfies(result->{
assertThat(result.upstreamStatus()).isEqualTo(429);assertThat(result.actualHttpAttempts()).isEqualTo(1);
assertThat(result.accountPaused()).isTrue();
});
clock.advanceMillis(601000);ledger.failComplete=false;
assertThatThrownBy(()->gate.execute(businessContext("after-uncertain-ack"),Operation.WAREHOUSES,HASH,handle->true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
}
@Test void validationRatePauseAndUnconfirmedCallbackNeverRecoverByTime() {
var gate = gate();
assertThrows(LingxingWmsException.class, () -> gate.execute(context("limited"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(null); throw rateLimit();
}));
clock.advanceMillis(600000);
assertThatThrownBy(() -> gate.execute(context("next"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
ledger.failComplete = true;
assertThrows(LingxingWmsException.class, () -> business.execute(businessContext("lost-complete"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(null); throw rateLimit();
}));
clock.advanceMillis(600000); ledger.failComplete = false;
assertThatThrownBy(() -> business.execute(businessContext("no-recover"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(2);
}
static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
synchronized void advanceMillis(long millis) { now = now.plusMillis(millis); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public synchronized Instant instant() { return now; }
}
/** Synthetic durable single-use ledger, shared between independently constructed gates. */
static class LedgerAuthority implements LingxingBudgetAuthority {
final Clock clock;
final Set<String> consumed = new LinkedHashSet<>();
final List<CompletionRequest> completions = new ArrayList<>();
volatile int authorizations;
boolean failAuthorize, failComplete;
AuthorizationDenial denial;
UnaryOperator<AttemptPermit> mutation = UnaryOperator.identity();
LedgerAuthority(Clock clock) { this.clock = clock; }
@Override public synchronized AttemptPermit authorize(AuthorizationRequest request) {
authorizations++;
if (denial != null) throw new LingxingBudgetDeniedException(denial);
if (failAuthorize || !consumed.add(request.context().attemptId())) throw new IllegalStateException("budget refused");
return mutation.apply(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, clock.instant().plusSeconds(30), true, true));
}
@Override public synchronized void complete(CompletionRequest request) {
completions.add(request); if (failComplete) throw new IllegalStateException("completion not durable");
}
}
}
@@ -0,0 +1,143 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.*;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Loopback authority only; the persisted collector ledger is tested by its own service. */
class LingxingHttpBudgetAuthorityTest {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final AtomicReference<String> response = new AtomicReference<>();
private final AtomicInteger status = new AtomicInteger(200);
private final AtomicInteger calls = new AtomicInteger();
private final AtomicReference<String> path = new AtomicReference<>(), token = new AtomicReference<>(), body = new AtomicReference<>();
private HttpServer server;
private static final String TOKEN = "synthetic-internal-token-0123456789";
private final AuthorizationRequest request = new AuthorizationRequest(LingxingEgressGateTest.context("attempt"), Operation.WAREHOUSES, LingxingEgressGateTest.HASH);
@BeforeEach void start() throws IOException {
response.set("{\"code\":\"200\",\"data\":true}");
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> {
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
token.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
exchange.getResponseHeaders().set("Location", "/redirect-must-not-follow");
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status.get(), bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
}
@AfterEach void stop() { server.stop(0); }
private LingxingHttpBudgetAuthority authority(String serviceToken) {
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null), mapper, serviceToken);
}
private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); }
private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1,
LingxingEgressGateTest.NOW.plusSeconds(30), true, true)); }
@Test void fixedInternalEndpointsAndTokenCarryExactAuthorizationAndCompletionContext() throws Exception {
response.set("{\"code\":\"200\",\"data\":" + permit() + "}");
var permit = authority(TOKEN).authorize(request);
assertThat(permit.context()).isEqualTo(request.context()); assertThat(permit.allowedAttempts()).isEqualTo(1);
assertThat(path.get()).isEqualTo("/internal/lastleg/self-operated-analytics/egress/authorize");
assertThat(token.get()).isEqualTo(TOKEN);
assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(request));
response.set("{\"code\":\"200\",\"data\":true}");
authority(TOKEN).complete(completion());
assertThat(path.get()).endsWith("/egress/complete"); assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(completion()));
assertThat(calls.get()).isEqualTo(2);
}
@ParameterizedTest @ValueSource(strings = {"false", "\"true\"", "1", "{}", "null"})
void completionRequiresLiteralBooleanAcknowledgment(String data) {
response.set("{\"code\":\"200\",\"data\":" + data + "}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"200", "\"201\"", "null"})
void envelopeRequiresExactStringSuccessCode(String code) {
response.set("{\"code\":" + code + ",\"data\":true}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(ints = {302, 401, 429, 503})
void statusErrorsNeverRetryOrFollowRedirectAndNeverEchoAuthorityBody(int code) {
status.set(code); response.set("private-fixture@example.test");
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.getCause()).isNull(); assertThat(error.getMessage()).doesNotContain("private-fixture", "synthetic-token");
assertThat(calls.get()).isEqualTo(1);
}
@Test void missingTokenPreventsAllAuthorityHttp() {
assertThrows(LingxingWmsException.class, () -> authority("").authorize(request));
assertThrows(LingxingWmsException.class, () -> authority(null).complete(completion()));
assertThat(calls.get()).isZero();
}
@Test void missingServerConfiguredAuthorityPreventsHttp() {
var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null), mapper, "synthetic-token");
assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero();
}
@Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception {
String valid = permit();
String[] invalid = {valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":2"),
valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":\"1\""),
valid.replace("\"exclusiveConfirmed\":true", "\"exclusiveConfirmed\":\"true\""),
valid.replace("\"budgetConsumed\":true", "\"budgetConsumed\":false"),
valid.substring(0, valid.length() - 1) + ",\"untrusted\":true}",
valid.replace("\"operation\":\"WAREHOUSES\"", "\"operation\":\"UNKNOWN\"")};
for (var data : invalid) {
response.set("{\"code\":\"200\",\"data\":" + data + "}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
}
assertThat(calls.get()).isEqualTo(invalid.length);
}
@Test void trailingTokensAndMalformedResponseFailClosed() {
for (String value : new String[] {"{\"code\":\"200\",\"data\":true} {}", "[]", "{"}) {
response.set(value); assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
}
assertThat(calls.get()).isEqualTo(3);
}
@Test void knownAuthorityDenialIsTypedAndDoesNotBecomeUnavailability() {
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"SESSION_EXPIRED\",\"sampleDone\":true,\"waitUntil\":null}}");
var error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.denial()).isEqualTo(new AuthorizationDenial("SESSION_EXPIRED", true, null));
assertThat(calls.get()).isEqualTo(1);
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:10:00Z\"}}");
error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.denial().waitUntil()).isEqualTo(LingxingEgressGateTest.NOW.plusSeconds(600));
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
}
@ParameterizedTest @ValueSource(strings = {
"{\"errorCode\":\"private@example.test\",\"sampleDone\":true,\"waitUntil\":null}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":\"true\",\"waitUntil\":null}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":\"invalid\"}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":null,\"extra\":true}"
})
void malformedDenialFailsAsUnavailability(String data) {
response.set("{\"code\":\"B0001\",\"data\":" + data + "}");
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.code()).endsWith("BUDGET_AUTHORITY_UNAVAILABLE");
assertThat(error.getMessage()).doesNotContain("private@example.test"); assertThat(calls.get()).isEqualTo(1);
}
@Test void allControlDtosRoundTripWithProductionTimeSerialization() throws Exception {
var permit = new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, LingxingEgressGateTest.NOW.plusSeconds(30), true, true);
var metadata = new AttemptMetadata("attempt", 1, true, LingxingEgressGateTest.NOW.plusSeconds(600));
var denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, metadata.cooldownUntil());
for (var dto : new Object[] {request.context(), request, permit, completion(), metadata, denial}) {
assertThat(mapper.readValue(mapper.writeValueAsString(dto), dto.getClass())).isEqualTo(dto);
}
}
}
@@ -1,261 +1,281 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.time.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Synthetic loopback transport only; no supplier or application services. */
class LingxingWmsClientTest {
private static final String APP_KEY = "local-test-app-key";
private static final String APP_SECRET = "local-test-app-secret";
private static final String PRIVATE_DATA = "customer-private@example.test";
private static final String PATH = "/openapi/v2/order/delivery/page";
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00.987Z");
private static final String EMPTY = "{\"code\":200,\"success\":true,\"data\":{\"total\":0,\"current\":1,\"size\":1,\"records\":[]}}";
private final ObjectMapper mapper = new ObjectMapper();
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
private static final String PRIVATE = "private-secret@example.test";
private static final String ORDER = """
{"deliveryNo":"DO-DEMO","sourceNo":"SRC-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO",
"status":100,"outboundTime":"2026-10-01 01:00:00"}
""";
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
private final AtomicInteger calls = new AtomicInteger();
private final AtomicInteger responseStatus = new AtomicInteger(200);
private final AtomicReference<String> responseBody = new AtomicReference<>(EMPTY);
private final AtomicReference<String> receivedBody = new AtomicReference<>();
private final AtomicReference<URI> receivedUri = new AtomicReference<>();
private final AtomicReference<String> receivedMethod = new AtomicReference<>();
private final AtomicInteger status = new AtomicInteger(200);
private final AtomicReference<String> response = new AtomicReference<>();
private final AtomicReference<String> body = new AtomicReference<>();
private final AtomicReference<String> path = new AtomicReference<>();
private HttpServer server;
@BeforeEach
void startServer() throws IOException {
private final AtomicInteger attempts = new AtomicInteger();
private final AtomicReference<String> retryAfter = new AtomicReference<>();
private final AtomicReference<CompletionRequest> completion = new AtomicReference<>();
@BeforeEach void start() throws IOException {
response.set(page("current", "size", 1, 1, 1, 1, ORDER));
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext(PATH, exchange -> {
calls.incrementAndGet();
receivedMethod.set(exchange.getRequestMethod());
receivedUri.set(exchange.getRequestURI());
receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = responseBody.get().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
exchange.sendResponseHeaders(responseStatus.get(), bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
server.createContext("/openapi/v2/", exchange -> {
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
if (retryAfter.get() != null) exchange.getResponseHeaders().set("Retry-After", retryAfter.get());
exchange.sendResponseHeaders(status.get(), bytes.length);
exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
}
@AfterEach
void stopServer() { server.stop(0); }
@Test
void sendsOmpWmsSignatureAndOutboundTimeFilterAndOnlyReturnsWhitelist() throws Exception {
responseBody.set("""
{"code":200,"success":true,"data":{"total":12,"current":1,"size":1,"records":[{
"deliveryNo":"DO-LOCAL-001","sourceNo":"OB-LOCAL-001","customerCode":"C-LOCAL",
"whCode":"WH-LOCAL","status":100,"createTime":"2026-09-21 10:11:12",
"outboundTime":"2026-09-22 20:21:22","email":"customer-private@example.test",
"remark":"local-test-app-secret","receiver":"PRIVATE_RECEIVER"
}]}}
""");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
var body = mapper.readTree(receivedBody.get());
String timestamp = Long.toString(NOW.getEpochSecond());
String dataValue = "{current=1, size=1, timeType=outboundTime, startTime=2026-09-12 08:00:00, endTime=2026-10-12 08:00:00, status=100}";
String input = APP_SECRET + PATH + "appKey" + APP_KEY + "data" + dataValue + "timestamp" + timestamp + APP_SECRET;
assertThat(body.path("sign").textValue()).isEqualTo(hmac(input));
assertThat(body.path("timestamp").textValue()).isEqualTo(timestamp).matches("\\d{10}");
assertThat(body.path("appKey").textValue()).isEqualTo(APP_KEY);
assertThat(body.path("data").toString()).isEqualTo("{\"current\":1,\"size\":1,\"timeType\":\"outboundTime\",\"startTime\":\"2026-09-12 08:00:00\",\"endTime\":\"2026-10-12 08:00:00\",\"status\":100}");
assertThat(receivedUri.get().getPath()).isEqualTo(PATH);
assertThat(receivedUri.get().getRawQuery()).isNull();
assertThat(receivedMethod.get()).isEqualTo("POST");
assertThat(receivedBody.get()).doesNotContain(APP_SECRET, "authcode", "reqTime", "access_token", "pageSize");
assertThat(result.total()).isEqualTo(12L);
@AfterEach void stop() { server.stop(0); }
@Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception {
var result = client().queryPage(query(1, 1), context());
var data = mapper.readTree(body.get()).path("data");
assertThat(data.size()).isEqualTo(6);
assertThat(data.path("timeType").asText()).isEqualTo("outboundTime");
assertThat(data.path("status").intValue()).isEqualTo(100);
assertThat(data.has("whCode")).isFalse();
assertThat(path.get()).isEqualTo("/openapi/v2/order/delivery/page");
assertThat(result.observedAt()).isEqualTo(NOW);
assertThat(result.sourceCode()).isEqualTo(200);
assertThat(result.records()).singleElement().satisfies(order -> {
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
assertThat(order.sourceNo()).isEqualTo("OB-LOCAL-001");
assertThat(order.customerCode()).isEqualTo("C-LOCAL");
assertThat(order.whCode()).isEqualTo("WH-LOCAL");
assertThat(order.status()).isEqualTo(100);
assertThat(order.createTime()).isEqualTo("2026-09-21 10:11:12");
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
});
String summary = mapper.writeValueAsString(result.records().get(0));
assertThat(mapper.readTree(summary).size()).isEqualTo(7);
assertThat(summary).doesNotContain(PRIVATE_DATA, APP_SECRET, "PRIVATE_RECEIVER");
assertThat(result.pages()).isEqualTo(1);
}
@Test
void emptySuccessfulPageHasNoInventedOrder() {
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
assertThat(calls).hasValue(1);
}
@Test
void verifiedWmsEnvelopeWithoutSuccessRequiresValidPage() {
responseBody.set("""
{"code":200,"data":{"total":1,"current":1,"size":1,"records":[{
"deliveryNo":"DO-LOCAL-001","status":100,"outboundTime":"2026-09-22 20:21:22"
}]}}
""");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.sourceCode()).isEqualTo(200);
assertThat(result.total()).isEqualTo(1L);
assertThat(result.records()).singleElement().satisfies(order -> {
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
assertThat(order.status()).isEqualTo(100);
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
});
assertThat(calls).hasValue(1);
}
@Test
void documentedEmptyNumericValuesRemainUnknown() {
responseBody.set("{\"code\":200,\"success\":true,\"data\":{\"total\":\"\",\"records\":[{\"deliveryNo\":\"DO-LOCAL\",\"status\":\"\",\"outboundTime\":\"\"}]}}");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.total()).isNull();
assertThat(result.records().get(0).status()).isNull();
@Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception {
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", "")));
var result = client().locate(new LocateRequest("DO-DEMO", 1, 1), context());
var data = mapper.readTree(body.get()).path("data");
assertThat(data.size()).isEqualTo(3);
assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse();
assertThat(result.records().get(0).status()).isEqualTo(99);
assertThat(result.records().get(0).outboundTime()).isEmpty();
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("DO-DEMO", "DO-DEMO-X")));
contract(() -> client().locate(new LocateRequest("DO-DEMO", 1, 1), context()), "LOCATE_DELIVERY_NO_MISMATCH");
}
@Test
void disabledAndMissingCredentialsNeverSendRequest() {
assertFailure(client(false, APP_KEY, APP_SECRET, Duration.ZERO), "DISABLED", false);
assertFailure(client(true, "", APP_SECRET, Duration.ZERO), "MISSING_CREDENTIALS", false);
assertFailure(client(true, APP_KEY, " ", Duration.ZERO), "MISSING_CREDENTIALS", false);
assertThat(calls).hasValue(0);
@Test void exactEmptyExceptionKeepsSourceSizeAndNeverAppliesToInventory() {
response.set(page("current", "size", 1, 10, 0, 0, ""));
var result = client().queryPage(query(1, 100), context());
assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero();
assertThat(result.size()).isEqualTo(100); assertThat(result.sourceSize()).isEqualTo(10);
contract(() -> client().queryPage(query(1, 99), context()), "SIZE_MISMATCH");
contract(() -> client().queryPage(query(2, 100), context()), "CURRENT_MISMATCH");
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 100), context()), "SIZE_MISMATCH");
response.set(page("current", "size", 1, 10, 1, 1, ORDER));
contract(() -> client().queryPage(query(1, 100), context()), "SIZE_MISMATCH");
}
@ParameterizedTest
@ValueSource(ints = {11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001})
void documentedStringAuthErrorsNeedNoDataAndDoNotEchoPayloadOrRetry(int sourceCode) {
responseBody.set("{\"code\":\"" + sourceCode + "\",\"msg\":\"" + APP_KEY + APP_SECRET + PRIVATE_DATA + "\"}");
String expected = sourceCode == 11008 ? "AUTHORIZATION_REJECTED"
: sourceCode == 11006 ? "SIGNATURE_REJECTED" : "SOURCE_REJECTED";
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), expected, sourceCode == 31001);
assertThat(error.sourceCode()).isEqualTo(sourceCode);
assertThat(error.upstreamStatus()).isEqualTo(200);
assertThat(calls).hasValue(1);
@Test void warehousesMapPageAndPageSizeWithoutTreatingTimezoneAsSource() throws Exception {
response.set(page("page", "pageSize", 1, 1000, 1, 1, """
{"whCode":"W-DEMO","whNameCn":"Demo","warehouseType":1,"status":0,"timeZone":"America/New_York","address":"private"}
"""));
var result = client().queryWarehouses(new WarehouseQueryRequest(1, 1000), context());
assertThat(result.sourceSize()).isEqualTo(1000);
assertThat(result.records().get(0).timeZone()).isEqualTo("America/New_York");
assertThat(mapper.readTree(body.get()).path("data").size()).isEqualTo(2);
assertThat(path.get()).isEqualTo("/openapi/v2/warehouse/page");
assertThat(mapper.writeValueAsString(result)).doesNotContain("address");
}
@Test
void falseBusinessSuccessIsNotAnEmptySuccess() {
responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}");
assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false);
@Test void detailPreservesDecimalLinesAndIgnoresPackageDuplicates() throws Exception {
response.set("""
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":100,
"orderCreateTime":"2026-09-01 01:00:00","outboundTime":"2026-10-01 01:00:00",
"skuVOList":[{"productSku":"SKU","actualDeliveryQty":2.123456,"deliveryQty":6},
{"productSku":"SKU","actualDeliveryQty":3,"deliveryQty":6}],
"packageList":[{"skuList":[{"actualDeliveryQty":"5"}]}],"email":"private"}}
""");
var result = client().queryDetail(detail(), context());
assertThat(result.skuVOList()).hasSize(2);
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isEqualByComparingTo("2.123456");
assertThat(result.orderCreateTime()).isEqualTo("2026-09-01 01:00:00");
assertThat(mapper.writeValueAsString(result.skuVOList())).doesNotContain("email", "packageList");
assertThat(mapper.readTree(body.get()).path("data").toString())
.isEqualTo("{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}");
response.set(response.get().replace("W-DEMO", "OTHER"));
contract(() -> client().queryDetail(detail(), context()), "DETAIL_IDENTITY_MISMATCH");
}
@ParameterizedTest
@ValueSource(ints = {401, 403, 429, 500, 302})
void httpFailuresDoNotEchoBodyOrRetry(int status) {
responseStatus.set(status);
responseBody.set(APP_KEY + APP_SECRET + PRIVATE_DATA);
String code = status == 429 ? "HTTP_RATE_LIMITED" : status == 401 || status == 403 ? "AUTHORIZATION_REJECTED" : "HTTP_FAILED";
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), code, status == 429 || status >= 500);
assertThat(error.upstreamStatus()).isEqualTo(status);
assertThat(calls).hasValue(1);
@Test void cancelledDetailRetainsUnknownQuantityAndEmptyTime() {
response.set("""
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":99,
"outboundTime":"","skuVOList":[{"productSku":"SKU","actualDeliveryQty":"","deliveryQty":1}]}}
""");
var result = client().queryDetail(detail(), context());
assertThat(result.status()).isEqualTo(99); assertThat(result.outboundTime()).isEmpty();
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isNull();
}
@Test
void malformedResponsesExposeFixedContractStageOnly() {
String[][] scenarios = {
{"<html>" + PRIVATE_DATA + "</html>", "JSON"},
{"[]", "ROOT_TYPE_ARRAY"},
{"{}", "CODE_TYPE_MISSING"},
{"{\"code\":\"200\"}", "CODE_TYPE_STRING"},
{"{\"code\":\"99999\"}", "CODE_TYPE_STRING"},
{"{\"code\":200}", "DATA_TYPE_MISSING"},
{"{\"code\":200,\"data\":{}}", "RECORDS_TYPE_MISSING"},
{"{\"code\":200,\"success\":null}", "SUCCESS_TYPE_NULL"},
{"{\"code\":200,\"success\":\"true\"}", "SUCCESS_TYPE_STRING"},
{"{\"code\":200,\"success\":true,\"data\":[]}", "DATA_TYPE_ARRAY"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":{}}}", "RECORDS_TYPE_OBJECT"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{},{}]}}", "RECORDS_OVER_PAGE_SIZE"},
{"{\"code\":200,\"success\":true,\"data\":{\"total\":\"0\",\"records\":[]}}", "TOTAL_TYPE_STRING"},
{"{\"code\":200,\"success\":true,\"data\":{\"current\":2,\"records\":[]}}", "CURRENT_MISMATCH"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":1}]}}", "DELIVERY_NO_TYPE_NUMBER"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":\"A\",\"status\":\"100\"}]}}", "STATUS_TYPE_STRING"}
@Test void inventoryPreservesIndependentNumbersAndUnknownsAndRejectsDuplicates() throws Exception {
String stock = """
{"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU","productQuality":0,
"stkQty":120.123456,"freezeQty":20,"useStkQty":100,"transitQty":30,"innerBatchNo":"",
"stockCompare":{"stkQty":999},"phone":"private"}
""";
response.set(page("current", "size", 1, 1000, 1, 1, stock));
var result = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context());
assertThat(mapper.readTree(body.get()).path("data").toString())
.isEqualTo("{\"current\":1,\"size\":1000,\"whCode\":\"W-DEMO\",\"isHideInventory\":0}");
var row = result.records().get(0);
assertThat(row.stkQty()).isEqualByComparingTo("120.123456");
assertThat(row.useStkQty()).isEqualByComparingTo("100"); assertThat(row.transitQty()).isEqualByComparingTo("30");
assertThat(row.innerBatchNo()).isEmpty();
assertThat(mapper.writeValueAsString(result.records())).doesNotContain("phone", "stockCompare");
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("120.123456", "0")
.replace("\"freezeQty\":20", "\"freezeQty\":\"\"").replace("\"useStkQty\":100", "\"useStkQty\":null")
.replace("\"transitQty\":30", "\"transitQty\":-1")));
row = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()).records().get(0);
assertThat(row.stkQty()).isZero(); assertThat(row.freezeQty()).isNull(); assertThat(row.useStkQty()).isNull();
assertThat(row.transitQty()).isEqualByComparingTo("-1");
response.set(page("current", "size", 1, 1000, 2, 1, stock + "," + stock));
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "DUPLICATE_INVENTORY_KEY");
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("W-DEMO", "OTHER")));
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "INVENTORY_WAREHOUSE_MISMATCH");
}
@ParameterizedTest @ValueSource(strings = {"total", "pages", "current", "size"})
void missingPaginationIsRejected(String field) {
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"" + field + "\":1,", ""));
contract(() -> client().queryPage(query(1, 1), context()), "");
}
@Test void shortPageAndWrongPageCountAreRejected() {
response.set(page("current", "size", 1, 2, 2, 1, ORDER));
contract(() -> client().queryPage(query(1, 2), context()), "PAGE_ROW_COUNT_MISMATCH");
response.set(page("current", "size", 1, 1, 1, 2, ORDER));
contract(() -> client().queryPage(query(1, 1), context()), "PAGES_MISMATCH");
}
@ParameterizedTest @ValueSource(ints = {401, 403, 302})
void nonretryableHttpNeverEchoesOrRetries(int code) {
status.set(code); response.set(PRIVATE);
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(error.retryable()).isFalse(); assertThat(error.getCause()).isNull();
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(ints = {429, 500, 503})
void everyFailureHasOneHttpAttemptAndNeverImplicitRetry(int code) {
status.set(code); response.set(PRIVATE);
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(calls.get()).isEqualTo(1);
assertThat(error.getMessage()).doesNotContain(PRIVATE);
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
assertThat(completion.get().actualHttpAttempts()).isEqualTo(1);
assertThat(completion.get().upstreamStatus()).isEqualTo(code);
}
@ParameterizedTest @ValueSource(strings = {"600", "Mon, 12 Oct 2026 00:10:00 GMT", "invalid"})
void rateLimitPausesAccountAcrossAllEntrypointsAndReportsCooldown(String header) {
status.set(429); response.set(PRIVATE); retryAfter.set(header);
var client = client();
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query(1, 1), context()));
assertThat(error.egress().accountPaused()).isTrue();
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(header.equals("invalid") ? 300 : 600));
assertThat(completion.get().accountPaused()).isTrue();
assertThat(completion.get().cooldownUntil()).isEqualTo(error.egress().cooldownUntil());
assertThatThrownBy(() -> client.locate(new LocateRequest("DO-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryWarehouses(new WarehouseQueryRequest(1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(calls.get()).isEqualTo(1);
}
@Test void validationRejectsLargePageBeforeHttpOrBudget() {
var c = client(CollectionMode.VALIDATION, true, true);
assertThatThrownBy(() -> c.queryPage(query(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.locate(new LocateRequest("DO-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.queryWarehouses(new WarehouseQueryRequest(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@Test void validationNeverRetriesEvenServerError() {
status.set(503); response.set(PRIVATE);
assertThrows(LingxingWmsException.class, () -> client(CollectionMode.VALIDATION, true, true)
.queryPage(query(1, 1), context(CollectionMode.VALIDATION)));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"31001", "\"429\""})
void businessRateLimitCodesPauseWithoutHttpRetry(String sourceCode) {
response.set("{\"code\":" + sourceCode + ",\"msg\":\"" + PRIVATE + "\"}");
var c = client();
var error = assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
assertThat(completion.get().sourceCode()).isIn(31001, 429);
assertThatThrownBy(() -> c.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(calls.get()).isEqualTo(1); assertThat(error.getMessage()).doesNotContain(PRIVATE);
}
@Test void allFiveEntrypointsRequireControlContext() {
var c = client();
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), null));
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), null));
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@Test void allFiveEntrypointsFailClosedForOffMissingGrantOrBudget() {
for (var c : new LingxingWmsClient[] {client(CollectionMode.OFF, true, true),
client(CollectionMode.BUSINESS, false, true), client(CollectionMode.BUSINESS, true, false)}) {
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), context()));
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context()));
}
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@ParameterizedTest @ValueSource(strings = {"\"11006\"", "11008", "\"200\"", "null"})
void rejectedEnvelopesNeverEchoRawMessage(String code) {
response.set("{\"code\":" + code + ",\"msg\":\"" + PRIVATE + "\"}");
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(error.getCause()).isNull();
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"false", "null", "\"true\""})
void presentSuccessMustBeBooleanTrue(String success) {
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success));
assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
}
private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); }
private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) {
var clock = new LingxingEgressGateTest.MutableClock(NOW);
var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
"synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2));
var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null,
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null);
var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) {
@Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); }
};
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
for (String[] scenario : scenarios) {
responseBody.set(scenario[0]);
var error = assertFailure(client, "INVALID_RESPONSE", false);
assertThat(error.getMessage()).contains("(" + scenario[1] + ")");
assertThat(error.upstreamStatus()).isEqualTo(200);
var gate = new LingxingEgressGate(props, ledger, clock, clock::advanceMillis);
return new LingxingWmsClient(p, mapper, clock, gate);
}
private EgressContext context() { return context(CollectionMode.BUSINESS); }
private EgressContext context(CollectionMode mode) {
return new EgressContext("demo-account", mode, "demo-root", "demo-session", "attempt-" + attempts.incrementAndGet(), "demo-grant");
}
@Test
void localRateLimitStopsSecondRequest() {
var client = client(true, APP_KEY, APP_SECRET, Duration.ofMinutes(1));
client.queryPage(query());
assertFailure(client, "LOCAL_RATE_LIMITED", true);
assertThat(calls).hasValue(1);
private static QueryRequest query(int current, int size) {
var end = LocalDateTime.of(2026, 10, 12, 8, 0); return new QueryRequest(end.minusMonths(1), end, current, size);
}
@Test
void validatesLocalPaginationAndMonthBeforeSending() {
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
var query = query();
for (var invalid : new QueryRequest[] {
new QueryRequest(query.startTime(), query.endTime(), 0, 1),
new QueryRequest(query.startTime(), query.endTime(), 1, 101),
new QueryRequest(query.endTime(), query.startTime(), 1, 1),
new QueryRequest(query.startTime().minusSeconds(1), query.endTime(), 1, 1),
new QueryRequest(null, query.endTime(), 1, 1)
}) assertThatThrownBy(() -> client.queryPage(invalid)).isInstanceOf(LingxingWmsException.class);
assertThat(calls).hasValue(0);
private static DetailRequest detail() { return new DetailRequest("W-DEMO", "C-DEMO", "DO-DEMO"); }
private static String page(String c, String s, int current, int size, long total, int pages, String rows) {
return "{\"code\":200,\"data\":{\"" + c + "\":" + current + ",\"" + s + "\":" + size
+ ",\"total\":" + total + ",\"pages\":" + pages + ",\"records\":[" + rows + "]}}";
}
private LingxingWmsException assertFailure(LingxingWmsClient client, String code, boolean retryable) {
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query()));
assertThat(error.code()).isEqualTo("LINGXING_WMS_" + code);
assertThat(error.retryable()).isEqualTo(retryable);
assertThat(error.getCause()).isNull();
StringWriter trace = new StringWriter();
error.printStackTrace(new PrintWriter(trace));
assertThat(trace.toString()).doesNotContain(APP_KEY, APP_SECRET, PRIVATE_DATA, "sign=", "http://");
return error;
}
private LingxingWmsClient client(boolean enabled, String key, String secret, Duration interval) {
var properties = new LingxingWmsProperties(enabled, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
key, secret, Duration.ofSeconds(2), Duration.ofSeconds(2), interval);
return new LingxingWmsClient(properties, mapper, Clock.fixed(NOW, ZoneOffset.UTC));
}
private static QueryRequest query() {
LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0);
return new QueryRequest(end.minusMonths(1), end, 1, 1);
}
private static String hmac(String input) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
private static void contract(Runnable action, String stage) {
var error = assertThrows(LingxingWmsException.class, action::run);
assertThat(error.code()).isEqualTo("LINGXING_WMS_INVALID_RESPONSE");
assertThat(error.getMessage()).contains(stage).doesNotContain(PRIVATE); assertThat(error.retryable()).isFalse();
}
}
@@ -70,16 +70,15 @@ class LingxingWmsSignerTest {
assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com"));
assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15));
assertThat(properties.minRequestInterval()).isEqualTo(Duration.ofSeconds(1));
assertThat(properties.toString()).doesNotContain("example-key", "example-secret");
}
@Test
void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() {
var properties = new LingxingWmsProperties(false, null, null, null, null, null, null);
var properties = new LingxingWmsProperties(false, null, null, null, null, null);
assertThat(properties.enabled()).isFalse();
assertThat(properties.hasCredentials()).isFalse();
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null))
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null))
.isInstanceOf(IllegalArgumentException.class);
}
+1
View File
@@ -22,6 +22,7 @@
| 功能 | 首个入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` |
| 自营领星统计 | `selfoperated/analytics/`;平台入口 `controller/PlatformAnalyticsController.java`,契约见 `../../docs/自营业务/README.md`;权限源 `../iam/src/main/resources/db/selfoperated-analytics-permissions.sql`。 |
| 订单查询与控制 / 导出 | `controller/LastlegOrderController.java`;异步导出见 `export/` 与 `lastleg-exports-v1.yaml` |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` |
@@ -7,11 +7,23 @@
现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。
- 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。
- 25条已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。
- 已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。
- 未审阅的新增类引用仍按原切片门禁拦截;车辆应用层的向内依赖规则对所有引用执行,不能通过逐类记录绕过。fleet继续只消费车辆应用端口/服务,不读取车辆持久化适配器。
- 主源码禁止包循环。`DeliveryDocumentSnapshot` 是DTO快照解析和摘要装配,已从 `delivery/model` 移至 `delivery/service`,消除原DTO与model互相引用;枚举及公开JSON结构不变。不为循环设长期例外。
- 离线反向测试覆盖未授权新消费者、退役类型恢复/引用、通配记录、绕过车辆应用层和新增包环。通过门禁只证明这些结构规则,不证明消除了所有存量耦合或完成部署/业务验收。
### 预约、车队与导出依赖收口(2026-10-12)
- 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。
- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号保持原 LB/TR 规则,由本域生成;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。
- 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。
- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。
- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。
### 平台自营统计边界
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
### 车次接入边界
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
@@ -13,6 +13,7 @@ const sourceRoots = {
const internalPrefix = 'com.baiyue.lastleg.';
const sliceRules = [
['selfoperated-analytics', /^com\.baiyue\.lastleg\.selfoperated\.analytics(?:\.|$)/],
['fleet', /^com\.baiyue\.lastleg\.fleet(?:\.|$)/],
['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/],
['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/],
@@ -10,6 +10,27 @@ const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
const report = analyze();
const prefix = 'com.baiyue.lastleg.selfoperated.analytics.';
const classes = report.classes.filter(entry => entry.fqcn.startsWith(prefix));
assert.ok(classes.length > 0);
assert.ok(classes.some(entry => entry.packageName.endsWith('.inventory')));
assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound')));
assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics'));
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics'
|| edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }]);
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{
source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard',
}]);
assert.deepEqual(report.packageSccs.filter(cycle => cycle.some(pkg => pkg.startsWith(prefix))), []);
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const source = classes.find(entry => entry.packageName.endsWith('.inventory')).fqcn;
const target = 'com.baiyue.lastleg.inventory.query.service.InventoryQueryService';
report.classDependencies.push({ source, target });
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`)));
});
test('fleet stays a business slice and its policies have no infrastructure dependencies', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.fqcn.startsWith('com.baiyue.lastleg.fleet.'));
@@ -30,6 +51,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'),
'dispatch must use fleet ports and guards, not its persistence mappers');
@@ -42,6 +67,7 @@ function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
encoding: 'utf8',
maxBuffer: 8 * 1024 * 1024,
}));
}
@@ -85,7 +111,10 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat
target: 'com.baiyue.lastleg.security.OverseasPermissions',
}]);
assert.ok(incoming.length > 0);
assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.')));
assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.')
|| edge.source === 'com.baiyue.lastleg.export.LastlegExportProviderService'
&& [vehiclePrefix + '.application.VehicleApplicationService', vehiclePrefix + '.application.model.VehicleModels'].includes(edge.target)),
'vehicle consumers use application contracts; the export composition has two exact read-only references');
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith(vehiclePrefix))), false);
assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false);
assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false);
@@ -126,6 +155,22 @@ test('reviewed references do not allow another consumer into the same slice', ()
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`)));
});
test('export composition approvals do not grant persistence access or new consumers', () => {
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const source = 'com.baiyue.lastleg.export.LastlegExportProviderService';
const cases = [
{ source, target: vehiclePrefix + '.adapter.out.persistence.VehicleMapper' },
{ source, target: 'com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper' },
{ source: 'com.baiyue.lastleg.export.LastlegExportColumns', target: 'com.baiyue.lastleg.fleet.quote.FleetQuoteService' },
];
for (const edge of cases) {
const report = analyze();
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)),
'each unreviewed consumer/target pair must still fail');
}
});
test('restoring a retired projection or importing its missing type still fails', () => {
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const report = analyze();
@@ -1,42 +1,6 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Objects;
/** Called by actual incoming materialization, never as a migration or historical data repair. */
@Service
public class AppointmentSourceLinker {
private final LastlegOrderMapper orders;
private final InboundFbaAppointmentItemMapper items;
private final AppointmentExternalSourceGateway bookings;
public AppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;}
@Transactional(propagation=Propagation.MANDATORY)
public void materialized(String customerId,String bookingNo,Long orderId) {
var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo);
bookings.lockForAppointment(List.of(ref));
var matched=bookings.resolve(List.of(ref));
if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对");
var order=orderId==null?null:orders.selectById(orderId);
if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物");
var rows=items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo)
.eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE"));
for(var row:rows) {
if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑");
if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue;
try {items.update(null,new LambdaUpdateWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true)
.set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));}
catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");}
}
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
/** Associates a newly materialized source with its receiving appointment in the caller's transaction. */
public interface AppointmentSourceLinker {
void materialized(String customerId, String bookingNo, Long orderId);
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.delivery.fleet;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.baiyue.lastleg.fleet.business.FleetPoolDeliveryEvidence;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashSet;
/** Same-transaction delivery proof; no persistence model escapes into fleet. */
@Component
public class FleetPoolDeliveryAdapter implements FleetPoolDeliveryEvidence {
private final DeliverySourceEvidenceMapper evidence;
public FleetPoolDeliveryAdapter(DeliverySourceEvidenceMapper evidence){this.evidence=evidence;}
@Override public boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages) {
var dispatchIds=evidence.dispatchIds(customerId,orderId);
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
for(Long dispatchId:dispatchIds){
if(dispatchId==null||dispatchId<=0)return false;
var d=evidence.deliveries(customerId,dispatchId);var p=evidence.picks(customerId,dispatchId);
var r=evidence.returns(customerId,dispatchId);
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
}
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(incomingNo,orderId,expectedPackages),
deliveries,picks,returns,true).fullySigned();
}
}
@@ -1,4 +1,5 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.SignedDelivery;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
@@ -115,7 +116,9 @@ public class DeliveryDocumentService {
orders.updateById(order);
if (autoCompleted) {
var completion = completeRelatedOrders(order, operatorId, now);
fleetPool.signed(order, completion.sourcePackages(), operatorId, now);
fleetPool.signed(new SignedDelivery(
order.getCustomerId(),order.getDispatchNo(),order.getDeliveryNo(),"DELIVERED".equals(order.getStatus()),order.getSignedAt()),
completion.sourcePackages(),operatorId,now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
customerVisits.ensureForCompletedDelivery(order, now);
}
@@ -0,0 +1,6 @@
package com.baiyue.lastleg.fleet.business;
/** Delivery owns source attribution, returns and full-signature proof. */
public interface FleetPoolDeliveryEvidence {
boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages);
}
@@ -1,15 +1,12 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -30,13 +27,13 @@ public class FleetPoolOrderBridge {
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final LastlegOrderNoGenerator numbers;
private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json;
private final DeliverySourceEvidenceMapper deliveryEvidence;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence,
UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
@@ -111,7 +108,7 @@ public class FleetPoolOrderBridge {
if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,FleetBookingIncomingGateway.Evidence evidence,int palletSpaces,String operator) {
public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
if(evidence==null||!Objects.equals(row.getIncomingNo(),evidence.incomingNo())
||!Objects.equals(row.getWarehouseId(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey())
@@ -157,33 +154,19 @@ public class FleetPoolOrderBridge {
}
}
@Transactional(propagation=Propagation.MANDATORY)
public void signed(DeliveryOrder delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.getDispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.getCustomerId(),delivery.getDispatchNo())) {
public void signed(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.dispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue;
if(!"IN_TRANSIT".equals(row.getFulfillmentStage()))throw conflict("商业单须实际出库后才能签收完成");
if("DELIVERED".equals(delivery.getStatus())&&delivery.getSignedAt()!=null&&wholeSourceSigned(row))
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.getDeliveryNo(),at);
if(delivery.delivered()&&delivery.signedAt()!=null&&wholeSourceSigned(row))
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at);
}
}
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
var dispatchIds=deliveryEvidence.dispatchIds(row.getCustomerId(),row.getLastlegOrderId());
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
for(Long dispatchId:dispatchIds) {
if(dispatchId==null||dispatchId<=0)return false;
var d=deliveryEvidence.deliveries(row.getCustomerId(),dispatchId);
var p=deliveryEvidence.picks(row.getCustomerId(),dispatchId);
var r=deliveryEvidence.returns(row.getCustomerId(),dispatchId);
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
}
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(row.getIncomingNo(),
row.getLastlegOrderId(),row.getActualPackageCount()),deliveries,picks,returns,true).fullySigned();
return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount());
}
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.business;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
public final class FleetPoolOrderModels {
private FleetPoolOrderModels() { }
@@ -13,6 +14,14 @@ public final class FleetPoolOrderModels {
BigDecimal weightKg, BigDecimal volumeCbm, String currency, String contactName, String contactPhone,
String remark) { }
public record BusinessOrderRef(String businessType, String businessOrderNo) { }
/** Receiving facts needed by commercial fulfillment, independent of trip planning DTOs. */
public record ReceivedPallet(String warehousePalletId,int packages) { }
public record Receipt(String incomingNo,String warehouseId,String destinationKey,String status,
List<ReceivedPallet> pallets,int receivedPackages) {
public Receipt { pallets=List.copyOf(pallets); }
}
public record SignedDelivery(String customerId,String dispatchNo,String deliveryNo,
boolean delivered,LocalDateTime signedAt) { }
public record OrderKey(String customerId, String businessType, long businessOrderId) { }
public record Arrangement(String sourceType, String businessType, String businessOrderNo, String bookingNo,
String tripNo, Instant plannedDepartureAt, String originWarehouseName, String destinationLabel,
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.business;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.regex.Pattern;
/** Commercial pool orders own LB/TR numbering; no tail-leg order sequence is consumed. */
@Component
public class FleetPoolOrderNumberGenerator {
private static final Pattern LOCATION=Pattern.compile("LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD");
public String next(String businessType,String destination,long uniqueId){
String prefix=switch(businessType){case "TRUCK"->"TR";case "LOOSEBOARD"->"LB";default->throw new IllegalArgumentException("Unsupported pool business type");};
return prefix+"-"+location(destination)+"-"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+"-"+String.format(Locale.ROOT,"%06d",Math.floorMod(uniqueId,1_000_000));
}
private static String location(String value){
String code=value==null?"":value.trim().toUpperCase(Locale.ROOT);var match=LOCATION.matcher(code);
if(Pattern.matches(".*("+LOCATION.pattern()+").*",code)&&match.find())return match.group();
return code.matches("[A-Z0-9]{2,8}")?code:"WH";
}
}
@@ -1,4 +1,6 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Receipt;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.ReceivedPallet;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
@@ -168,7 +170,10 @@ public class FleetBookingService {
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),confirmed,totalSpaces,operator);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt(
confirmed.incomingNo(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),
confirmed.pallets().stream().map(p->new ReceivedPallet(p.warehousePalletId(),p.packages())).toList(),
confirmed.receivedPackages()),totalSpaces,operator);
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
}
@Transactional
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.appointmentplanning;
package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
@@ -10,6 +10,8 @@ import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/appointments")
@@ -19,13 +21,17 @@ public class AppointmentSourceCreateController {
@PostMapping
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')")
public ApiResponse<FbaAppointmentResponse> create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody SourceFbaAppointmentRequest request) {
AppointmentPlanningController.requirePlatform(user);
requirePlatform(user);
return ApiResponse.success(service.createFromSources(String.valueOf(user.userId()),request));
}
@GetMapping("/unscheduled")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public ApiResponse<PageResult<FbaAppointmentResponse>> unscheduled(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) {
AppointmentPlanningController.requirePlatform(user);
requirePlatform(user);
return ApiResponse.success(service.unscheduled(page,size));
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
}
@@ -1,6 +1,5 @@
package com.baiyue.lastleg.inboundplan.dto;
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
@@ -11,4 +10,6 @@ public record SourceFbaAppointmentRequest(
@NotBlank @Size(max=64) String fbaWarehouseCode,
@NotNull @Valid FbaAppointmentDetails details,
@NotBlank @Size(max=64) String idempotencyKey
) { }
) {
public record SourceRef(String customerId, String sourceKey, String destinationKey) { }
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker;
import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Objects;
/** Called by actual incoming materialization, never as a migration or historical data repair. */
@Service
public class InboundAppointmentSourceLinker implements AppointmentSourceLinker {
private final LastlegOrderMapper orders;
private final InboundFbaAppointmentItemMapper items;
private final AppointmentExternalSourceGateway bookings;
public InboundAppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;}
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void materialized(String customerId,String bookingNo,Long orderId) {
var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo);
bookings.lockForAppointment(List.of(ref));
var matched=bookings.resolve(List.of(ref));
if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对");
var order=orderId==null?null:orders.selectById(orderId);
if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物");
var rows=items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo)
.eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE"));
for(var row:rows) {
if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑");
if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue;
try {items.update(null,new LambdaUpdateWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true)
.set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));}
catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");}
}
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -66,7 +66,10 @@ public class InboundFbaAppointmentService {
}
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse createFromSources(String operatorId,SourceFbaAppointmentRequest request) {
return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,request.sources(),request.originWarehouseId(),request.fbaWarehouseCode(),request.details()));
return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,
request.sources()==null?null:request.sources().stream().map(source->source==null?null:
new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList(),
request.originWarehouseId(),request.fbaWarehouseCode(),request.details()));
}
private FbaAppointmentResponse create(String operator,List<AppointmentCoverageService.SourceRef> refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) {
var resolved=resolveForWrite(refs,null);
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.selfoperated.analytics.client;
/** Fixed diagnostic only: never retain provider bodies, token or arbitrary messages. */
public final class AnalyticsClientException extends RuntimeException {
private final String code;
private final boolean retryable;
public AnalyticsClientException(String code, boolean retryable) {
super(code);
this.code = code;
this.retryable = retryable;
}
public String code() { return code; }
public boolean retryable() { return retryable; }
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.selfoperated.analytics.client;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads.Order;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads.Stock;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
/** One upstream page or detail per call. Durable orchestration belongs to sync. */
public interface LingxingAnalyticsClient {
Page<Warehouse> warehouses(SelfopRequestContext context, int current, int size);
Page<Summary> discover(SelfopRequestContext context, LocalDateTime startTime, LocalDateTime endTime, int current, int size);
Page<Summary> locate(SelfopRequestContext context, String deliveryNo, int current, int size);
Order detail(SelfopRequestContext context, String warehouseCode, String customerCode, String deliveryNo);
Page<Stock> inventory(SelfopRequestContext context, String warehouseCode, int current, int size);
record Page<T>(List<T> records, Integer current, Integer size, Long total, Integer pages) {
public Page { records = List.copyOf(records); }
}
record Warehouse(String warehouseCode, String warehouseName, Integer warehouseType,
Integer status, String timeZone) { }
record Summary(String sourceAccount, String customerCode, String warehouseCode,
String deliveryNo, String sourceNo, Integer sourceStatus,
String sourceCreatedTime, String sourceOutboundTime) { }
}
@@ -0,0 +1,248 @@
package com.baiyue.lastleg.selfoperated.analytics.client;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.baiyue.lastleg.selfoperated.analytics.request.*;
import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import java.math.BigDecimal;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Map;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
@Component
public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
private static final String PREFIX = "/internal/integrations/lingxing/wms";
private static final Set<String> PROVIDER_ERROR_CODES = Set.of(
"LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED",
"LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED",
"LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED",
"LINGXING_WMS_ACCOUNT_PAUSED", "LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
private final RestClient client;
private final SelfopAnalyticsProperties properties;
private final SelfopEgressService egress;
private final ObjectMapper responseMapper = new ObjectMapper()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
@Autowired public RestLingxingAnalyticsClient(RestClient.Builder builder, SelfopAnalyticsProperties properties, SelfopEgressService egress) {
this.egress = egress;
this.properties = properties;
var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build());
factory.setReadTimeout(Duration.ofSeconds(30));
this.client = builder.requestFactory(factory).baseUrl(properties.getIntegrationBaseUrl()).build();
}
// Allows an entirely in-memory transport in contract tests.
RestLingxingAnalyticsClient(RestClient client, SelfopAnalyticsProperties properties, SelfopEgressService egress) {
this.egress = egress;
this.client = client;
this.properties = properties;
}
@Override public Page<Warehouse> warehouses(SelfopRequestContext context, int current, int size) {
gate(context); String account = context.sourceAccount(); paging(current, size, 1000);
return page(post(context, "/warehouses/query", Map.of("current", current, "size", size)), current, size,
n -> new Warehouse(required(n, "whCode", 64), optional(n, "whNameCn"),
integer(n, "warehouseType"), integer(n, "status"), optional(n, "timeZone")));
}
@Override public Page<Summary> discover(SelfopRequestContext context, LocalDateTime start, LocalDateTime end, int current, int size) {
gate(context); String account = context.sourceAccount(); paging(current, size, 100);
if (start == null || end == null || !start.isBefore(end) || end.isAfter(start.plusMonths(1)))
throw failure("INVALID_DISCOVERY_WINDOW");
return page(post(context, "/outbound-orders/query", Map.of("startTime", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),
"endTime", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),
"current", current, "size", size)), current, size, n -> summary(account, n));
}
@Override public Page<Summary> locate(SelfopRequestContext context, String deliveryNo, int current, int size) {
gate(context); String account = context.sourceAccount(); paging(current, size, 1000); argument(deliveryNo, 128);
return page(post(context, "/outbound-orders/locate", Map.of("deliveryNo", deliveryNo,
"current", current, "size", size)), current, size, n -> summary(account, n));
}
@Override public SelfopPayloads.Order detail(SelfopRequestContext context, String warehouse, String customer, String deliveryNo) {
gate(context); String account = context.sourceAccount(); argument(warehouse, 64); argument(customer, 64); argument(deliveryNo, 128);
JsonNode n = post(context, "/outbound-orders/detail", Map.of("warehouseCode", warehouse,
"customerCode", customer, "deliveryNo", deliveryNo));
if (!warehouse.equals(required(n, "whCode", 64)) || !customer.equals(required(n, "customerCode", 64))
|| !deliveryNo.equals(required(n, "deliveryNo", 128))) throw failure("DETAIL_IDENTITY_MISMATCH");
if (!n.path("skuVOList").isArray()) throw failure("DETAIL_ITEMS_MISSING");
var items = new ArrayList<SelfopPayloads.Item>();
for (JsonNode item : n.path("skuVOList")) {
items.add(new SelfopPayloads.Item(required(item, "productSku", 128), optional(item, "productName"),
quantity(item, "actualDeliveryQty"), properties.getQuantityUnit()));
}
return new SelfopPayloads.Order(account, customer, warehouse, deliveryNo, optional(n, "sourceNo"),
integer(n, "status"), optional(n, "orderCreateTime"), optional(n, "outboundTime"), List.copyOf(items));
}
@Override public Page<SelfopPayloads.Stock> inventory(SelfopRequestContext context, String warehouse, int current, int size) {
gate(context); String account = context.sourceAccount(); argument(warehouse, 64); paging(current, size, 1000);
return page(post(context, "/product-inventory/query", Map.of("warehouseCode", warehouse,
"current", current, "size", size)), current, size, n -> {
if (!warehouse.equals(required(n, "whCode", 64))) throw failure("INVENTORY_WAREHOUSE_MISMATCH");
return new SelfopPayloads.Stock(account, required(n, "customerCode", 64), warehouse,
required(n, "productSku", 128), integer(n, "productQuality"), optional(n, "productName"),
quantity(n, "stkQty"), quantity(n, "useStkQty"), quantity(n, "freezeQty"),
quantity(n, "transitQty"), optional(n, "uniqueKey4tenant"));
});
}
private Summary summary(String account, JsonNode n) {
return new Summary(account, required(n, "customerCode", 64), required(n, "whCode", 64),
required(n, "deliveryNo", 128), optional(n, "sourceNo"), integer(n, "status"),
optional(n, "createTime"), optional(n, "outboundTime"));
}
private void gate(SelfopRequestContext context) {
if (context == null) throw failure("EGRESS_CONTEXT_MISSING");
String account = context.sourceAccount();
if ("OFF".equals(properties.getCollectionMode())) throw failure("EXECUTION_DISABLED");
if (!properties.getCollectionMode().equals(context.executionPurpose())) throw failure("EGRESS_PURPOSE_MISMATCH");
argument(account, 64);
if (!account.equals(properties.getSourceAccount())) throw failure("SOURCE_ACCOUNT_MISMATCH");
if (properties.getInternalServiceToken() == null || properties.getInternalServiceToken().isBlank())
throw failure("INTERNAL_TOKEN_UNCONFIGURED");
}
private JsonNode post(SelfopRequestContext context, String suffix, Object request) {
Map<?,?> fields = (Map<?,?>)request;
Operation op = switch(suffix) {
case "/warehouses/query" -> Operation.WAREHOUSES;
case "/outbound-orders/query" -> Operation.OUTBOUND_QUERY;
case "/outbound-orders/locate" -> Operation.OUTBOUND_LOCATE;
case "/outbound-orders/detail" -> Operation.OUTBOUND_DETAIL;
case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY;
default -> throw failure("EGRESS_OPERATION_INVALID");
};
int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0;
String warehouse=(String)fields.get("warehouseCode");
String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null;
var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields));
JsonNode body;
try {
String response = client.post().uri(PREFIX + suffix).contentType(MediaType.APPLICATION_JSON)
.header("X-Internal-Service-Token", properties.getInternalServiceToken())
.header("X-Lingxing-Egress-Context", SelfopSupport.json(permission))
.body(request).retrieve().body(String.class);
body = response == null ? null : responseMapper.readTree(response);
} catch (RestClientResponseException e) {
int status = e.getStatusCode().value();
if(status>=500||status==429)egress.pauseUncertain(permission,op,"INTEGRATION_HTTP_FAILURE");
throw new AnalyticsClientException("INTEGRATION_HTTP_FAILURE", status == 429 || status >= 500);
} catch (ResourceAccessException e) {
egress.pauseUncertain(permission,op,"INTEGRATION_TRANSPORT_FAILURE");
throw new AnalyticsClientException("INTEGRATION_TRANSPORT_FAILURE", true);
} catch (RestClientException e) {
egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID");
throw failure("INTEGRATION_RESPONSE_INVALID");
} catch (JsonProcessingException e) {
egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID");
throw failure("INTEGRATION_RESPONSE_INVALID");
}
if (body == null || !body.isObject()) {
egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID");throw failure("INTEGRATION_RESPONSE_INVALID");
}
if (!"200".equals(body.path("code").asText())) {
JsonNode metadata=body.path("data").path("egress");
if(metadata.path("accountPaused").isBoolean()&&metadata.path("accountPaused").booleanValue()){
if(!metadata.path("attemptId").isTextual()||!permission.attemptId().equals(metadata.path("attemptId").textValue()))
throw failure("INTEGRATION_PAUSE_BINDING_INVALID");
JsonNode reportedCode=body.path("data").path("errorCode");
egress.pauseUncertain(permission,op,reportedCode.isTextual()?reportedCode.textValue():"INTEGRATION_RESPONSE_INVALID");
}
if("LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED".equals(body.path("data").path("errorCode").asText())) {
JsonNode denial=body.path("data").path("denial");
if(!denial.isObject()||!denial.path("errorCode").isTextual()||!denial.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")||!denial.path("sampleDone").isBoolean()||!denial.has("waitUntil"))throw failure("INTEGRATION_RESPONSE_INVALID");
LocalDateTime wait=null;
if(!denial.path("waitUntil").isNull()) {
if(!denial.path("waitUntil").isTextual())throw failure("INTEGRATION_RESPONSE_INVALID");
try {wait=LocalDateTime.ofInstant(java.time.Instant.parse(denial.path("waitUntil").textValue()),java.time.ZoneOffset.UTC);}catch(Exception invalid){throw failure("INTEGRATION_RESPONSE_INVALID");}
}
throw new EgressRejectedException(denial.path("errorCode").textValue(),denial.path("sampleDone").booleanValue(),wait);
}
// Retryability is a normalized Integration field, never infer it from provider text.
boolean retryable = body.path("data").path("retryable").isBoolean()
&& body.path("data").path("retryable").booleanValue();
JsonNode sourceError = body.path("data").path("errorCode");
String code = sourceError.isTextual() && PROVIDER_ERROR_CODES.contains(sourceError.textValue())
? sourceError.textValue() : "INTEGRATION_PROVIDER_FAILURE";
if ("LINGXING_WMS_AUTHORIZATION_REJECTED".equals(code) || "LINGXING_WMS_SIGNATURE_REJECTED".equals(code))
retryable = false;
throw new AnalyticsClientException(code, retryable);
}
if (!body.path("data").isObject()) throw failure("INTEGRATION_DATA_MISSING");
return body.path("data");
}
private <T> Page<T> page(JsonNode n, int current, int size, Function<JsonNode,T> mapper) {
int actualCurrent = integer(n, "current"), actualSize = integer(n, "size"), pages = integer(n, "pages");
JsonNode totalNode = n.path("total");
long total;
try {
if (totalNode.isIntegralNumber() && totalNode.canConvertToLong()) total = totalNode.longValue();
else if (totalNode.isTextual() && totalNode.textValue().matches("[0-9]+")) total = Long.parseLong(totalNode.textValue());
else throw failure("PAGE_METADATA_INVALID");
} catch (NumberFormatException e) { throw failure("PAGE_METADATA_INVALID"); }
if (!n.path("records").isArray() || actualCurrent != current || actualSize != size || total < 0 || pages < 0
|| pages != (total == 0 ? 0 : ((total - 1) / size) + 1) || (pages > 0 && current > pages)
|| (total == 0 && current != 1)) throw failure("PAGE_METADATA_INVALID");
long expected = total == 0 ? 0 : Math.min(size, total - ((long) current - 1) * size);
if (n.path("records").size() != expected) throw failure("PAGE_ROW_COUNT_INVALID");
var records = new ArrayList<T>();
for (JsonNode row : n.path("records")) {
if (!row.isObject()) throw failure("PAGE_ROW_INVALID");
records.add(mapper.apply(row));
}
return new Page<>(records, current, size, total, pages);
}
private static void paging(int current, int size, int max) {
if (current < 1 || size < 1 || size > max) throw failure("INVALID_PAGE_REQUEST");
}
private static void argument(String value, int max) {
if (value == null || value.isBlank() || value.length() > max) throw failure("INVALID_BUSINESS_IDENTITY");
}
private static String required(JsonNode n, String field, int max) {
String value = optional(n, field); argument(value, max); return value;
}
private static String optional(JsonNode n, String field) {
JsonNode value = n.get(field);
if (value == null || value.isNull()) return null;
if (!value.isTextual()) throw failure("SOURCE_FIELD_TYPE_INVALID");
return value.textValue();
}
private static int integer(JsonNode n, String field) {
JsonNode value = n.path(field);
if (!value.isIntegralNumber() || !value.canConvertToInt()) throw failure("SOURCE_INTEGER_INVALID");
return value.intValue();
}
private static BigDecimal quantity(JsonNode n, String field) {
JsonNode value = n.get(field);
if (value == null || value.isNull() || value.isTextual() && value.textValue().isEmpty()) return null;
if (!value.isNumber()) throw failure("SOURCE_QUANTITY_INVALID");
return value.decimalValue();
}
private static AnalyticsClientException failure(String code) { return new AnalyticsClientException(code, false); }
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.selfoperated.analytics.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.selfoperated.analytics.query.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics")
@PreAuthorize("hasAuthority('platform:self-operated-analytics:list')")
@Tag(name="平台自营出库销量与库存只读分析")
public class PlatformAnalyticsController {
public static final String READ_PERMISSION="platform:self-operated-analytics:list";
private final AnalyticsQueryService service;
private final CollectionQueryService collection;
public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection){this.service=service;this.collection=collection;}
@GetMapping("/sku-sales-summary")
@Operation(summary="五周期或历史自定义范围SKU累计清单")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.SalesSummary>> salesSummary(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.salesSummary(query));
}
@GetMapping("/sku-daily-sales")
@Operation(summary="精确货主与SKU的完整日趋势轴")
public ApiResponse<AnalyticsResponses.Trend> dailySales(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.dailySales(query));
}
@GetMapping("/hot-skus")
@Operation(summary="近7或30个完整自然日热销Top100")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.Metric>> hotSkus(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.hotSkus(query));
}
@GetMapping("/slow-skus")
@Operation(summary="固定近30天有库存低出库量Top100")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.Metric>> slowSkus(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.slowSkus(query));
}
@GetMapping("/inventory-coverage")
@Operation(summary="正品库存、固定30天日均和可售天数")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.Metric>> inventoryCoverage(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options));
}
@GetMapping("/owners")
@Operation(summary="固定两版已采集货主编码前缀分页")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.OwnerOption>> owners(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
requirePlatform(user);return ApiResponse.success(service.owners(query,options));
}
@GetMapping("/owner-overview")
@Operation(summary="本仓一个货主完整候选的30日经营概览")
public ApiResponse<AnalyticsResponses.OwnerOverview> ownerOverview(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
requirePlatform(user);return ApiResponse.success(service.ownerOverview(query,options));
}
@GetMapping("/sync-overview")
@Operation(summary="各仓任务族独立配置、覆盖和采集运行概览")
public ApiResponse<CollectionResponses.Overview> syncOverview(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.overview(query));
}
@GetMapping("/sync-tasks")
@Operation(summary="本仓逻辑采集任务与轻量最新尝试分页")
public ApiResponse<CollectionResponses.Page<CollectionResponses.Task>> syncTasks(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.tasks(query));
}
@GetMapping("/sync-tasks/{taskNo}")
@Operation(summary="稳定业务任务号的采集详情与进度")
public ApiResponse<CollectionResponses.TaskDetail> syncTaskDetail(
@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String taskNo,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.taskDetail(taskNo,query));
}
@GetMapping("/sync-runs")
@Operation(summary="本仓或指定任务号的尝试历史与终态进度")
public ApiResponse<CollectionResponses.Page<CollectionResponses.Attempt>> syncRuns(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.runs(query));
}
private static void requirePlatform(AuthenticatedUser user){
if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0L||user.impersonated()||!user.permissions().contains(READ_PERMISSION))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"平台自营分析只读权限不足");
}
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.selfoperated.analytics.inventory;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Builds one immutable inventory observation, using only fixed published reference versions. */
public final class InventorySnapshotAssembler {
private InventorySnapshotAssembler() { }
public record Candidate(List<SelfopInventorySnapshot> snapshots, List<SelfopSkuProfile> profiles) { }
private record StockKey(String customer, String sku, Integer quality) { }
private record SkuKey(String customer, String sku) { }
public static Candidate assemble(SelfopSyncScope scope, SelfopSyncRun run,
List<SelfopSyncStage> stages, List<SelfopInventorySnapshot> priorStock,
List<SelfopSkuProfile> priorProfiles, List<SelfopSkuProfile> salesProfiles, String unit) {
require("COMPLETE".equals(run.getCoverageStatus()), "SELFOP_INVENTORY_INCOMPLETE");
require(run.getFetchStartedAtUtc() != null && run.getFetchFinishedAtUtc() != null
&& !run.getFetchFinishedAtUtc().isBefore(run.getFetchStartedAtUtc()), "SELFOP_INVENTORY_INTERVAL_INVALID");
require(run.getPageCount() != null && run.getPageCount() > 0
&& run.getSourceTotalCount() != null && run.getSourceTotalCount() >= 0
&& Objects.equals(run.getSourceTotalCount(), run.getUniqueObjectCount())
&& run.getSourceTotalCount() == stages.size(), "SELFOP_INVENTORY_COUNT_MISMATCH");
var now = utcNow();
Map<StockKey, SelfopInventorySnapshot> stocks = new LinkedHashMap<>();
Map<SkuKey, SelfopSkuProfile> profiles = new LinkedHashMap<>();
Set<String> hashes = new HashSet<>();
Set<Integer> observedPages = new HashSet<>();
for (var stage : stages) {
require(Objects.equals(stage.getRunId(), run.getId()) && Objects.equals(stage.getStageBatchId(), run.getId())
&& Objects.equals(stage.getLastFetchRunId(), run.getId()), "SELFOP_INVENTORY_ATTEMPT_MIXED");
require("PRODUCT_STOCK".equals(stage.getObjectType()) && "VALID".equals(stage.getValidationStatus())
&& Objects.equals(stage.getWarehouseCode(), scope.getWarehouseCode()), "SELFOP_INVENTORY_STAGE_INVALID");
require(stage.getFirstPageNo() != null && stage.getFirstPageNo() >= 1
&& stage.getFirstPageNo() <= run.getPageCount() && stage.getObservedAtUtc() != null,
"SELFOP_INVENTORY_PAGE_INVALID");
require(hashes.add(stage.getObjectKeyHash()), "SELFOP_INVENTORY_DUPLICATE_KEY");
observedPages.add(stage.getFirstPageNo());
var value = read(stage.getPayloadJson(), SelfopPayloads.Stock.class);
verifyIdentity(scope, value.sourceAccount(), value.warehouseCode(), value.customerCode(), value.skuCode());
require(value.productQuality() != null && value.productQuality() >= Short.MIN_VALUE
&& value.productQuality() <= Short.MAX_VALUE, "SELFOP_INVENTORY_QUALITY_INVALID");
require(value.productName() == null || value.productName().length() <= 255, "SELFOP_INVENTORY_NAME_INVALID");
require(value.sourceUniqueKey() == null || value.sourceUniqueKey().length() <= 512, "SELFOP_INVENTORY_SOURCE_KEY_INVALID");
require(Objects.equals(hash(value), stage.getContentHash()), "SELFOP_INVENTORY_CONTENT_CHANGED");
var key = new StockKey(value.customerCode(), value.skuCode(), value.productQuality());
require(!stocks.containsKey(key), "SELFOP_INVENTORY_DUPLICATE_KEY");
var row = snapshot(scope, run, key, now);
row.setProductName(value.productName()); row.setSourceUniqueKey(value.sourceUniqueKey());
row.setPhysicalQuantity(value.physicalQuantity()); row.setAvailableQuantity(value.availableQuantity());
row.setFrozenQuantity(value.frozenQuantity()); row.setTransitQuantity(value.transitQuantity());
row.setObservedAtUtc(stage.getObservedAtUtc());
var quality = quality(value);
row.setQualityStatus(quality[0]); row.setQualityReason(quality[1]);
if ("QUANTITY_PRECISION".equals(quality[1])) {
// The exact offending decimals remain in immutable stage evidence; never let DECIMAL silently round them.
row.setPhysicalQuantity(null); row.setAvailableQuantity(null); row.setFrozenQuantity(null); row.setTransitQuantity(null);
}
stocks.put(key, row);
var skuKey = new SkuKey(key.customer(), key.sku());
var profile = profiles.computeIfAbsent(skuKey, ignored -> profile(scope, run, skuKey, now));
profile.setProductName(profile.getProductName() == null ? value.productName() : profile.getProductName());
profile.setQuantityUnit(unit);
profile.setFirstSeenAtUtc(earlier(profile.getFirstSeenAtUtc(), stage.getObservedAtUtc()));
profile.setEarliestEvidenceAtUtc(profile.getFirstSeenAtUtc());
profile.setEvidenceType("INVENTORY_OBSERVATION"); profile.setEvidenceRunId(run.getId());
profile.setLastSeenRunId(run.getId());
}
require(stages.isEmpty() ? run.getPageCount() == 1 : observedPages.size() == run.getPageCount(),
"SELFOP_INVENTORY_PAGE_MISSING");
for (var old : priorStock) {
verifyIdentity(scope, old.getSourceAccount(), old.getWarehouseCode(), old.getCustomerCode(), old.getSkuCode());
require(Objects.equals(old.getSnapshotRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_INVALID");
var key = new StockKey(old.getCustomerCode(), old.getSkuCode(), old.getProductQuality());
stocks.computeIfAbsent(key, ignored -> missing(scope, run, key, old.getProductName(), now));
profiles.computeIfAbsent(new SkuKey(key.customer(), key.sku()), ignored -> profile(scope, run, ignored, now));
}
for (var old : priorProfiles) {
verifyIdentity(scope, old.getSourceAccount(), old.getWarehouseCode(), old.getCustomerCode(), old.getSkuCode());
require(Objects.equals(old.getProfileRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_PROFILE_BASE_INVALID");
var key = new SkuKey(old.getCustomerCode(), old.getSkuCode());
var current = profiles.computeIfAbsent(key, ignored -> profile(scope, run, key, now));
if (current.getProductName() == null) current.setProductName(old.getProductName());
if ("INVENTORY_OBSERVATION".equals(old.getEvidenceType()) && old.getFirstSeenAtUtc() != null) {
boolean observedNow = current.getLastSeenRunId() != null;
current.setFirstSeenAtUtc(earlier(current.getFirstSeenAtUtc(), old.getFirstSeenAtUtc()));
current.setEarliestEvidenceAtUtc(current.getFirstSeenAtUtc());
current.setEvidenceType("INVENTORY_OBSERVATION");
current.setEvidenceRunId(old.getEvidenceRunId());
if (!observedNow) { current.setLastSeenRunId(old.getLastSeenRunId()); current.setQuantityUnit(old.getQuantityUnit()); }
}
}
for (var sales : salesProfiles) {
verifyIdentity(scope, sales.getSourceAccount(), sales.getWarehouseCode(), sales.getCustomerCode(), sales.getSkuCode());
require(Objects.equals(sales.getProfileRunId(), run.getReferenceSalesRunId()), "SELFOP_INVENTORY_SALES_REFERENCE_INVALID");
var key = new StockKey(sales.getCustomerCode(), sales.getSkuCode(), 0);
stocks.computeIfAbsent(key, ignored -> missing(scope, run, key, sales.getProductName(), now));
var skuKey = new SkuKey(key.customer(), key.sku());
var current = profiles.computeIfAbsent(skuKey, ignored -> profile(scope, run, skuKey, now));
if (current.getProductName() == null) current.setProductName(sales.getProductName());
// Sales identifies a candidate; it cannot prove that inventory ever observed it.
}
return new Candidate(List.copyOf(stocks.values()), List.copyOf(profiles.values()));
}
private static String[] quality(SelfopPayloads.Stock stock) {
var quantities = new BigDecimal[]{ stock.physicalQuantity(), stock.availableQuantity(), stock.frozenQuantity(), stock.transitQuantity() };
boolean missing = false;
for (var quantity : quantities) {
if (quantity == null) { missing = true; continue; }
if (quantity.scale() > 6 && quantity.stripTrailingZeros().scale() > 6
|| quantity.precision() - quantity.scale() > 14) return new String[]{"INVALID", "QUANTITY_PRECISION"};
if (quantity.signum() < 0) return new String[]{"INVALID", "NEGATIVE_QUANTITY"};
}
return missing ? new String[]{"UNKNOWN", "FIELD_MISSING"} : new String[]{"VALID", null};
}
private static void verifyIdentity(SelfopSyncScope scope, String account, String warehouse, String customer, String sku) {
identity(customer, 64); identity(sku, 128);
require(Objects.equals(scope.getSourceAccount(), account) && Objects.equals(scope.getWarehouseCode(), warehouse),
"SELFOP_INVENTORY_SCOPE_LEAK");
}
private static SelfopInventorySnapshot snapshot(SelfopSyncScope scope, SelfopSyncRun run, StockKey key, LocalDateTime now) {
var row = new SelfopInventorySnapshot(); row.setId(IdWorker.getId()); row.setCreatedAt(now); row.setUpdatedAt(now);
row.setSnapshotRunId(run.getId()); row.setSourceAccount(scope.getSourceAccount()); row.setWarehouseCode(scope.getWarehouseCode());
row.setCustomerCode(key.customer()); row.setSkuCode(key.sku()); row.setProductQuality(key.quality()); return row;
}
private static SelfopInventorySnapshot missing(SelfopSyncScope scope, SelfopSyncRun run, StockKey key, String name, LocalDateTime now) {
var row = snapshot(scope, run, key, now); row.setProductName(name); row.setObservedAtUtc(run.getFetchFinishedAtUtc());
row.setQualityStatus("UNKNOWN"); row.setQualityReason("SOURCE_ROW_MISSING"); return row;
}
private static SelfopSkuProfile profile(SelfopSyncScope scope, SelfopSyncRun run, SkuKey key, LocalDateTime now) {
var row = new SelfopSkuProfile(); row.setId(IdWorker.getId()); row.setCreatedAt(now); row.setUpdatedAt(now);
row.setProfileRunId(run.getId()); row.setSourceAccount(scope.getSourceAccount()); row.setWarehouseCode(scope.getWarehouseCode());
row.setCustomerCode(key.customer()); row.setSkuCode(key.sku()); row.setEvidenceType("NONE"); return row;
}
private static LocalDateTime earlier(LocalDateTime a, LocalDateTime b) { return a == null || b != null && b.isBefore(a) ? b : a; }
}
@@ -0,0 +1,179 @@
package com.baiyue.lastleg.selfoperated.analytics.inventory;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
@RequiredArgsConstructor
public class InventorySnapshotService {
static final String DAILY_SAMPLE_RULE = "DAILY_SCHEDULED_FIRST_V1";
private static final ZoneId SAMPLE_ZONE = ZoneId.of("Asia/Shanghai");
private final SelfopCoordinationMapper coordination;
private final SelfopSyncScopeMapper scopes;
private final SelfopSyncRunMapper runs;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncStageMapper stages;
private final SelfopInventorySnapshotMapper snapshots;
private final SelfopSkuProfileMapper profiles;
private final SelfopAnalyticsProperties properties;
/** Whole candidate commits atomically. No source HTTP request occurs in this transaction. */
@Transactional(rollbackFor = Exception.class)
public void build(long scopeId, long runId, String owner, long fence) {
var scope = coordination.lockScope(scopeId);
var run = coordination.lockRun(runId);
validate(scope, run, owner, fence);
if ("READY".equals(run.getStatus()) || "PUBLISHED".equals(run.getStatus())) return;
require("VALIDATED".equals(run.getStatus()), "SELFOP_INVENTORY_NOT_VALIDATED");
require(Objects.equals(scope.getPublishedRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_CHANGED");
var priorStock = List.<SelfopInventorySnapshot>of();
var priorProfiles = List.<SelfopSkuProfile>of();
if (run.getBasePublishedRunId() != null) {
reference(scope, run.getBasePublishedRunId(), "INVENTORY");
priorStock = snapshots.selectList(new QueryWrapper<SelfopInventorySnapshot>().eq("snapshot_run_id", run.getBasePublishedRunId()));
priorProfiles = profiles.selectList(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", run.getBasePublishedRunId()));
}
var salesProfiles = List.<SelfopSkuProfile>of();
if (run.getReferenceSalesRunId() != null) {
reference(scope, run.getReferenceSalesRunId(), "OUTBOUND");
salesProfiles = profiles.selectList(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", run.getReferenceSalesRunId()));
}
var candidate = InventorySnapshotAssembler.assemble(scope, run,
stages.selectList(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id", runId).eq("object_type", "PRODUCT_STOCK").orderByAsc("id")),
priorStock, priorProfiles, salesProfiles, fixedUnit(run));
require(snapshots.selectCount(new QueryWrapper<SelfopInventorySnapshot>().eq("snapshot_run_id", runId)) == 0
&& profiles.selectCount(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", runId)) == 0,
"SELFOP_INVENTORY_CANDIDATE_EXISTS");
run.setBuildStartedAtUtc(utcNow());run.setLastPhase("BUILDING");
for (var row : candidate.snapshots()) row.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
for (var row : candidate.profiles()) row.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
if(!candidate.snapshots().isEmpty())snapshots.insert(candidate.snapshots(),Math.max(1,Math.min(500,properties.getPersistenceBatchSize())));
if(!candidate.profiles().isEmpty())profiles.insert(candidate.profiles(),Math.max(1,Math.min(500,properties.getPersistenceBatchSize())));
run.setStatus("READY"); run.setUpdatedAt(utcNow()); runs.updateById(run);
}
/** Inventory publication deliberately has no outbound apply-state/revision barrier. */
@Transactional(rollbackFor = Exception.class)
public void publish(long scopeId, long runId, String owner, long fence) {
var scope = coordination.lockScope(scopeId);
var run = coordination.lockRun(runId);
validate(scope, run, owner, fence);
if ("PUBLISHED".equals(run.getStatus())) {
verifyPublishedResult(run);
return;
}
require(fixedUnit(run) != null, "SELFOP_QUANTITY_UNIT_UNCONFIRMED");
require("READY".equals(run.getStatus()) && "COMPLETE".equals(run.getCoverageStatus()), "SELFOP_INVENTORY_NOT_READY");
require(Objects.equals(scope.getPublishedRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_CHANGED");
var rows = snapshots.selectList(new QueryWrapper<SelfopInventorySnapshot>().eq("snapshot_run_id", runId));
var dimensions = profiles.selectList(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", runId));
require(run.getSourceTotalCount() != null && rows.size() >= run.getSourceTotalCount(), "SELFOP_INVENTORY_CANDIDATE_INCOMPLETE");
Set<List<String>> rowSkus = new HashSet<>(), profileSkus = new HashSet<>();
for (var row : rows) {
require(Objects.equals(scope.getSourceAccount(), row.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), row.getWarehouseCode()), "SELFOP_INVENTORY_SCOPE_LEAK");
rowSkus.add(List.of(row.getCustomerCode(), row.getSkuCode()));
}
for (var profile : dimensions) {
require(Objects.equals(scope.getSourceAccount(), profile.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), profile.getWarehouseCode()), "SELFOP_INVENTORY_SCOPE_LEAK");
profileSkus.add(List.of(profile.getCustomerCode(), profile.getSkuCode()));
}
require(profileSkus.containsAll(rowSkus), "SELFOP_INVENTORY_PROFILE_INCOMPLETE");
var task = coordination.lockTask(run.getTaskId());
SelfopCollectionPolicy.requireBusiness(task);
require(task != null && Objects.equals(task.getScopeId(), scopeId) && Objects.equals(task.getScopeHash(), scope.getScopeHash())
&& Objects.equals(task.getSourceAccount(), scope.getSourceAccount())
&& Objects.equals(task.getWarehouseCode(), scope.getWarehouseCode())
&& "NOT_APPLICABLE".equals(task.getApplyState()), "SELFOP_INVENTORY_TASK_INVALID");
require(run.getInventoryDailySampleDate() == null && run.getInventorySampleRuleVersion() == null,
"SELFOP_INVENTORY_UNPUBLISHED_SAMPLE");
LocalDate sampleDate = eligibleSampleDate(task, run);
if (sampleDate != null) {
var selected = runs.dailyInventorySample(scope.getSourceAccount(), scope.getWarehouseCode(), sampleDate);
require(selected == null || ("PUBLISHED".equals(selected.getStatus())
&& DAILY_SAMPLE_RULE.equals(selected.getInventorySampleRuleVersion())), "SELFOP_INVENTORY_SAMPLE_INVALID");
if (selected == null) {
run.setInventoryDailySampleDate(sampleDate);
run.setInventorySampleRuleVersion(DAILY_SAMPLE_RULE);
}
}
var now = utcNow();
if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncRun>()
.eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now));
scope.setPublishedRunId(runId); scope.setUpdatedAt(now); scopes.updateById(scope);
run.setProgressSnapshotJson(task.getProgressJson());run.setStatus("PUBLISHED"); run.setPublishedAtUtc(now); run.setFinishedAtUtc(now); run.setUpdatedAt(now); runs.updateById(run);
task.setResultPublishedRunId(runId);task.setStatus("SUCCEEDED"); task.setUpdatedAt(now); tasks.updateById(task);
}
/** Recovery verifies the committed result, never elects an old unmarked version retroactively. */
private void verifyPublishedResult(SelfopSyncRun run) {
var task = coordination.lockTask(run.getTaskId());
SelfopCollectionPolicy.requireBusiness(task);
require(task != null && Objects.equals(task.getResultPublishedRunId(), run.getId())
&& run.getPublishedAtUtc() != null, "SELFOP_INVENTORY_PUBLISHED_RESULT_INVALID");
if (run.getInventoryDailySampleDate() == null) {
require(run.getInventorySampleRuleVersion() == null, "SELFOP_INVENTORY_SAMPLE_INVALID");
return;
}
require(DAILY_SAMPLE_RULE.equals(run.getInventorySampleRuleVersion())
&& Objects.equals(run.getInventoryDailySampleDate(), eligibleSampleDate(task, run)), "SELFOP_INVENTORY_SAMPLE_INVALID");
var selected = runs.dailyInventorySample(run.getSourceAccount(), run.getWarehouseCode(), run.getInventoryDailySampleDate());
require(selected != null && Objects.equals(selected.getId(), run.getId()), "SELFOP_INVENTORY_SAMPLE_INVALID");
}
/** The observed interval, not publication/retry time, determines the Beijing observation day. */
static LocalDate eligibleSampleDate(SelfopSyncTask task, SelfopSyncRun run) {
if (!"BUSINESS".equals(task.getExecutionPurpose()) || !"BUSINESS".equals(run.getExecutionPurpose())
|| !"SCHEDULED".equals(task.getTaskType()) || !"WAREHOUSE_SYNC".equals(task.getTaskRole())
|| !"WAREHOUSE_SYNC".equals(run.getRunRole()) || !"COMPLETE".equals(run.getCoverageStatus())
|| !("READY".equals(run.getStatus()) || "PUBLISHED".equals(run.getStatus()))
|| task.getPlannedAtUtc() == null || run.getFetchStartedAtUtc() == null || run.getFetchFinishedAtUtc() == null
|| run.getFetchFinishedAtUtc().isBefore(run.getFetchStartedAtUtc())
|| run.getPageCount() == null || run.getPageCount() < 1 || run.getSourceTotalCount() == null
|| run.getSourceTotalCount() < 0 || !Objects.equals(run.getSourceTotalCount(), run.getUniqueObjectCount())) return null;
LocalDate planned = task.getPlannedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate();
LocalDate started = run.getFetchStartedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate();
LocalDate finished = run.getFetchFinishedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate();
return planned.equals(started) && planned.equals(finished) ? planned : null;
}
private void reference(SelfopSyncScope scope, long referenceId, String family) {
var ref = runs.selectById(referenceId);
SelfopCollectionPolicy.requireBusiness(ref);
require(ref != null && "PUBLISHED".equals(ref.getStatus()) && "WAREHOUSE_SYNC".equals(ref.getRunRole())
&& Objects.equals(scope.getSourceAccount(), ref.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), ref.getWarehouseCode()), "SELFOP_INVENTORY_REFERENCE_INVALID");
var refScope = scopes.selectById(ref.getScopeId());
require(refScope != null && family.equals(refScope.getDataFamily())
&& Objects.equals(scope.getSourceAccount(), refScope.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), refScope.getWarehouseCode()), "SELFOP_INVENTORY_REFERENCE_SCOPE_INVALID");
}
private static String fixedUnit(SelfopSyncRun run) {
require(run.getScopeSnapshotJson() != null, "SELFOP_INVENTORY_CONFIG_MISSING");
Object unit = read(run.getScopeSnapshotJson(), Map.class).get("quantityUnit");
require(unit == null || unit instanceof String, "SELFOP_QUANTITY_UNIT_INVALID");
if (unit == null || ((String) unit).isBlank()) return null;
return identity((String) unit, 32);
}
private static void validate(SelfopSyncScope scope, SelfopSyncRun run, String owner, long fence) {
SelfopCollectionPolicy.requireBusiness(run);
require(scope != null && run != null && "INVENTORY".equals(scope.getDataFamily())
&& "WAREHOUSE_SYNC".equals(run.getRunRole()) && Objects.equals(scope.getId(), run.getScopeId())
&& Objects.equals(scope.getSourceAccount(), run.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), run.getWarehouseCode())
&& Objects.equals(scope.getScopeHash(), run.getScopeHash()), "SELFOP_INVENTORY_SCOPE_INVALID");
require(owner != null && owner.equals(scope.getLeaseOwner()) && Objects.equals(scope.getFenceToken(), fence)
&& Objects.equals(run.getFenceToken(), fence) && scope.getLeaseUntilUtc() != null
&& scope.getLeaseUntilUtc().isAfter(utcNow()), "SELFOP_LEASE_LOST");
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface SelfopCoordinationMapper {
@Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE")
SelfopSyncScope lockScope(long id);
@Select("SELECT * FROM ll_selfop_outbound_discovery_scope WHERE id=#{id} FOR UPDATE")
SelfopOutboundDiscoveryScope lockDiscovery(long id);
@Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE")
SelfopSyncTask lockTask(long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun lockRun(long id);
@Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}")
List<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose);
@Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
long barriers(@Param("account") String account, @Param("warehouse") String warehouse);
@Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND apply_state='APPLIED' AND (query_from_utc IS NULL OR (query_from_utc>=#{from} AND query_until_utc<=#{until})) AND status IN ('READY','RUNNING','RETRY_WAIT','DEAD')")
int completeCoveredTasks(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("from") LocalDateTime from,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now);
@Update("UPDATE ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id SET r.status='REUSED',r.finished_at_utc=#{now},r.updated_at=#{now},r.progress_snapshot_json=COALESCE(r.progress_snapshot_json,CASE WHEN JSON_EXTRACT(t.progress_json,'$.attemptNo')=r.attempt_no THEN t.progress_json ELSE NULL END) WHERE t.result_published_run_id=#{published} AND r.id<>#{published} AND r.attempt_no=t.attempt_count AND r.execution_purpose='BUSINESS' AND r.status IN ('FETCHING','FACTS_APPLIED','BUILDING','READY')")
int completeReusedRuns(@Param("published") long published,@Param("now") LocalDateTime now);
@Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND task_type='SCHEDULED' AND apply_state='NOT_APPLICABLE' AND status='WAITING_SOURCE' AND planned_at_utc<=#{until}")
int completeCoveredMarkers(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now);
@Update("<script>UPDATE ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id SET r.heartbeat_at_utc=#{now},r.updated_at=#{now} WHERE r.fence_token=#{fence} AND t.status='RUNNING' AND r.status IN ('FETCHING','VALIDATED','FACTS_APPLIED','BUILDING','READY') <choose><when test='account'>AND t.discovery_scope_id=#{scope}</when><otherwise>AND r.scope_id=#{scope}</otherwise></choose></script>")
int heartbeat(@Param("scope") long scope,@Param("account") boolean account,@Param("fence") long fence,@Param("now") LocalDateTime now);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopInventorySnapshot;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopInventorySnapshotMapper extends BaseMapper<SelfopInventorySnapshot> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundDiscoveryScope;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopOutboundDiscoveryScopeMapper extends BaseMapper<SelfopOutboundDiscoveryScope> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopOutboundItemMapper extends BaseMapper<SelfopOutboundItem> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundOrder;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopOutboundOrderMapper extends BaseMapper<SelfopOutboundOrder> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuDailySales;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSkuDailySalesMapper extends BaseMapper<SelfopSkuDailySales> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuProfile;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSkuProfileMapper extends BaseMapper<SelfopSkuProfile> {
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
@Mapper
public interface SelfopSyncRunMapper extends BaseMapper<SelfopSyncRun> {
/** Current read after acquiring the inventory scope lock, including after a competing publisher commits. */
@Select("SELECT * FROM ll_selfop_sync_run WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND inventory_daily_sample_date=#{date} FOR UPDATE")
SelfopSyncRun dailyInventorySample(@Param("account") String account, @Param("warehouse") String warehouse,
@Param("date") LocalDate date);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSyncScopeMapper extends BaseMapper<SelfopSyncScope> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncStage;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSyncStageMapper extends BaseMapper<SelfopSyncStage> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSyncTaskMapper extends BaseMapper<SelfopSyncTask> {
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Component
@ConfigurationProperties(prefix = "baiyue.self-operated-analytics")
public class SelfopAnalyticsProperties {
private String collectionMode = "OFF";
private String environment = "test";
private String exclusiveGrantId;
private LocalDateTime exclusiveGrantExpiresAtUtc;
private boolean exclusiveHandoffVerified = false;
private boolean priorEgressClosed = false;
private boolean inflightDrained = false;
private boolean transferredBudgetVerified = false;
private long transferredDailyHttpCount = 0;
private LocalDate transferredBudgetDate;
private LocalDateTime transferredCooldownUntilUtc;
private String transferredPauseReason;
private int dailyRequestBudget = 0;
private int businessMinRequestIntervalMs = 5000;
private int maxRateLimitFailures = 5;
private int cooldownSeconds = 60;
private int egressLeaseSeconds = 120;
private boolean retentionEnabled = false;
private int salesRetentionDays = 7;
private int persistenceBatchSize = 500;
private int minimumBuildIntervalSeconds = 1800;
/** Read-only UI projection; collectionMode is the sole runtime mode input. */
public boolean isExecutionEnabled() {
return "VALIDATION".equals(collectionMode)||("BUSINESS".equals(collectionMode)&&"production".equals(environment));
}
private boolean salesPublicationEnabled = false;
private String integrationBaseUrl = "http://integration-service";
private String internalServiceToken = "";
private String sourceAccount = "";
private String sourceQueryTimezone;
private String quantityUnit;
private int requestBudget = 100;
private int timeBudgetSeconds = 120;
private int minRequestIntervalMs = 1100;
private int leaseSeconds = 180;
private int maxAttempts = 5;
private int pageSize = 100;
private int applyBatchSize = 100;
private Map<String, Warehouse> warehouses = new LinkedHashMap<>();
@Data
public static class Warehouse {
private String businessTimezone = "Asia/Shanghai";
private String sourceTimezone = "Asia/Shanghai";
private String outboundCron = "0 0 8,20 * * *";
private String inventoryCron = "0 0 8 * * *";
private int inventoryStaleAfterSeconds = 93600;
private int outboundStaleAfterSeconds = 93600;
private int reviewRequestBudget = 100;
private int reviewTimeBudgetSeconds = 120;
private int reviewTargetDays = 7;
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.require;
/** Purpose is persisted on every task/run. Unknown purpose is never treated as business. */
public final class SelfopCollectionPolicy {
private SelfopCollectionPolicy() { }
public static void requireBusiness(SelfopSyncTask task) {
require(task != null && "BUSINESS".equals(task.getExecutionPurpose()) && task.getValidationRootTaskId()==null,
"SELFOP_BUSINESS_PURPOSE_REQUIRED");
}
public static void requireBusiness(SelfopSyncRun run) {
require(run != null && "BUSINESS".equals(run.getExecutionPurpose()),"SELFOP_BUSINESS_PURPOSE_REQUIRED");
}
public static void requireExecution(SelfopAnalyticsProperties config,String purpose) {
require("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose),"SELFOP_PURPOSE_INVALID");
require(purpose.equals(config.getCollectionMode()),"SELFOP_COLLECTION_MODE_REJECTED");
if("BUSINESS".equals(purpose))require("production".equals(config.getEnvironment()),"SELFOP_BUSINESS_ENVIRONMENT_REJECTED");
}
public static String configuredPurpose(SelfopAnalyticsProperties config) {
String purpose=config.getCollectionMode();requireExecution(config,purpose);return purpose;
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_inventory_snapshot")
public class SelfopInventorySnapshot {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long snapshotRunId;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String skuCode;
private Integer productQuality;
private String productName;
private BigDecimal physicalQuantity;
private BigDecimal availableQuantity;
private BigDecimal frozenQuantity;
private BigDecimal transitQuantity;
private String sourceUniqueKey;
private LocalDateTime observedAtUtc;
private String qualityStatus;
private String qualityReason;
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_outbound_discovery_scope")
public class SelfopOutboundDiscoveryScope {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private LocalDateTime catalogFromUtc;
private LocalDateTime catalogUntilUtc;
private Long nextSequence;
private Long nextObservationSequence;
private Long activeTaskId;
private String leaseOwner;
private LocalDateTime leaseUntilUtc;
private Long fenceToken;
private Integer requestBudget;
private Integer timeBudgetSeconds;
private Integer minRequestIntervalMs;
private String apiBudgetJson;
private LocalDateTime apiCooldownUntilUtc;
private String apiPauseReason;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_outbound_item")
public class SelfopOutboundItem {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long orderId;
private String skuCode;
private String productName;
private BigDecimal actualQuantity;
private String quantityUnit;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_outbound_order")
public class SelfopOutboundOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String deliveryNo;
private String sourceNo;
private Integer sourceStatus;
private String sourceCreatedTime;
private String sourceOutboundTime;
private String sourceTimezone;
private LocalDateTime outboundAtUtc;
private LocalDate businessDate;
private String businessTimezone;
private Integer salesEligible;
private Long revisionNo;
private String contentHash;
private Long lastRunId;
private Long lastObservationSequence;
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import java.math.BigDecimal;
import java.util.List;
public final class SelfopPayloads {
private SelfopPayloads() { }
public record Order(String sourceAccount, String customerCode, String warehouseCode,
String deliveryNo, String sourceNo, Integer sourceStatus, String sourceCreatedTime,
String sourceOutboundTime, List<Item> items) { }
public record Item(String skuCode, String productName, BigDecimal actualQuantity, String quantityUnit) { }
public record Stock(String sourceAccount, String customerCode, String warehouseCode,
String skuCode, Integer productQuality, String productName, BigDecimal physicalQuantity,
BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity,
String sourceUniqueKey) { }
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
public final class SelfopProgressProjection {
private SelfopProgressProjection() { }
private static final Set<String> PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY",
"VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED");
/** Pure projection for callers already holding the scope/task/run locks and fence. */
public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan) {
require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID");
require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID");
require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID");
Map<String,Object> p=progress(task);
if(!Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()))p=new LinkedHashMap<>();
String countScope=scope(task); Object started=p.get("stageStartedAt");
if(!phase.equals(p.get("phase")) || started==null)started=instant(utcNow());
p.put("attemptNo",run.getAttemptNo()); p.put("phase",phase); p.put("progressScope",countScope);
p.put("stageStartedAt",started); p.put("unit",unit); p.put("completed",completed);
p.put("total",total); p.put("totalKnown",total!=null); p.put("scanRound",scan); p.put("countBasis",Set.of("INVENTORY","BUILD").contains(phase)?"ATTEMPT_ONLY":"TASK_CUMULATIVE"); p.put("sliceFrom",instant(task.getQueryFromUtc())); p.put("sliceUntil",instant(task.getQueryUntilUtc())); p.put("stageFinishedAt",null);
// A zero denominator is meaningful evidence, but cannot produce a percentage.
p.put("phasePercent",total==null||total==0?null:BigDecimal.valueOf(completed).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total),1,RoundingMode.DOWN));
List<Map<String,Object>> stages=new ArrayList<>();
if(p.get("stages") instanceof List<?> prior)for(Object value:prior) {
var entry=map(value);
if(PHASES.contains(Objects.toString(entry.get("phase"),""))&&!phase.equals(entry.get("phase")))stages.add(entry);
}
var current=new LinkedHashMap<String,Object>();
for(String key:List.of("phase","unit","completed","total","totalKnown","scanRound","progressScope","countBasis",
"attemptNo","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt","phasePercent"))current.put(key,p.get(key));
stages.add(current);p.remove("phases");p.put("stages",stages);
task.setProgressJson(bounded(p));task.setProgressUpdatedAtUtc(utcNow());task.setUpdatedAt(utcNow());
run.setLastPhase(phase); run.setHeartbeatAtUtc(utcNow()); run.setUpdatedAt(utcNow());
}
/** Pure terminal snapshot; historical attempt evidence is immutable. */
public static void freezeRun(SelfopSyncTask task,SelfopSyncRun run) {
if(run.getProgressSnapshotJson()!=null)return;
if(task.getProgressJson()==null) {
var fallback=new LinkedHashMap<String,Object>();fallback.put("attemptNo",run.getAttemptNo());
fallback.put("phase",run.getLastPhase()!=null&&PHASES.contains(run.getLastPhase())?run.getLastPhase():"WAITING");
fallback.put("progressScope",scope(task));fallback.put("countBasis","TASK_CUMULATIVE");fallback.put("scanRound",0);
fallback.put("unit","NONE");fallback.put("completed",0);fallback.put("total",null);fallback.put("totalKnown",false);
fallback.put("sliceFrom",instant(task.getQueryFromUtc()));fallback.put("sliceUntil",instant(task.getQueryUntilUtc()));
fallback.put("stageStartedAt",instant(run.getFetchStartedAtUtc()));fallback.put("stageFinishedAt",null);fallback.put("stages",List.of());
run.setProgressSnapshotJson(bounded(fallback));
return;
}
var p=progress(task);
// A later attempt's projection must never be copied into an earlier historical run.
require(Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()),"SELFOP_PROGRESS_ATTEMPT_MISMATCH");
run.setProgressSnapshotJson(bounded(p));run.setUpdatedAt(utcNow());
}
private static String bounded(Map<String,Object> p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;}
private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";}
@SuppressWarnings("unchecked") private static Map<String,Object> progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);}
@SuppressWarnings("unchecked") private static Map<String,Object> map(Object value){return value instanceof Map<?,?>?new LinkedHashMap<>((Map<String,Object>)value):new LinkedHashMap<>();}
private static Long number(Object value){return value instanceof Number n?n.longValue():null;}
private static String instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
/** Collector-owned identity; no credentials or provider protocol live in this neutral model. */
public record SelfopRequestContext(String sourceAccount,String executionPurpose,Long rootTaskId,
String sessionId,String exclusiveGrantId,Long taskId,Long runId) {
public static SelfopRequestContext forTask(SelfopSyncTask task,SelfopSyncRun run,SelfopAnalyticsProperties properties) {
Long root="VALIDATION".equals(task.getExecutionPurpose())?task.getValidationRootTaskId():task.getId();
return new SelfopRequestContext(task.getSourceAccount(),task.getExecutionPurpose(),root,
"selfop-"+root,properties.getExclusiveGrantId(),task.getId(),run.getId());
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
/** Durable directory cursor and effective immutable stage references. */
@Data
public class SelfopRoutingManifest {
private int scan = 1;
private int nextPage = 1;
private Integer pages;
private Long total;
private boolean directoryComplete;
private Map<String, Entry> objects = new LinkedHashMap<>();
private Map<String, String> firstScan = new LinkedHashMap<>();
@Data
public static class Entry {
private String customerCode;
private String deliveryNo;
private String warehouseCode;
private Long directoryStageId;
private Long effectiveStageId;
private Long refreshTaskId;
private Long newestSeenSequence;
private Long appliedStageId;
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sku_daily_sales")
public class SelfopSkuDailySales {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long salesRunId;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String skuCode;
private LocalDate businessDate;
private BigDecimal outboundQuantity;
private Long outboundOrderCount;
private String quantityUnit;
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sku_profile")
public class SelfopSkuProfile {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long profileRunId;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String skuCode;
private String productName;
private String quantityUnit;
private LocalDateTime firstSeenAtUtc;
private LocalDateTime earliestEvidenceAtUtc;
private String evidenceType;
private Long evidenceRunId;
private Long lastSeenRunId;
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
public final class SelfopSupport {
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
private SelfopSupport() { }
public static LocalDateTime utcNow() { return LocalDateTime.now(ZoneOffset.UTC); }
public static String json(Object value) {
try { return JSON.writeValueAsString(value); }
catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); }
}
public static <T> T read(String value, Class<T> type) {
try { return JSON.readValue(value, type); }
catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); }
}
public static String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception e) { throw new IllegalStateException("SELFOP_HASH_FAILED"); }
}
public static void require(boolean valid, String code) {
if (!valid) throw new IllegalStateException(code);
}
public static String identity(String value, int max) {
require(value != null && !value.isBlank() && value.length() <= max, "SELFOP_IDENTITY_INVALID");
return value;
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_run")
public class SelfopSyncRun {
private String executionPurpose;
private LocalDateTime buildStartedAtUtc;
private LocalDateTime supersededAtUtc;
private String progressSnapshotJson;
private LocalDateTime heartbeatAtUtc;
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long taskId;
private Long scopeId;
private String sourceAccount;
private String warehouseCode;
private String runRole;
private Long upstreamTaskId;
private Integer attemptNo;
private String scopeHash;
private String scopeSnapshotJson;
private String observedDimensionsJson;
private Long fenceToken;
private Long basePublishedRunId;
private Long referenceSalesRunId;
private String status;
private String lastPhase;
private LocalDateTime fetchStartedAtUtc;
private LocalDateTime fetchFinishedAtUtc;
private LocalDate inventoryDailySampleDate;
private String inventorySampleRuleVersion;
private LocalDateTime finishedAtUtc;
private LocalDateTime publishedAtUtc;
private Integer pageCount;
private Long sourceTotalCount;
private Long uniqueObjectCount;
private String coverageStatus;
private LocalDateTime coveredFromUtc;
private LocalDateTime coveredUntilUtc;
private LocalDate completeDayFrom;
private LocalDate completeDayUntil;
private Long appliedFactRevision;
private Long calculatedFactRevision;
private String errorCode;
private String errorMessage;
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_scope")
public class SelfopSyncScope {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private String warehouseCode;
private String dataFamily;
private String scopeJson;
private String scopeHash;
private String businessTimezone;
private String sourceTimezone;
private String scheduleCron;
private Integer staleAfterSeconds;
private String warehousePolicyRef;
private LocalDateTime initialFromUtc;
private LocalDateTime initialUntilUtc;
private LocalDateTime discoveryFromUtc;
private LocalDateTime discoveryUntilUtc;
private Long factRevision;
private Long publishedFactRevision;
private Integer rebuildRequired;
private LocalDateTime invalidatedAtUtc;
private Long publishedRunId;
private String leaseOwner;
private LocalDateTime leaseUntilUtc;
private Long fenceToken;
private Long knownOrderCursorId;
private Long knownOrderRoundMaxId;
private LocalDateTime knownOrderRoundStartedAtUtc;
private LocalDateTime knownOrderRoundFinishedAtUtc;
private LocalDateTime historyRoundFromUtc;
private LocalDateTime historyRoundUntilUtc;
private LocalDateTime historySliceCursorUtc;
private LocalDateTime historyRoundFinishedAtUtc;
private Integer reviewRequestBudget;
private Integer reviewTimeBudgetSeconds;
private Integer reviewTargetDays;
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_stage")
public class SelfopSyncStage {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long runId;
private Long stageBatchId;
private Long lastFetchRunId;
private Long observationSequence;
private String warehouseCode;
private String previousWarehouseCode;
private String objectType;
private String objectKeyHash;
private Integer firstPageNo;
private LocalDateTime observedAtUtc;
private String contentHash;
private String payloadJson;
private String beforePayloadJson;
private String validationStatus;
private String validationCode;
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_task")
public class SelfopSyncTask {
private String taskNo;
private String executionPurpose;
private Long validationRootTaskId;
private String validationBudgetJson;
private Long resultPublishedRunId;
private String progressJson;
private LocalDateTime progressUpdatedAtUtc;
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private String taskRole;
private Long scopeId;
private String warehouseCode;
private Long discoveryScopeId;
private Long upstreamTaskId;
private Long discoverySequence;
private Long observationBaseline;
private String requestKey;
private String requestHash;
private String taskType;
private LocalDateTime plannedAtUtc;
private LocalDateTime queryFromUtc;
private LocalDateTime queryUntilUtc;
private String queryScopeJson;
private String routingManifestJson;
private String scopeHash;
private String status;
private String applyState;
private String affectedWarehouseCodesJson;
private LocalDateTime applyStartedAtUtc;
private LocalDateTime applyCompletedAtUtc;
private Integer attemptCount;
private LocalDateTime nextRetryAtUtc;
private String requestedBy;
}
@@ -0,0 +1,163 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */
@Mapper
public interface OutboundAggregateMapper {
@Select("""
SELECT COUNT(*) FROM ll_selfop_outbound_order o
LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id
LEFT JOIN ll_selfop_sync_stage s ON s.observation_sequence=o.last_observation_sequence
AND s.warehouse_code=o.warehouse_code AND s.object_type='OUTBOUND_ORDER'
AND s.validation_status='VALID'
AND EXISTS (SELECT 1 FROM ll_selfop_sync_run origin WHERE origin.id=s.run_id AND origin.source_account=o.source_account)
LEFT JOIN ll_selfop_sync_run r ON r.id=s.run_id AND r.source_account=o.source_account
LEFT JOIN ll_selfop_sync_task t ON t.id=r.task_id
WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1
AND (o.outbound_at_utc IS NULL OR o.business_date IS NULL
OR o.source_timezone IS NULL OR o.source_timezone != #{sourceTimezone}
OR o.business_timezone IS NULL OR o.business_timezone != #{businessTimezone}
OR i.id IS NULL OR i.actual_quantity IS NULL OR i.actual_quantity < 0
OR i.quantity_unit IS NULL OR i.quantity_unit != #{unit}
OR r.id IS NULL OR r.execution_purpose IS NULL OR r.execution_purpose != 'BUSINESS'
OR t.execution_purpose IS NULL OR t.execution_purpose != 'BUSINESS' OR t.validation_root_task_id IS NOT NULL)
""")
long invalidFacts(@Param("account") String account, @Param("warehouse") String warehouse,
@Param("sourceTimezone") String sourceTimezone, @Param("businessTimezone") String businessTimezone,
@Param("unit") String unit);
@Select("""
<script>
SELECT o.customer_code,i.sku_code,o.business_date,
SUM(i.actual_quantity) outbound_quantity,COUNT(DISTINCT o.id) outbound_order_count,
MAX(i.quantity_unit) quantity_unit
FROM ll_selfop_outbound_order o JOIN ll_selfop_outbound_item i ON i.order_id=o.id
WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1
<if test='customer != null'>
AND (o.customer_code > #{customer} OR (o.customer_code=#{customer} AND i.sku_code > #{sku})
OR (o.customer_code=#{customer} AND i.sku_code=#{sku} AND o.business_date > #{date}))
</if>
GROUP BY o.customer_code,i.sku_code,o.business_date
ORDER BY o.customer_code,i.sku_code,o.business_date LIMIT 500
</script>
""")
List<SelfopSkuDailySales> dailyBatch(@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku,@Param("date") LocalDate date);
@Select("""
<script>
SELECT customer_code,sku_code,MAX(product_name) product_name,MAX(quantity_unit) quantity_unit,
MIN(observed_at_utc) first_seen_at_utc,MIN(outbound_at_utc) earliest_evidence_at_utc,
MAX(CASE WHEN outbound_rank=1 THEN observation_run_id END) evidence_run_id,
MAX(CASE WHEN seen_rank=1 THEN observation_run_id END) last_seen_run_id
FROM (
SELECT o.customer_code,i.sku_code,i.product_name,i.quantity_unit,o.outbound_at_utc,
s.observed_at_utc,s.run_id observation_run_id,
ROW_NUMBER() OVER (PARTITION BY o.customer_code,i.sku_code ORDER BY o.outbound_at_utc,o.id) outbound_rank,
ROW_NUMBER() OVER (PARTITION BY o.customer_code,i.sku_code ORDER BY s.observed_at_utc DESC,s.id DESC) seen_rank
FROM ll_selfop_outbound_order o JOIN ll_selfop_outbound_item i ON i.order_id=o.id
JOIN ll_selfop_sync_stage s ON s.observation_sequence=o.last_observation_sequence
AND s.warehouse_code=o.warehouse_code AND s.object_type='OUTBOUND_ORDER' AND s.validation_status='VALID'
JOIN ll_selfop_sync_run r ON r.id=s.run_id AND r.source_account=o.source_account AND r.execution_purpose='BUSINESS'
JOIN ll_selfop_sync_task t ON t.id=r.task_id AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL
WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1
<if test='customer != null'>
AND (o.customer_code > #{customer} OR (o.customer_code=#{customer} AND i.sku_code > #{sku}))
</if>
) accepted
GROUP BY customer_code,sku_code ORDER BY customer_code,sku_code LIMIT 500
</script>
""")
List<SelfopSkuProfile> profileBatch(@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku);
@Select("""
<script>
SELECT * FROM ll_selfop_sku_profile WHERE profile_run_id=#{runId}
<if test='customer != null'>AND (customer_code > #{customer} OR (customer_code=#{customer} AND sku_code > #{sku}))</if>
ORDER BY customer_code,sku_code LIMIT 500
</script>
""")
List<SelfopSkuProfile> publishedProfileBatch(@Param("runId") Long runId,@Param("customer") String customer,@Param("sku") String sku);
@Insert("""
<script>
INSERT INTO ll_selfop_sku_daily_sales
(id,created_at,updated_at,sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date,
outbound_quantity,outbound_order_count,quantity_unit) VALUES
<foreach collection='rows' item='r' separator=','>
(#{r.id},#{r.createdAt},#{r.updatedAt},#{r.salesRunId},#{r.sourceAccount},#{r.customerCode},#{r.warehouseCode},
#{r.skuCode},#{r.businessDate},#{r.outboundQuantity},#{r.outboundOrderCount},#{r.quantityUnit})
</foreach>
</script>
""")
int insertDaily(@Param("rows") List<SelfopSkuDailySales> rows);
@Insert("""
<script>
INSERT INTO ll_selfop_sku_profile
(id,created_at,updated_at,profile_run_id,source_account,customer_code,warehouse_code,sku_code,product_name,
quantity_unit,first_seen_at_utc,earliest_evidence_at_utc,evidence_type,evidence_run_id,last_seen_run_id) VALUES
<foreach collection='rows' item='r' separator=','>
(#{r.id},#{r.createdAt},#{r.updatedAt},#{r.profileRunId},#{r.sourceAccount},#{r.customerCode},#{r.warehouseCode},
#{r.skuCode},#{r.productName},#{r.quantityUnit},#{r.firstSeenAtUtc},#{r.earliestEvidenceAtUtc},#{r.evidenceType},
#{r.evidenceRunId},#{r.lastSeenRunId})
</foreach>
</script>
""")
int insertProfiles(@Param("rows") List<SelfopSkuProfile> rows);
@Select("""
<script>
SELECT id,query_from_utc,query_until_utc FROM ll_selfop_sync_task WHERE scope_id=#{scopeId} AND scope_hash=#{scopeHash}
AND execution_purpose='BUSINESS' AND apply_state='APPLIED'
AND query_from_utc IS NOT NULL AND query_until_utc IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(routing_manifest_json,'$.directoryComplete'))='true'
<if test='id != null'>AND (query_from_utc > #{from} OR (query_from_utc=#{from} AND id > #{id}))</if>
ORDER BY query_from_utc,id LIMIT 500
</script>
""")
List<SelfopSyncTask> coverageBatch(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,
@Param("from") LocalDateTime from,@Param("id") Long id);
@Select("""
<script>
SELECT CAST(#{initial} AS DATETIME) AS covered_from_utc,MAX(until_at) AS covered_until_utc
FROM (
SELECT from_at,until_at,SUM(CASE WHEN previous_until IS NULL OR from_at > previous_until THEN 1 ELSE 0 END)
OVER (ORDER BY from_at,until_at ROWS UNBOUNDED PRECEDING) AS island
FROM (
SELECT from_at,until_at,MAX(until_at)
OVER (ORDER BY from_at,until_at ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS previous_until
FROM (
SELECT query_from_utc AS from_at,query_until_utc AS until_at FROM ll_selfop_sync_task
WHERE scope_id=#{scopeId} AND scope_hash=#{scopeHash} AND execution_purpose='BUSINESS' AND apply_state='APPLIED'
AND query_from_utc IS NOT NULL AND query_until_utc > query_from_utc AND query_until_utc >= #{initial}
AND JSON_UNQUOTE(JSON_EXTRACT(routing_manifest_json,'$.directoryComplete'))='true'
UNION ALL SELECT CAST(#{initial} AS DATETIME),CAST(#{initial} AS DATETIME)
<if test='discoveryFrom != null and discoveryUntil != null'>
UNION ALL SELECT CAST(#{discoveryFrom} AS DATETIME),CAST(#{discoveryUntil} AS DATETIME)
WHERE #{discoveryUntil} > #{discoveryFrom} AND #{discoveryUntil} >= #{initial}
</if>
) AS intervals
) AS with_previous
) AS islands
GROUP BY island HAVING MIN(from_at) &lt;= #{initial} AND MAX(until_at) > #{initial}
</script>
""")
SelfopSyncRun contiguousCoverage(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,
@Param("initial") LocalDateTime initial,@Param("discoveryFrom") LocalDateTime discoveryFrom,
@Param("discoveryUntil") LocalDateTime discoveryUntil);
@Select("""
SELECT COUNT(*) FROM ll_selfop_outbound_order WHERE source_account=#{account} AND warehouse_code=#{warehouse}
AND sales_eligible=1 AND updated_at >= #{buildStarted}
""")
long observationsChangedSince(@Param("account") String account,@Param("warehouse") String warehouse,
@Param("buildStarted") LocalDateTime buildStarted);
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
/** Orchestration has no encompassing transaction: checkpoints and heartbeats commit after every batch. */
@Service
@RequiredArgsConstructor
public class OutboundCandidateBuilder {
private final OutboundCandidateTransaction transaction;
public void build(Long scopeId,Long runId,String owner,long fence) {
OutboundCandidateTransaction.Checkpoint checkpoint=transaction.begin(scopeId,runId,owner,fence);
while(!"FINISH".equals(checkpoint.phase()) && !"DONE".equals(checkpoint.phase()))checkpoint=transaction.batch(scopeId,runId,owner,fence);
if("FINISH".equals(checkpoint.phase()))transaction.finish(scopeId,runId,owner,fence);
}
public Long reusable(Long scopeId,Long runId,String owner,long fence) {return transaction.reusable(scopeId,runId,owner,fence);}
public static boolean regularBuildDue(SelfopSyncTask task,LocalDateTime previousBuild,LocalDateTime now) {
return "REBUILD".equals(task.getTaskType()) || "RECHECK".equals(task.getTaskType()) || task.getAttemptCount()!=null && task.getAttemptCount()>1
|| previousBuild==null || !now.isBefore(previousBuild.plusMinutes(30));
}
public static Coverage contiguous(LocalDateTime initialFrom,List<Coverage> intervals) {
if(initialFrom==null)return new Coverage(null,null);LocalDateTime until=initialFrom;
for(Coverage interval:intervals.stream().filter(i->i.from()!=null && i.until()!=null && i.from().isBefore(i.until()))
.sorted(Comparator.comparing(Coverage::from)).toList()) {
if(interval.from().isAfter(until))break;if(interval.until().isAfter(until))until=interval.until();
}
return until.equals(initialFrom)?new Coverage(null,null):new Coverage(initialFrom,until);
}
public record Coverage(LocalDateTime from,LocalDateTime until) { }
}
@@ -0,0 +1,159 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
/** Each public method is one committed bounded transaction, invoked through Spring's proxy by the facade. */
@Service
@RequiredArgsConstructor
public class OutboundCandidateTransaction {
private final OutboundLockMapper locks;
private final OutboundFactService facts;
private final SelfopSyncRunMapper runs;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncScopeMapper scopes;
private final SelfopSkuDailySalesMapper daily;
private final SelfopSkuProfileMapper profiles;
private final OutboundAggregateMapper aggregates;
private final SelfopAnalyticsProperties config;
public record Checkpoint(Long runId,String phase,String customer,String sku,LocalDate date,long dailyRows,long profileRows,long committedBatches) { }
private record Held(SelfopSyncScope scope,SelfopSyncRun run,SelfopSyncTask task) { }
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public Checkpoint begin(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,false);
SelfopSyncRun run=held.run(); SelfopSyncScope scope=held.scope();
SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(run.getStatus()),"CANDIDATE_RUN_INVALID");
SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED");
facts.assertNoBarrier(scope); validateFacts(scope);
if("READY".equals(run.getStatus())) return new Checkpoint(runId,"DONE",null,null,null,0,0,0);
Checkpoint existing=checkpoint(held.task());
if(run.getBuildStartedAtUtc()!=null && existing!=null && Objects.equals(existing.runId(),runId)) {
SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED");
SelfopSupport.require(aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"CANDIDATE_OBSERVATION_CHANGED");
return existing;
}
SelfopSupport.require(run.getBuildStartedAtUtc()==null,"CANDIDATE_CHECKPOINT_MISSING");
// A new attempt has a new run ID; failed attempts are never overwritten or emptied.
daily.delete(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId));
profiles.delete(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId));
OutboundCandidateBuilder.Coverage coverage=coverage(scope);
run.setBasePublishedRunId(scope.getPublishedRunId());
run.setCoveredFromUtc(coverage.from());run.setCoveredUntilUtc(coverage.until());
OutboundPolicy.DayCoverage days=OutboundPolicy.completeDays(coverage.from(),coverage.until(),scope.getBusinessTimezone());
run.setCompleteDayFrom(days.from());run.setCompleteDayUntil(days.until());run.setCoverageStatus(coverage.until()==null?"UNKNOWN":"COMPLETE");
run.setCalculatedFactRevision(scope.getFactRevision());run.setBuildStartedAtUtc(SelfopSupport.utcNow());run.setStatus("BUILDING");
Checkpoint result=new Checkpoint(runId,"DAILY",null,null,null,0,0,0);save(held,result);return result;
}
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public Checkpoint batch(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,true); Checkpoint cursor=checkpoint(held.task());
SelfopSupport.require(cursor!=null && Objects.equals(runId,cursor.runId()),"CANDIDATE_CHECKPOINT_MISSING");
Checkpoint next;
if("DAILY".equals(cursor.phase())) {
List<SelfopSkuDailySales> rows=aggregates.dailyBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku(),cursor.date());
SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT");
if(rows.isEmpty()) next=new Checkpoint(runId,"PROFILE",null,null,null,cursor.dailyRows(),0,cursor.committedBatches());
else {
for(var row:rows){row.setId(IdWorker.getId());row.setSalesRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());}
aggregates.insertDaily(rows);var last=rows.get(rows.size()-1);
SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()) || !Objects.equals(cursor.date(),last.getBusinessDate()),"CANDIDATE_CURSOR_STALLED");
next=new Checkpoint(runId,"DAILY",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate(),cursor.dailyRows()+rows.size(),0,cursor.committedBatches()+1);
}
} else if("PROFILE".equals(cursor.phase())) {
List<SelfopSkuProfile> rows=aggregates.profileBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku());
SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT");
if(rows.isEmpty()) next=new Checkpoint(runId,"FINISH",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches());
else {
for(var row:rows){row.setId(IdWorker.getId());row.setProfileRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setEvidenceType("VALID_OUTBOUND");row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());}
aggregates.insertProfiles(rows);var last=rows.get(rows.size()-1);
SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()),"CANDIDATE_CURSOR_STALLED");
next=new Checkpoint(runId,"PROFILE",last.getCustomerCode(),last.getSkuCode(),null,cursor.dailyRows(),cursor.profileRows()+rows.size(),cursor.committedBatches()+1);
}
} else {SelfopSupport.require("FINISH".equals(cursor.phase()),"CANDIDATE_PHASE_INVALID");return cursor;}
save(held,next);return next;
}
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public void finish(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,true);Checkpoint cursor=checkpoint(held.task());
SelfopSupport.require(cursor!=null && Objects.equals(cursor.runId(),runId) && "FINISH".equals(cursor.phase()),"CANDIDATE_NOT_COMPLETE");
SelfopSupport.require(daily.selectCount(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId))==cursor.dailyRows()
&& profiles.selectCount(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId))==cursor.profileRows(),"CANDIDATE_ROWS_INCOMPLETE");
facts.assertNoBarrier(held.scope());held.run().setStatus("READY");save(held,new Checkpoint(runId,"DONE",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches()));
}
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public Long reusable(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,false);SelfopSyncScope scope=held.scope();
SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(held.run().getStatus()),"CANDIDATE_RUN_INVALID");
SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED");
facts.assertNoBarrier(scope);validateFacts(scope);
if(scope.getPublishedRunId()==null)return null;
SelfopSyncRun published=runs.selectById(scope.getPublishedRunId());
if(published==null || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose())
|| published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash())
|| !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision())
|| !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null;
OutboundCandidateBuilder.Coverage next=coverage(scope);
if(!Objects.equals(next.from(),published.getCoveredFromUtc()) || !Objects.equals(next.until(),published.getCoveredUntilUtc()))return null;
// Content-identical accepted details still change receipt/run evidence; conservatively rebuild them.
return aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),published.getBuildStartedAtUtc())==0?published.getId():null;
}
private Held held(Long scopeId,Long runId,String owner,long fence,boolean building) {
SelfopSyncScope scope=locks.scope(scopeId);LocalDateTime now=SelfopSupport.utcNow();
SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()) && Objects.equals(scope.getLeaseOwner(),owner) && Objects.equals(scope.getFenceToken(),fence)
&& scope.getLeaseUntilUtc()!=null && scope.getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED");
SelfopSyncRun run=locks.run(runId);SelfopCollectionPolicy.requireBusiness(run);
SelfopSyncTask task=locks.task(run.getTaskId());SelfopCollectionPolicy.requireBusiness(task);
SelfopSupport.require(Objects.equals(scopeId,run.getScopeId()) && Objects.equals(run.getFenceToken(),fence) && Objects.equals(scope.getScopeHash(),run.getScopeHash())
&& "WAREHOUSE_SYNC".equals(run.getRunRole()) && Objects.equals(scopeId,task.getScopeId()) && Objects.equals(scope.getSourceAccount(),task.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(),task.getWarehouseCode()) && Objects.equals(scope.getScopeHash(),task.getScopeHash()),"CANDIDATE_RUN_INVALID");
SelfopSupport.require(run.getStatus()!=null,"CANDIDATE_RUN_INVALID");
if(building) {
SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED");
SelfopSupport.require(run.getBuildStartedAtUtc()!=null && aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,
"CANDIDATE_OBSERVATION_CHANGED");
}
scope.setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));scope.setUpdatedAt(now);scopes.updateById(scope);
return new Held(scope,run,task);
}
private void validateFacts(SelfopSyncScope scope) {
Map<?,?> settings=SelfopSupport.read(scope.getScopeJson(),Map.class);String unit=settings.get("quantityUnit") instanceof String s?s:null;
SelfopSupport.require(scope.getSourceTimezone()!=null && !scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED");
SelfopSupport.require(unit!=null && !unit.isBlank(),"QUANTITY_UNIT_UNCONFIRMED");
SelfopSupport.require(aggregates.invalidFacts(scope.getSourceAccount(),scope.getWarehouseCode(),scope.getSourceTimezone(),scope.getBusinessTimezone(),unit)==0,"FACT_TIME_UNIT_OR_EVIDENCE_INVALID");
}
private OutboundCandidateBuilder.Coverage coverage(SelfopSyncScope scope) {
if(scope.getInitialFromUtc()==null)return new OutboundCandidateBuilder.Coverage(null,null);
SelfopSyncRun result=aggregates.contiguousCoverage(scope.getId(),scope.getScopeHash(),scope.getInitialFromUtc(),scope.getDiscoveryFromUtc(),scope.getDiscoveryUntilUtc());
return result==null?new OutboundCandidateBuilder.Coverage(null,null):new OutboundCandidateBuilder.Coverage(result.getCoveredFromUtc(),result.getCoveredUntilUtc());
}
private Checkpoint checkpoint(SelfopSyncTask task) {
if(task.getProgressJson()==null)return null;Map<?,?> progress=SelfopSupport.read(task.getProgressJson(),Map.class);Object value=progress.get("candidateBuild");
return value==null?null:SelfopSupport.read(SelfopSupport.json(value),Checkpoint.class);
}
private void save(Held held,Checkpoint checkpoint) {
LocalDateTime now=SelfopSupport.utcNow();
SelfopSupport.require(held.scope().getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED");
held.scope().setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));held.scope().setUpdatedAt(now);scopes.updateById(held.scope());
Map<String,Object> progress=new LinkedHashMap<>();
if(held.task().getProgressJson()!=null)SelfopSupport.read(held.task().getProgressJson(),Map.class).forEach((k,v)->progress.put(String.valueOf(k),v));
if(!(progress.get("attemptNo") instanceof Number attempt) || !Objects.equals(attempt.intValue(),held.run().getAttemptNo()))progress.clear();
progress.put("attemptNo",held.run().getAttemptNo());progress.put("candidateBuild",checkpoint);progress.remove("processedCount");
held.task().setProgressJson(SelfopSupport.json(progress));
SelfopProgressProjection.applyCheckpoint(held.task(),held.run(),"BUILD","BATCH",checkpoint.committedBatches(),null,0);
tasks.updateById(held.task());runs.updateById(held.run());
}
}
@@ -0,0 +1,245 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
/** Short bounded transactions; all source requests must finish before entering this service. */
@Service
@RequiredArgsConstructor
public class OutboundFactService {
private final OutboundLockMapper locks;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopSyncScopeMapper scopes;
private final SelfopSyncStageMapper stages;
private final SelfopOutboundOrderMapper orders;
private final SelfopOutboundItemMapper items;
public record Lease(String owner, long fence) {}
@Transactional(rollbackFor = Exception.class)
public void merge(Long taskId, Long runId, List<Long> stageIds, Map<String, Lease> leases) {
SelfopSupport.require(stageIds != null && stageIds.size() <= 100, "MERGE_BATCH_LIMIT");
SelfopSyncTask preview = tasks.selectById(taskId);
SelfopSupport.require(preview != null, "TASK_MISSING");
SelfopCollectionPolicy.requireBusiness(preview);
// Discover the entire old/new lock set before touching facts. Recheck after order locks.
SortedSet<String> warehouses = affected(preview);
warehouses.add(preview.getWarehouseCode());
// Coordinator acquired this full old/new set from the entire stable routing manifest,
// not only this bounded segment. Register it before the first fact mutation.
warehouses.addAll(leases.keySet());
for (Long id : stageIds) {
SelfopSyncStage s = stages.selectById(id);
SelfopSupport.require(s != null, "STAGE_MISSING");
SelfopPayloads.Order p = SelfopSupport.read(s.getPayloadJson(),SelfopPayloads.Order.class);
warehouses.add(p.warehouseCode());
SelfopOutboundOrder old = findOrder(p);
if (old != null) warehouses.add(old.getWarehouseCode());
}
Map<String,SelfopSyncScope> held = lockScopes(preview.getSourceAccount(),warehouses,leases);
SelfopSyncTask task = locks.task(taskId);
SelfopSyncRun run = locks.run(runId);
validateTaskRun(task,run,held);
SelfopRoutingManifest manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
SelfopSupport.require(!"APPLIED".equals(task.getApplyState()), "TASK_ALREADY_APPLIED");
task.setAffectedWarehouseCodesJson(SelfopSupport.json(warehouses));
task.setApplyState("APPLYING");
if (task.getApplyStartedAtUtc() == null) task.setApplyStartedAtUtc(SelfopSupport.utcNow());
task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task);
for (Long id : stageIds) {
SelfopSyncStage stage=locks.stage(id); applyOne(task,run,stage,held,manifest);
manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId());
}
task.setRoutingManifestJson(SelfopSupport.json(manifest));
mergeProgress(task,run,manifest);tasks.updateById(task);
run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision());
run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run);
}
/** The caller supplies the stable complete routing set, including refreshed effective references. */
@Transactional(rollbackFor = Exception.class)
public void finish(Long taskId, Long runId, List<Long> allEffectiveStageIds, Map<String,Lease> leases) {
SelfopSyncTask preview = tasks.selectById(taskId);
SelfopSupport.require(preview != null,"TASK_MISSING");
SelfopCollectionPolicy.requireBusiness(preview);
SortedSet<String> warehouseSet = affected(preview); warehouseSet.add(preview.getWarehouseCode());
Map<String,SelfopSyncScope> held = lockScopes(preview.getSourceAccount(),warehouseSet,leases);
SelfopSyncTask task = locks.task(taskId); SelfopSyncRun run = locks.run(runId);
validateTaskRun(task,run,held);
SelfopSupport.require(allEffectiveStageIds != null && new HashSet<>(allEffectiveStageIds).size() == allEffectiveStageIds.size(),"ROUTING_SET_INVALID");
SelfopRoutingManifest manifest = SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
SelfopSupport.require(manifest.isDirectoryComplete(),"CATALOG_NOT_COMPLETE");
Set<Long> required = new HashSet<>();
for(SelfopRoutingManifest.Entry entry : manifest.getObjects().values()) {
SelfopSupport.require(entry.getEffectiveStageId() != null,"DETAIL_RECHECK_REQUIRED");
required.add(entry.getEffectiveStageId());
}
SelfopSupport.require(required.equals(new HashSet<>(allEffectiveStageIds)),"ROUTING_SET_INCOMPLETE");
for (Long id : allEffectiveStageIds) {
SelfopSyncStage stage = locks.stage(id);
SelfopPayloads.Order p = validateStage(task, stage,manifest);
SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo());
SelfopSupport.require(order != null && Objects.equals(order.getLastObservationSequence(),stage.getObservationSequence())
&& Objects.equals(order.getContentHash(),factHash(p,held.get(p.warehouseCode()))),"OBJECT_NOT_APPLIED");
manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId());
}
task.setApplyState("APPLIED"); task.setApplyCompletedAtUtc(SelfopSupport.utcNow());
task.setRoutingManifestJson(SelfopSupport.json(manifest));mergeProgress(task,run,manifest);
task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task);
run.setStatus("FACTS_APPLIED");
run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision());
run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run);
}
public void assertNoBarrier(SelfopSyncScope scope) {
SelfopSupport.require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode()).isEmpty(),"HALF_APPLIED_FACTS");
}
private void applyOne(SelfopSyncTask task, SelfopSyncRun run, SelfopSyncStage stage, Map<String,SelfopSyncScope> held,SelfopRoutingManifest manifest) {
SelfopPayloads.Order p = validateStage(task,stage,manifest);
SelfopSyncScope target = held.get(p.warehouseCode());
SelfopSupport.require(target != null,"LOCK_SET_CHANGED");
LocalDateTime utc = OutboundPolicy.outboundUtc(p,target);
String unit = unit(target);
for(SelfopPayloads.Item item : p.items()) SelfopSupport.require(Objects.equals(unit,item.quantityUnit()),"QUANTITY_UNIT_UNCONFIRMED");
SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo());
SelfopSupport.require(order == null || held.containsKey(order.getWarehouseCode()),"LOCK_SET_CHANGED");
SelfopSupport.require(order == null || order.getLastObservationSequence() <= stage.getObservationSequence(),"DETAIL_RECHECK_REQUIRED");
String hash = factHash(p,target);
if(order != null && Objects.equals(order.getContentHash(),hash)) {
order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence());
order.setUpdatedAt(SelfopSupport.utcNow()); orders.updateById(order); return;
}
SelfopSupport.require(order == null || order.getLastObservationSequence() < stage.getObservationSequence(),"OBSERVATION_CONTENT_CONFLICT");
String oldWarehouse = order == null ? null : order.getWarehouseCode();
if(order != null && stage.getBeforePayloadJson() == null) {
Map<String,Object> before = new LinkedHashMap<>(); before.put("order",order);
before.put("items",items.selectList(new QueryWrapper<SelfopOutboundItem>().eq("order_id",order.getId()).orderByAsc("sku_code")));
stages.update(null,new UpdateWrapper<SelfopSyncStage>().eq("id",stage.getId())
.isNull("before_payload_json").set("before_payload_json",SelfopSupport.json(before)).set("updated_at",SelfopSupport.utcNow()));
}
boolean fresh = order == null;
if(fresh) { order = new SelfopOutboundOrder(); order.setCreatedAt(SelfopSupport.utcNow()); order.setRevisionNo(1L); }
else order.setRevisionNo(order.getRevisionNo()+1);
order.setSourceAccount(p.sourceAccount()); order.setCustomerCode(p.customerCode()); order.setWarehouseCode(p.warehouseCode());
order.setDeliveryNo(p.deliveryNo()); order.setSourceNo(p.sourceNo()); order.setSourceStatus(p.sourceStatus());
order.setSourceCreatedTime(p.sourceCreatedTime()); order.setSourceOutboundTime(p.sourceOutboundTime());
order.setSourceTimezone(target.getSourceTimezone()); order.setBusinessTimezone(target.getBusinessTimezone());
order.setOutboundAtUtc(utc); order.setBusinessDate(utc == null ? null : utc.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of(target.getBusinessTimezone())).toLocalDate());
order.setSalesEligible(Integer.valueOf(100).equals(p.sourceStatus()) ? 1 : 0);
order.setContentHash(hash); order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence());
order.setUpdatedAt(SelfopSupport.utcNow());
if(fresh) orders.insert(order);
else {
orders.updateById(order);
// Explicit nulls are meaningful for cancellation and corrections; MyBatis default ignores null entity fields.
orders.update(null,new UpdateWrapper<SelfopOutboundOrder>().eq("id",order.getId())
.set("outbound_at_utc",order.getOutboundAtUtc()).set("business_date",order.getBusinessDate())
.set("source_no",order.getSourceNo()).set("source_created_time",order.getSourceCreatedTime())
.set("source_outbound_time",order.getSourceOutboundTime()));
items.delete(new QueryWrapper<SelfopOutboundItem>().eq("order_id",order.getId()));
}
for (SelfopPayloads.Item value : p.items()) {
SelfopOutboundItem item = new SelfopOutboundItem(); item.setOrderId(order.getId()); item.setSkuCode(value.skuCode());
item.setProductName(value.productName()); item.setActualQuantity(value.actualQuantity()); item.setQuantityUnit(value.quantityUnit());
item.setCreatedAt(SelfopSupport.utcNow()); item.setUpdatedAt(item.getCreatedAt()); items.insert(item);
}
Set<String> changed = new HashSet<>(); changed.add(p.warehouseCode()); if(oldWarehouse != null) changed.add(oldWarehouse);
for(String warehouse : changed) {
SelfopSyncScope scope = held.get(warehouse); scope.setFactRevision(scope.getFactRevision()+1);
scope.setRebuildRequired(1); scope.setInvalidatedAtUtc(SelfopSupport.utcNow()); scope.setUpdatedAt(SelfopSupport.utcNow()); scopes.updateById(scope);
if(oldWarehouse != null && !oldWarehouse.equals(p.warehouseCode())) rebuild(task,scope,order);
}
}
private void rebuild(SelfopSyncTask upstream,SelfopSyncScope scope,SelfopOutboundOrder order) {
String key = "rebuild:"+upstream.getId()+":"+order.getId()+":"+order.getRevisionNo()+":"+scope.getWarehouseCode();
if(tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("source_account",scope.getSourceAccount()).eq("request_key",key)) > 0) return;
SelfopSyncTask task = new SelfopSyncTask(); task.setSourceAccount(scope.getSourceAccount());
task.setExecutionPurpose("BUSINESS"); task.setTaskNo("SO-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
task.setTaskRole("WAREHOUSE_SYNC"); task.setScopeId(scope.getId()); task.setWarehouseCode(scope.getWarehouseCode());
task.setRequestKey(key); task.setRequestHash(SelfopSupport.hash(List.of(key,scope.getScopeHash())));
task.setTaskType("REBUILD"); task.setQueryScopeJson(scope.getScopeJson()); task.setScopeHash(scope.getScopeHash());
task.setStatus("READY"); task.setApplyState("NOT_APPLICABLE"); task.setAttemptCount(0);
task.setCreatedAt(SelfopSupport.utcNow()); task.setUpdatedAt(task.getCreatedAt()); tasks.insert(task);
}
private SelfopPayloads.Order validateStage(SelfopSyncTask task,SelfopSyncStage stage,SelfopRoutingManifest manifest) {
SelfopSupport.require(stage != null && "VALID".equals(stage.getValidationStatus())
&& "OUTBOUND_ORDER".equals(stage.getObjectType()) && stage.getObservationSequence() != null,"DETAIL_NOT_VALID");
SelfopSyncRun sourceRun=runs.selectById(stage.getRunId());
SelfopCollectionPolicy.requireBusiness(sourceRun);
SelfopSyncTask sourceTask=tasks.selectById(sourceRun.getTaskId());
SelfopCollectionPolicy.requireBusiness(sourceTask);
SelfopSupport.require(Objects.equals(task.getSourceAccount(),sourceRun.getSourceAccount())
&& Objects.equals(task.getSourceAccount(),sourceTask.getSourceAccount()),"STAGE_IDENTITY_CONFLICT");
SelfopPayloads.Order raw = SelfopSupport.read(stage.getPayloadJson(),SelfopPayloads.Order.class);
SelfopSupport.require(Objects.equals(stage.getContentHash(),SelfopSupport.hash(raw)),"STAGE_CONTENT_CONFLICT");
SelfopPayloads.Order p = OutboundPolicy.normalize(raw);
SelfopRoutingManifest.Entry reference = manifest.getObjects().get(stage.getObjectKeyHash());
SelfopSupport.require(reference != null && Objects.equals(reference.getEffectiveStageId(),stage.getId()),"STAGE_NOT_ROUTED");
if(reference.getRefreshTaskId() != null) {
SelfopSyncTask refresh = tasks.selectById(reference.getRefreshTaskId());
SelfopSupport.require(refresh != null && "SUCCEEDED".equals(refresh.getStatus())
&& Objects.equals(stage.getStageBatchId(),reference.getRefreshTaskId()),"DETAIL_RECHECK_REQUIRED");
} else SelfopSupport.require(Objects.equals(stage.getStageBatchId(),task.getUpstreamTaskId()),"STAGE_BATCH_CONFLICT");
SelfopSupport.require(Objects.equals(task.getSourceAccount(),p.sourceAccount()) && Objects.equals(stage.getWarehouseCode(),p.warehouseCode()),"STAGE_IDENTITY_CONFLICT");
SelfopSupport.require(Objects.equals(stage.getObjectKeyHash(),SelfopSupport.hash(List.of(p.sourceAccount(),p.customerCode(),p.deliveryNo()))),"STAGE_KEY_CONFLICT");
SelfopSupport.require(stage.getObservationSequence() >= locks.newestObservation(p.sourceAccount(),stage.getObjectKeyHash()),"DETAIL_RECHECK_REQUIRED");
return p;
}
private void mergeProgress(SelfopSyncTask task,SelfopSyncRun run,SelfopRoutingManifest manifest) {
Set<Long> required=new HashSet<>(),applied=new HashSet<>();
boolean totalKnown=manifest.isDirectoryComplete();
for(SelfopRoutingManifest.Entry entry:manifest.getObjects().values()) {
if(entry.getEffectiveStageId()==null)totalKnown=false;
if(entry.getEffectiveStageId()!=null)required.add(entry.getEffectiveStageId());
if(entry.getEffectiveStageId()!=null && Objects.equals(entry.getEffectiveStageId(),entry.getAppliedStageId()))applied.add(entry.getAppliedStageId());
}
SelfopProgressProjection.applyCheckpoint(task,run,"MERGE","ORDER",applied.size(),totalKnown?(long)required.size():null,0);
}
private String factHash(SelfopPayloads.Order p,SelfopSyncScope scope) {
SelfopSupport.require(scope != null,"LOCK_SET_CHANGED");
return SelfopSupport.hash(List.of(p,scope.getBusinessTimezone(),scope.getSourceTimezone(),unit(scope)));
}
private String unit(SelfopSyncScope scope) {
Map<?,?> json = SelfopSupport.read(scope.getScopeJson(),Map.class); Object value = json.get("quantityUnit");
SelfopSupport.require(value instanceof String && !((String)value).isBlank(),"QUANTITY_UNIT_UNCONFIRMED");
return (String)value;
}
private SelfopOutboundOrder findOrder(SelfopPayloads.Order p) {
return orders.selectOne(new QueryWrapper<SelfopOutboundOrder>().eq("source_account",p.sourceAccount())
.eq("customer_code",p.customerCode()).eq("delivery_no",p.deliveryNo()));
}
private SortedSet<String> affected(SelfopSyncTask task) {
SortedSet<String> values = new TreeSet<>();
if(task.getAffectedWarehouseCodesJson() != null) values.addAll(Arrays.asList(SelfopSupport.read(task.getAffectedWarehouseCodesJson(),String[].class)));
return values;
}
private Map<String,SelfopSyncScope> lockScopes(String account,SortedSet<String> warehouses,Map<String,Lease> leases) {
Map<String,SelfopSyncScope> held = new LinkedHashMap<>();
for(String warehouse : warehouses) {
SelfopSyncScope scope = locks.warehouse(account,warehouse); Lease lease = leases.get(warehouse);
SelfopSupport.require(scope != null && lease != null && Objects.equals(scope.getLeaseOwner(),lease.owner())
&& Objects.equals(scope.getFenceToken(),lease.fence()) && scope.getLeaseUntilUtc() != null
&& scope.getLeaseUntilUtc().isAfter(SelfopSupport.utcNow()),"LEASE_FENCE_REJECTED");
held.put(warehouse,scope);
}
return held;
}
private void validateTaskRun(SelfopSyncTask task,SelfopSyncRun run,Map<String,SelfopSyncScope> held) {
SelfopCollectionPolicy.requireBusiness(task); SelfopCollectionPolicy.requireBusiness(run);
SelfopSyncScope own = held.get(task.getWarehouseCode());
SelfopSupport.require(run != null && Objects.equals(run.getTaskId(),task.getId()) && Objects.equals(run.getScopeId(),task.getScopeId())
&& Objects.equals(run.getScopeHash(),own.getScopeHash()) && Objects.equals(task.getScopeHash(),own.getScopeHash())
&& Objects.equals(run.getFenceToken(),own.getFenceToken()) && !"PUBLISHED".equals(run.getStatus()),"RUN_SCOPE_FENCE_CONFLICT");
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface OutboundLockMapper {
@Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE")
SelfopSyncScope scope(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND data_family='OUTBOUND' FOR UPDATE")
SelfopSyncScope warehouse(@Param("account") String account,@Param("warehouse") String warehouse);
@Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE")
SelfopSyncTask task(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun run(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_sync_stage WHERE id=#{id} FOR UPDATE")
SelfopSyncStage stage(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_outbound_order WHERE source_account=#{account} AND customer_code=#{customer} AND delivery_no=#{delivery} FOR UPDATE")
SelfopOutboundOrder order(@Param("account") String account,@Param("customer") String customer,@Param("delivery") String delivery);
@Select("SELECT COALESCE(MAX(observation_sequence),0) FROM ll_selfop_sync_stage s JOIN ll_selfop_sync_run r ON r.id=s.run_id JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE r.source_account=#{account} AND r.execution_purpose='BUSINESS' AND t.execution_purpose='BUSINESS' AND s.object_type='OUTBOUND_ORDER' AND s.object_key_hash=#{key} AND s.validation_status='VALID'")
Long newestObservation(@Param("account") String account,@Param("key") String key);
@Select("SELECT * FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
List<SelfopSyncTask> barriers(@Param("account") String account,@Param("warehouse") String warehouse);
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import java.math.BigDecimal;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
/** Source strings are interpreted only after explicit source timezone and unit confirmation. */
public final class OutboundPolicy {
private OutboundPolicy() {}
private static final DateTimeFormatter SOURCE = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
.withResolverStyle(java.time.format.ResolverStyle.STRICT);
public static SelfopPayloads.Order normalize(SelfopPayloads.Order p) {
text(p.sourceAccount(), 64); text(p.customerCode(), 64); text(p.warehouseCode(), 64);
text(p.deliveryNo(), 128);
optional(p.sourceNo(),128); optional(p.sourceCreatedTime(),64); optional(p.sourceOutboundTime(),64);
SelfopSupport.require(p.sourceStatus() != null, "SOURCE_STATUS_MISSING");
SelfopSupport.require(p.items() != null, "DETAIL_ITEMS_MISSING");
SelfopSupport.require(!Integer.valueOf(100).equals(p.sourceStatus()) || !p.items().isEmpty(), "OUTBOUND_ITEMS_EMPTY");
String quantityUnit = p.items().isEmpty() ? null : p.items().get(0).quantityUnit();
TreeMap<String, SelfopPayloads.Item> result = new TreeMap<>();
for (SelfopPayloads.Item item : p.items()) {
text(item.skuCode(),128); optional(item.productName(),255);
SelfopSupport.require(Objects.equals(quantityUnit, item.quantityUnit()) && item.quantityUnit() != null && !item.quantityUnit().isBlank(), "QUANTITY_UNIT_CONFLICT");
BigDecimal value = item.actualQuantity();
SelfopSupport.require(value != null && value.signum() >= 0 && value.scale() <= 6
&& value.precision() - value.scale() <= 14, "INVALID_ACTUAL_QUANTITY");
SelfopPayloads.Item old = result.get(item.skuCode());
BigDecimal sum = old == null ? value : old.actualQuantity().add(value);
SelfopSupport.require(sum.precision() - sum.scale() <= 14, "QUANTITY_OVERFLOW");
result.put(item.skuCode(), new SelfopPayloads.Item(item.skuCode(),
old != null && old.productName() != null ? old.productName() : item.productName(),
sum.stripTrailingZeros(), quantityUnit));
}
return new SelfopPayloads.Order(p.sourceAccount(),p.customerCode(),p.warehouseCode(),p.deliveryNo(),
p.sourceNo(),p.sourceStatus(),p.sourceCreatedTime(),p.sourceOutboundTime(), List.copyOf(result.values()));
}
public static LocalDateTime outboundUtc(SelfopPayloads.Order p, SelfopSyncScope scope) {
SelfopSupport.require(scope.getSourceTimezone() != null && !scope.getSourceTimezone().isBlank(),
"SOURCE_TIMEZONE_UNCONFIRMED");
if (!Integer.valueOf(100).equals(p.sourceStatus())) return null;
text(p.sourceOutboundTime(),64);
LocalDateTime local = LocalDateTime.parse(p.sourceOutboundTime(),SOURCE);
ZoneId zone = ZoneId.of(scope.getSourceTimezone());
List<ZoneOffset> offsets = zone.getRules().getValidOffsets(local);
SelfopSupport.require(offsets.size() == 1, "SOURCE_TIME_DST_AMBIGUOUS_OR_GAP");
return LocalDateTime.ofInstant(local.toInstant(offsets.get(0)),ZoneOffset.UTC);
}
/** Source API includes both endpoint seconds; new facts belong to exactly one internal slice.
* Unknown source timezone retains evidence only; caller must keep formal readiness closed.
* Known facts and explicit rechecks retain revisions even when corrected outside the old slice.
*/
public static boolean belongsToSlice(SelfopPayloads.Order p, SelfopSyncScope scope,
LocalDateTime from, LocalDateTime until,
boolean knownOrder, String taskType) {
if(knownOrder || "RECHECK".equals(taskType) || !Integer.valueOf(100).equals(p.sourceStatus())) return true;
if(scope.getSourceTimezone() == null || scope.getSourceTimezone().isBlank()) return true;
if(from == null || until == null) return true;
SelfopSupport.require(from.isBefore(until),"OUTBOUND_SLICE_INVALID");
LocalDateTime observed = outboundUtc(p,scope);
return !observed.isBefore(from) && observed.isBefore(until);
}
public static DayCoverage completeDays(LocalDateTime from, LocalDateTime until, String timezone) {
if (from == null || until == null || !from.isBefore(until)) return new DayCoverage(null,null);
ZoneId zone = ZoneId.of(timezone);
ZonedDateTime start = from.atZone(ZoneOffset.UTC).withZoneSameInstant(zone);
ZonedDateTime end = until.atZone(ZoneOffset.UTC).withZoneSameInstant(zone);
LocalDate first = start.toLocalDate();
if (!start.toInstant().equals(first.atStartOfDay(zone).toInstant())) first = first.plusDays(1);
LocalDate last = end.toLocalDate();
return first.isAfter(last) ? new DayCoverage(null,null) : new DayCoverage(first,last);
}
public record DayCoverage(LocalDate from, LocalDate until) {}
private static void optional(String value,int max) {
SelfopSupport.require(value == null || value.length() <= max,"IDENTITY_TOO_LONG");
}
private static void text(String value,int max) {
SelfopSupport.require(value != null && !value.isBlank() && value.length() <= max,"IDENTITY_OR_UNIT_MISSING");
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import java.util.Set;
/** Endpoint-specific inventory presentation, separate from immutable version/date identity. */
public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold) {
public String attentionValue(){return attention==null?"ALL":attention;}
public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;}
public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
public void validate(String endpoint,AnalyticsQuery q){
if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数");
if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效");
if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效");
if("COVERAGE_GT_THRESHOLD".equals(attentionValue())&&coverageDaysThreshold==null)throw new BizException("超过可售天数筛选必须指定阈值");
if("OWNERS".equals(endpoint)){
if(keyword!=null&&(keyword.isBlank()||keyword.length()>64))throw new BizException("货主编码前缀不能为空或超长");
if(q.customerCode()!=null||q.sku()!=null||attention!=null||sortBy!=null||coverageDaysThreshold!=null)throw new BizException("货主选项仅接受编码前缀与分页");
}else{
if(keyword!=null)throw new BizException("此接口不接受货主前缀");
if("OVERVIEW".equals(endpoint)){
AnalyticsQuery.text(q.customerCode(),64,"货主");
if(q.sku()!=null||q.current()!=null||q.size()!=null||attention!=null||sortBy!=null)throw new BizException("货主概览不接受SKU、关注、排序或分页条件");
}
}
}
}
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.Map;
/** Immutable, one-scope SQL. Qualities and days aggregate independently before joining SKU keys. */
public final class AnalyticsInventorySql {
private AnalyticsInventorySql(){}
private static String scope(AnalyticsSqlRequest p){
return " source_account=#{p.account} AND warehouse_code=#{p.warehouse}"
+(p.customer()==null?"":" AND customer_code=#{p.customer}")+(p.sku()==null?"":" AND sku_code=#{p.sku}");
}
private static String keys(AnalyticsSqlRequest p){return "SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+scope(p)
+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+scope(p);}
private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
private static String quantity(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";}
private static String cte(AnalyticsSqlRequest p){
return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name,"
+"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid,"
+"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid,"
+"CASE WHEN (s.sku_code IS NULL OR s.quantity_unit='PCS') AND (i.sku_code IS NULL OR i.quantity_unit='PCS') THEN 1 ELSE 0 END unit_valid,"
+"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence,"
+"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence "
+"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code "
+"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code),"
+" stock AS (SELECT customer_code,sku_code,"+quantity("physical_quantity")+" physical_quantity,"+quantity("frozen_quantity")+" frozen_quantity,"
+quantity("transit_quantity")+" transit_quantity,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available_quantity,"
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective_physical_quantity,MAX(observed_at_utc) observed_at_utc "
+"FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code),"
+" sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_quantity,SUM(outbound_order_count) order_count,"
+"MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales "
+"WHERE sales_run_id=#{p.salesRun} AND"+scope(p)+" AND business_date>=#{p.from} AND business_date<#{p.until} GROUP BY customer_code,sku_code),"
+" last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+scope(p)
+" AND business_date<=#{p.anchor} AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code),"
+" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid,"
+"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc,"
+stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc,"
+"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.complete30}=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound,"
+"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count,l.last_outbound "
+"FROM profiles c LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code "
+"LEFT JOIN last_sales l ON l.customer_code=c.customer_code AND l.sku_code=c.sku_code),"
+" qualified AS (SELECT v.*,CASE WHEN evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN 1 ELSE 0 END observation_eligible,"
+"CASE WHEN unit_valid=1 AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN owner_outbound END outbound_quantity30d FROM values_by_sku v),"
+" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*30/outbound_quantity30d END coverage_days,"
+"CASE WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d=0 THEN 1 ELSE 0 END END no_outbound,"
+"CASE WHEN #{o.coverageDaysThreshold} IS NULL THEN NULL WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND available_quantity=0 THEN 0 "
+"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*30>outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold "
+"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) ";
}
private static String stockValue(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END "+field;}
private static String filter(AnalyticsInventoryOptions o){return switch(o.attentionValue()){
case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1";
case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1";
case "FROZEN_STOCK" -> " WHERE frozen_quantity>0";
case "DEFECTIVE_STOCK" -> " WHERE defective_physical_quantity>0";
case "UNASSESSABLE" -> " WHERE unassessable=1";
default -> "";};}
private static String order(AnalyticsInventoryOptions o){String field=switch(o.sortValue()){
case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_30D_ASC" -> "outbound_quantity30d";default -> "available_quantity";};
return field+" IS NULL,"+field+("OUTBOUND_30D_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";}
public static String inventoryPage(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o");
return typed(cte(p)+"SELECT customer_code,sku_code,product_name,unit_valid,evidence_at_utc,physical_quantity,available_quantity,frozen_quantity,transit_quantity,defective_physical_quantity,observed_at_utc,owner_outbound,observed_outbound,order_count,last_outbound,observation_eligible,outbound_quantity30d,coverage_days,no_outbound,above_threshold,unassessable FROM result"+filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}");}
public static String inventoryCount(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o");
return typed("ALL".equals(o.attentionValue())?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));}
public static String ownerTotals(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count");
for(String field:java.util.List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound"))
sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
sql.append(",SUM(CASE WHEN physical_quantity>0 THEN 1 WHEN physical_quantity=0 THEN 0 END) physical_sku_known,SUM(observed_outbound) observed_outbound");
for(String risk:java.util.List.of("no_outbound","above_threshold"))sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical_quantity ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown,SUM(CASE WHEN ").append(risk).append(" IS NULL THEN physical_quantity END) ").append(risk).append("_unknown_physical");
return typed(sql+" FROM result");}
private static String ownersBase(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){return "SELECT DISTINCT customer_code FROM ("+keys(p)+") k"+(o.keyword()==null?"":" WHERE customer_code LIKE #{o.keywordPattern} ESCAPE '!'");}
public static String owners(Map<String,Object> args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");}
public static String ownerCount(Map<String,Object> args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");}
private static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import java.time.Instant;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
/** Canonical constructor only: this record is also Spring's HTTP query binding contract. */
public record AnalyticsQuery(String sourceAccount, String warehouseCode, String customerCode, String sku,
String rangePreset, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate startDate,
@DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, String bucket, Integer windowDays,
Integer current, Integer size, Long salesRunId, Long inventoryRunId, String salesScopeHash,
String inventoryScopeHash, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE_TIME) Instant queryAsOf) {
public int pageNumber(){return current==null?1:current;}
public int pageSize(){return size==null?20:size;}
public int rankingDays(){return windowDays==null?30:windowDays;}
public boolean pinned(){return salesRunId!=null;}
public long offset(){return (long)(pageNumber()-1)*pageSize();}
public void validate(boolean chart, boolean exactSku, boolean hot) {
text(sourceAccount,64,"来源账号"); text(warehouseCode,64,"仓库");
if(customerCode!=null)text(customerCode,64,"货主"); if(sku!=null)text(sku,128,"SKU");
if(exactSku){text(customerCode,64,"货主");text(sku,128,"SKU");}
if(pageNumber()<1||pageSize()<1||pageSize()>100)throw new BizException("分页范围为current≥1,size为1至100");
if((salesRunId==null)!=(inventoryRunId==null))throw new BizException("必须携带完整版本对");
if(pinned()){
if(salesRunId<0||inventoryRunId<0||queryAsOf==null)throw new BizException("固定版本必须携带queryAsOf");
if(salesRunId>0&&!hash(salesScopeHash)||inventoryRunId>0&&!hash(inventoryScopeHash))throw new BizException("固定版本缺少范围摘要");
}else if(queryAsOf!=null||salesScopeHash!=null||inventoryScopeHash!=null||pageNumber()!=1)
throw new BizException("后续查询必须携带首屏完整上下文");
if(chart){
if(windowDays!=null)throw new BizException("SKU周期查询不接受windowDays");
if(bucket!=null&&!"DAY".equals(bucket))throw new BizException("仅支持DAY日粒度");
}else{
if(rangePreset!=null||startDate!=null||endDate!=null||bucket!=null)throw new BizException("指标不接受图表周期参数");
if(hot?(rankingDays()!=7&&rankingDays()!=30):(windowDays!=null))throw new BizException("热销仅支持7/30天;其他指标固定30天");
}
}
private static boolean hash(String v){return v!=null&&v.matches("[0-9a-f]{64}");}
static void text(String v,int max,String label){if(v==null||v.isBlank()||v.length()>max)throw new BizException(label+"必填且不能超长");}
}
@@ -0,0 +1,267 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.selfoperated.analytics.statistics.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*;
@Service
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public class AnalyticsQueryService {
private static final ZoneId BUSINESS_ZONE=ZoneId.of("Asia/Shanghai");
private final AnalyticsReadMapper mapper;
private final ObjectMapper json;
private final Clock clock;
@Value("${baiyue.self-operated-analytics.max-query-days:366}") private int maxQueryDays=366;
@Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7;
@Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json){this(mapper,json,Clock.systemUTC());}
AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock){this.mapper=mapper;this.json=json;this.clock=clock;}
private record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted,
LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){}
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q){
Snapshot s=context(q,true,false,false); AnalyticsSqlRequest p=request(s,"SUMMARY");
long count=mapper.count(p);
var records=mapper.page(p).stream().map(r->summary(s,r)).toList();
return page(s,records,count,Map.of());
}
public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){
Snapshot s=context(q,true,true,false);
Profile sp=profile(s,s.sales),ip=profile(s,s.inventory);
if(sp==null&&ip==null)throw new BizException("所选固定版本中无该货主和SKU记录");
String unit=sp!=null?sp.quantityUnit():ip==null?null:ip.quantityUnit();
boolean valid="PCS".equals(unit);
List<Daily> rows=s.sales==null?List.of():mapper.daily(s.sales.id(),q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),
s.range.startDate(),s.range.endDateExclusive());
Map<LocalDate,Daily> daily=new HashMap<>();
for(Daily row:rows){
if(daily.put(row.businessDate(),row)!=null)throw new BizException("已发布版本存在重复日行");
if(!Objects.equals(unit,row.quantityUnit()))valid=false;
}
Instant evidence=earliest(sp,ip);
List<SalesBucketPolicy.Point> evaluated=new ArrayList<>(); List<AnalyticsResponses.Point> points=new ArrayList<>();
for(LocalDate day=s.range.startDate();day.isBefore(s.range.endDateExclusive());day=day.plusDays(1)){
Daily row=daily.get(day);
var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,s.fullFrom,s.fullUntil,
s.context.salesCoveredFrom(),s.context.salesCoveredUntil(),evidence,row==null?null:row.outboundQuantity(),
row==null?null:row.outboundOrderCount(),valid,s.sourceKnown,s.context.rebuildRequired(),"STALE".equals(s.context.salesStatus())));
evaluated.add(point);
points.add(new AnalyticsResponses.Point(day,point.outboundQuantity(),point.observedQuantity(),point.dataStatus(),
point.reasonCodes(),unit,point.provedFrom(),point.provedUntil()));
}
var totals=SalesBucketPolicy.aggregate(evaluated);
var summary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),name(sp,ip),unit,
totals.outboundQuantity(),totals.observedQuantity(),totals.dataStatus(),totals.reasonCodes(),
totals.completeDays(),totals.partialDays(),totals.unknownDays());
return new AnalyticsResponses.Trend(s.context,summary,List.copyOf(points));
}
public AnalyticsResponses.Page<AnalyticsResponses.Metric> hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);}
public AnalyticsResponses.Page<AnalyticsResponses.Metric> slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);}
public AnalyticsResponses.Page<AnalyticsResponses.Metric> inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY");
long total=mapper.inventoryCount(p,o);
return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of());
}
public AnalyticsResponses.Page<AnalyticsResponses.OwnerOption> owners(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("OWNERS",q);Snapshot s=context(q,false,false,false);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o);
return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,null)).toList(),total,Map.of());
}
public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o);
Map<String,Object> totals=new HashMap<>();if(raw!=null)raw.forEach((key,value)->totals.put(key.toLowerCase(Locale.ROOT),value));
long count=number(totals,"sku_count");if(count==0)return null;
var physical=quantity(totals,"physical_quantity");var outbound=quantity(totals,"owner_outbound");
if(outbound.overallTotal()==null)outbound=new AnalyticsResponses.QuantityTotal(null,decimal(totals,"observed_outbound"),outbound.unknownSkuCount(),
decimal(totals,"observed_outbound")==null?"UNKNOWN":"PARTIAL",List.of("SALES_WINDOW_UNAVAILABLE"));
var average=new AnalyticsResponses.QuantityTotal(divide30(outbound.overallTotal()),divide30(outbound.knownSubtotal()),outbound.unknownSkuCount(),outbound.dataStatus(),outbound.reasonCodes());
var physicalSkus=new AnalyticsResponses.QuantityTotal(physical.unknownSkuCount()==0?decimal(totals,"physical_sku_known"):null,
decimal(totals,"physical_sku_known"),physical.unknownSkuCount(),physical.dataStatus(),physical.reasonCodes());
return new AnalyticsResponses.OwnerOverview(s.context,q.customerCode(),null,count,physicalSkus,physical,quantity(totals,"available_quantity"),
quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average,
risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null));
}
private AnalyticsResponses.Metric inventoryMetric(Snapshot s,Inventory r){
boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID";
String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT";
var calculated=InventoryCoveragePolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted,
complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid()));
List<String> attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_30D");
if(Integer.valueOf(1).equals(r.aboveThreshold()))attention.add("COVERAGE_GT_THRESHOLD");
if(positive(r.frozenQuantity()))attention.add("FROZEN_STOCK");if(positive(r.defectivePhysicalQuantity()))attention.add("DEFECTIVE_STOCK");
if(Boolean.TRUE.equals(r.unassessable()))attention.add("UNASSESSABLE");
List<String> reasons=new ArrayList<>(s.context.reasonCodes());if(!observed)reasons.add("OBSERVATION_INSUFFICIENT");
if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT");
if(r.lastOutbound()==null)reasons.add("NO_OUTBOUND_IN_COLLECTED_HISTORY");
if(!"VALID".equals(calculated.status()))reasons.add(calculated.status());
Map<String,String> statuses=new LinkedHashMap<>();String unknown="STALE".equals(s.context.inventoryStatus())?"STALE":"UNKNOWN";
statuses.put("physicalQuantity",r.physicalQuantity()==null?unknown:"VALID");statuses.put("availableQuantity",r.availableQuantity()==null?unknown:"VALID");
statuses.put("frozenQuantity",r.frozenQuantity()==null?unknown:"VALID");statuses.put("transitQuantity",r.transitQuantity()==null?unknown:"VALID");
statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("outboundQuantity30d",r.outboundQuantity30d()==null?"UNKNOWN":"VALID");
return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null,
r.outboundQuantity30d(),r.orderCount(),null,r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()),
divide30(r.outboundQuantity30d()),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)),
null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses));
}
private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;}
private static LocalDate historyStart(Snapshot s){return s.context.salesCoveredFrom()==null?null:s.context.salesCoveredFrom().atZone(BUSINESS_ZONE).toLocalDate();}
private static BigDecimal divide30(BigDecimal n){return n==null?null:n.divide(BigDecimal.valueOf(30),12,java.math.RoundingMode.HALF_UP);}
private static BigDecimal decimal(Map<String,Object> row,String key){Object n=row.get(key);return n==null?null:n instanceof BigDecimal d?d:new BigDecimal(n.toString());}
private static long number(Map<String,Object> row,String key){BigDecimal n=decimal(row,key);return n==null?0:n.longValueExact();}
private static AnalyticsResponses.QuantityTotal quantity(Map<String,Object> row,String field){
BigDecimal known=decimal(row,field+"_known");long unknown=number(row,field+"_unknown");
return new AnalyticsResponses.QuantityTotal(unknown==0?known:null,known,unknown,unknown==0?"COMPLETE":known==null?"UNKNOWN":"PARTIAL",
unknown==0?List.of():List.of("METRIC_DEPENDENCY_UNKNOWN"));
}
private static AnalyticsResponses.RiskShare risk(Map<String,Object> row,String field,AnalyticsResponses.QuantityTotal physical,boolean unset){
long unknown=number(row,field+"_unknown");long known=number(row,field+"_count");BigDecimal knownPhysical=decimal(row,field+"_physical");
boolean complete=!unset&&unknown==0&&physical.overallTotal()!=null;
BigDecimal ratio=complete&&positive(physical.overallTotal())?knownPhysical.divide(physical.overallTotal(),12,java.math.RoundingMode.HALF_UP):null;
List<String> reasons=unset?List.of("THRESHOLD_NOT_SET"):!complete?List.of("RISK_DEPENDENCY_UNKNOWN"):
!positive(physical.overallTotal())?List.of("NO_PHYSICAL_INVENTORY"):List.of();
return new AnalyticsResponses.RiskShare(complete?known:null,complete?knownPhysical:null,ratio,unset?null:known,unset?null:knownPhysical,
unknown,decimal(row,field+"_unknown_physical"),reasons);
}
private AnalyticsResponses.Page<AnalyticsResponses.Metric> metrics(AnalyticsQuery q,String kind,boolean hot){
Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind);
long total=mapper.count(p);
List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r)).toList();
Map<String,Long> excluded=new TreeMap<>();
if(!"INVENTORY".equals(kind))for(var e:mapper.excluded(p))excluded.put(e.reason(),e.total());
return page(s,rows,total,excluded);
}
private AnalyticsResponses.SalesSummary summary(Snapshot s,Aggregate r){
int complete=r.completeDayCount(),partial=r.partialDayCount(),unknown=r.unknownDayCount();
String status=complete==s.range.dayCount()?"COMPLETE":r.observedQuantity()!=null?"PARTIAL":"UNKNOWN";
List<String> reasons=new ArrayList<>(s.context.reasonCodes());
if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT");
if(partial>0)reasons.add("SALES_DAY_INCOMPLETE"); if(unknown>0)reasons.add("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN");
return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(),
r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown);
}
private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r){
Instant evidence=utc(r.evidenceAtUtc());
boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant());
boolean stockValid="VALID".equals(r.inventoryQuality());
String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":!s.trusted?"SALES_UNTRUSTED":
complete(s,s.range.startDate(),s.range.endDateExclusive())?"COMPLETE":"COVERAGE_INSUFFICIENT";
var result=InventoryCoveragePolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.sales30Quantity(),
s.trusted,complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),observed,Boolean.TRUE.equals(r.unitValid()));
List<String> reasons=new ArrayList<>(s.context.reasonCodes());
if(!"COMPLETE".equals(coverage))reasons.add(coverage);
if(!"VALID".equals(result.status()))reasons.add(result.status());
if(r.inventoryReason()!=null)reasons.add(r.inventoryReason());
BigDecimal today=r.todayQuantity();
if(today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){
Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant();
Instant lower=evidence.isAfter(start)?evidence:start;
if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom();
if(lower.isBefore(s.context.salesCoveredUntil()))today=BigDecimal.ZERO;
}
return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(),
r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null,
stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(),
r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of());
}
private Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){
q.validate(chart,exact,hot); Instant now=clock.instant();
List<Scope> scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode());
Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY");
if(salesScope==null&&stockScope==null)throw new BizException("来源账号或仓库未配置");
Run sales=resolve(salesScope,q.pinned()?q.salesRunId():null,q.salesScopeHash());
Run inventory=resolve(stockScope,q.pinned()?q.inventoryRunId():null,q.inventoryScopeHash());
JsonNode sc=config(sales),ic=config(inventory);
String zone=sales!=null?sc.path("businessTimezone").asText():inventory!=null?ic.path("businessTimezone").asText():
salesScope!=null?salesScope.businessTimezone():stockScope.businessTimezone();
if(!BUSINESS_ZONE.getId().equals(zone)||inventory!=null&&!BUSINESS_ZONE.getId().equals(ic.path("businessTimezone").asText()))
throw new BizException("发布版本不符合已确认的北京时间口径");
Instant asOf=q.pinned()?q.queryAsOf():now;
if(asOf.isAfter(now))throw new BizException("queryAsOf不能晚于服务端时间");
if(sales!=null&&(sales.publishedAtUtc()==null||asOf.isBefore(utc(sales.publishedAtUtc())))
||inventory!=null&&(inventory.publishedAtUtc()==null||asOf.isBefore(utc(inventory.publishedAtUtc()))))
throw new BizException("queryAsOf不能早于所选版本发布时间");
Instant expires=asOf.plusSeconds(1800);
if(sales!=null&&sales.supersededAtUtc()!=null){
Instant deadline=utc(sales.supersededAtUtc()).plus(Duration.ofDays(Math.max(7,retentionDays)));
if(deadline.isBefore(expires))expires=deadline;
}
if(!now.isBefore(expires))throw new BizException("查询上下文或统计版本已过期,请刷新整页");
LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate();
SalesRangePolicy.Range range;
try{
range=chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(),
q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays):
new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30,
anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant());
}catch(IllegalArgumentException e){throw new BizException("查询周期或日期范围无效,最多"+maxQueryDays+"个自然日");}
boolean rebuild=salesScope!=null&&(Boolean.TRUE.equals(salesScope.rebuildRequired())
||!Objects.equals(salesScope.factRevision(),salesScope.publishedFactRevision())||mapper.applyBarriers(q.sourceAccount(),q.warehouseCode())>0);
boolean known=sales!=null&&BUSINESS_ZONE.getId().equals(sc.path("sourceTimezone").asText());
String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(sales.coveredUntilUtc(),threshold(sc))?"STALE":"VALID";
if(rebuild)salesState="REBUILD_REQUIRED";
String inventoryState=inventory==null?"UNKNOWN":stale(inventory.fetchFinishedAtUtc(),threshold(ic))?"STALE":"VALID";
LocalDate fullFrom=sales==null?null:sales.completeDayFrom(),fullUntil=sales==null?null:sales.completeDayUntil();
if(sales!=null&&(sales.coveredFromUtc()==null||sales.coveredUntilUtc()==null||!sales.coveredFromUtc().isBefore(sales.coveredUntilUtc()))){
fullFrom=null;fullUntil=null;
}
if(sales!=null&&sales.coveredFromUtc()!=null&&fullFrom!=null){
var covered=utc(sales.coveredFromUtc()).atZone(BUSINESS_ZONE); LocalDate first=covered.toLocalDate();
if(!covered.toLocalTime().equals(LocalTime.MIDNIGHT))first=first.plusDays(1);
if(first.isAfter(fullFrom))fullFrom=first;
}
if(sales!=null&&sales.coveredUntilUtc()!=null&&fullUntil!=null){
LocalDate last=utc(sales.coveredUntilUtc()).atZone(BUSINESS_ZONE).toLocalDate();
if(last.isBefore(fullUntil))fullUntil=last; if(anchor.isBefore(fullUntil))fullUntil=anchor;
}
String review=reviewStatus(salesScope); List<String> reasons=new ArrayList<>();
if(!"VALID".equals(salesState))reasons.add("SALES_"+salesState);
if(!"VALID".equals(inventoryState))reasons.add("INVENTORY_"+inventoryState);
if(!"CURRENT".equals(review))reasons.add("REVISION_REVIEW_"+review);
var c=new AnalyticsResponses.Context(sales==null?0L:sales.id(),inventory==null?0L:inventory.id(),sales==null?null:sales.scopeHash(),
inventory==null?null:inventory.scopeHash(),asOf,expires,zone,anchor,range.startDate(),range.endDateExclusive(),chart?null:range.dayCount(),
fullFrom,fullUntil,sales==null?null:utc(sales.coveredFromUtc()),sales==null?null:utc(sales.coveredUntilUtc()),
sales==null?null:utc(sales.publishedAtUtc()),inventory==null?null:utc(inventory.fetchStartedAtUtc()),
inventory==null?null:utc(inventory.fetchFinishedAtUtc()),salesState,inventoryState,rebuild,review,List.copyOf(reasons));
return new Snapshot(q,sales,inventory,known,"VALID".equals(salesState),fullFrom,fullUntil,range,c);
}
private AnalyticsSqlRequest request(Snapshot s,String kind){
int size=s.q.pageSize(); if("HOT".equals(kind)||"SLOW".equals(kind))size=(int)Math.max(0,Math.min(size,100-s.q.offset()));
return new AnalyticsSqlRequest(s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku(),s.context.salesRunId(),
s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()),
s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown,
s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()),
s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind);
}
private Run resolve(Scope scope,Long requested,String hash){
if(scope==null){if(requested!=null&&requested!=0)throw new BizException("版本任务族未配置");return null;}
Long id=requested==null?scope.publishedRunId():requested; if(id==null||id==0)return null;
Run run=mapper.publishedRun(id);
if(run==null||!Objects.equals(run.scopeId(),scope.id())||!scope.sourceAccount().equals(run.sourceAccount())
||!scope.warehouseCode().equals(run.warehouseCode())||!scope.scopeHash().equals(run.scopeHash())
||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status()))
throw new BizException("版本不可用或范围不符,请刷新整页");
if(!"COMPLETE".equals(run.coverageStatus()))throw new BizException("发布版本缺少完整覆盖证明");
return run;
}
private Profile profile(Snapshot s,Run run){return run==null?null:mapper.profile(run.id(),s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku());}
private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&s.sales!=null&&s.sales.coveredFromUtc()!=null&&s.sales.coveredUntilUtc()!=null
&&s.sales.coveredFromUtc().isBefore(s.sales.coveredUntilUtc())&&s.fullFrom!=null&&s.fullUntil!=null&&!from.isBefore(s.fullFrom)&&!until.isAfter(s.fullUntil);}
private JsonNode config(Run r){if(r==null)return json.createObjectNode();try{JsonNode n=json.readTree(r.scopeSnapshotJson());if(n==null||!n.isObject())throw new IllegalArgumentException();return n;}catch(Exception e){throw new BizException("版本配置快照无效");}}
private static int threshold(JsonNode c){int n=c.path("staleAfterSeconds").asInt();if(n<=0)throw new BizException("版本过期阈值无效");return n;}
private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));}
private String reviewStatus(Scope s){if(s==null||s.knownOrderRoundFinishedAtUtc()==null||s.historyRoundFinishedAtUtc()==null||s.reviewTargetDays()==null||s.reviewTargetDays()<=0)return "UNKNOWN";int age=Math.multiplyExact(s.reviewTargetDays(),86400);return stale(s.knownOrderRoundFinishedAtUtc(),age)||stale(s.historyRoundFinishedAtUtc(),age)?"LAGGING":"CURRENT";}
private static Scope scope(List<Scope> ss,String family){return ss.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null);}
private static String name(Profile s,Profile i){return i!=null&&i.productName()!=null?i.productName():s==null?null:s.productName();}
private static Instant earliest(Profile s,Profile i){Instant a=evidence(s,"VALID_OUTBOUND"),b=evidence(i,"INVENTORY_OBSERVATION");return a==null?b:b==null||a.isBefore(b)?a:b;}
private static Instant evidence(Profile p,String type){return p!=null&&type.equals(p.evidenceType())&&p.evidenceRunId()!=null?utc(p.earliestEvidenceAtUtc()):null;}
private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);}
private static <T> AnalyticsResponses.Page<T> page(Snapshot s,List<T> rows,long total,Map<String,Long> exclusions){int size=s.q.pageSize();return new AnalyticsResponses.Page<>(s.context,rows,s.q.pageNumber(),size,total,total/size+(total%size==0?0:1),Map.copyOf(exclusions));}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.List;
import org.apache.ibatis.annotations.*;
/** Reads immutable published versions in Lastleg only. */
@Mapper
public interface AnalyticsReadMapper {
String RUN_COLUMNS="id,task_id,scope_id,source_account,warehouse_code,run_role,scope_hash,scope_snapshot_json,status,"
+"coverage_status,covered_from_utc,covered_until_utc,complete_day_from,complete_day_until,"
+"fetch_started_at_utc,fetch_finished_at_utc,published_at_utc,calculated_fact_revision,error_code,error_message,superseded_at_utc,execution_purpose";
@Select("SELECT id,source_account,warehouse_code,data_family,scope_hash,business_timezone,source_timezone,"
+"stale_after_seconds,published_run_id,fact_revision,published_fact_revision,rebuild_required,"
+"known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days "
+"FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} ORDER BY data_family")
List<AnalyticsReadRows.Scope> scopes(@Param("account") String account,@Param("warehouse") String warehouse);
@Select("SELECT "+RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE id=#{id} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS'")
AnalyticsReadRows.Run publishedRun(@Param("id") long id);
@SelectProvider(type=AnalyticsReadSql.class,method="page")
List<AnalyticsReadRows.Aggregate> page(@Param("p") AnalyticsSqlRequest request);
@SelectProvider(type=AnalyticsReadSql.class,method="count")
long count(@Param("p") AnalyticsSqlRequest request);
record Exclusion(String reason,Long total){}
@SelectProvider(type=AnalyticsReadSql.class,method="excluded")
List<Exclusion> excluded(@Param("p") AnalyticsSqlRequest request);
@Select("SELECT customer_code,sku_code,product_name,quantity_unit,first_seen_at_utc,earliest_evidence_at_utc,"
+"evidence_type,evidence_run_id,last_seen_run_id FROM ll_selfop_sku_profile WHERE profile_run_id=#{run} "
+"AND source_account=#{account} AND warehouse_code=#{warehouse} AND customer_code=#{customer} AND sku_code=#{sku}")
AnalyticsReadRows.Profile profile(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku);
@Select("SELECT customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit "
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{run} AND source_account=#{account} AND warehouse_code=#{warehouse} "
+"AND customer_code=#{customer} AND sku_code=#{sku} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date")
List<AnalyticsReadRows.Daily> daily(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku,@Param("from") java.time.LocalDate from,@Param("until") java.time.LocalDate until);
@Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') "
+"AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
long applyBarriers(@Param("account") String account,@Param("warehouse") String warehouse);
@SelectProvider(type=AnalyticsInventorySql.class,method="inventoryPage")
List<AnalyticsReadRows.Inventory> inventoryPage(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="inventoryCount")
long inventoryCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="owners")
List<String> owners(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="ownerCount")
long ownerCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="ownerTotals")
java.util.Map<String,Object> ownerTotals(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Read projections only; DATETIME columns are UTC, never JVM-local time. */
public final class AnalyticsReadRows {
private AnalyticsReadRows() {}
public record Scope(Long id, String sourceAccount, String warehouseCode, String dataFamily,
String scopeHash, String businessTimezone, String sourceTimezone,
Integer staleAfterSeconds, Long publishedRunId, Long factRevision,
Long publishedFactRevision, Boolean rebuildRequired,
LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc,
Integer reviewTargetDays) {}
public record Run(Long id, Long taskId, Long scopeId, String sourceAccount, String warehouseCode, String runRole,
String scopeHash, String scopeSnapshotJson, String status, String coverageStatus,
LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc,
LocalDate completeDayFrom, LocalDate completeDayUntil,
LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc,
LocalDateTime publishedAtUtc, Long calculatedFactRevision,
String errorCode, String errorMessage, LocalDateTime supersededAtUtc, String executionPurpose) {}
public record Profile(String customerCode, String skuCode, String productName, String quantityUnit,
LocalDateTime firstSeenAtUtc, LocalDateTime earliestEvidenceAtUtc,
String evidenceType, Long evidenceRunId, Long lastSeenRunId) {}
public record Daily(String customerCode, String skuCode, LocalDate businessDate,
BigDecimal outboundQuantity, Long outboundOrderCount, String quantityUnit) {}
public record Aggregate(String customerCode, String skuCode, String productName, String quantityUnit,
LocalDateTime evidenceAtUtc, Boolean unitValid, BigDecimal observedQuantity, Long skuOrderCount,
Integer completeDayCount, Integer partialDayCount, Integer unknownDayCount,
BigDecimal windowQuantity, Long windowSkuOrderCount, BigDecimal sales30Quantity, BigDecimal todayQuantity,
BigDecimal physicalQuantity, BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity,
LocalDateTime inventoryObservedAtUtc, String inventoryQuality, String inventoryReason) {}
public record Inventory(String customerCode,String skuCode,String productName,Boolean unitValid,
LocalDateTime evidenceAtUtc,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,
BigDecimal transitQuantity,BigDecimal defectivePhysicalQuantity,LocalDateTime observedAtUtc,BigDecimal ownerOutbound,
BigDecimal observedOutbound,Long orderCount,LocalDate lastOutbound,Boolean observationEligible,
BigDecimal outboundQuantity30d,BigDecimal coverageDays,Integer noOutbound,Integer aboveThreshold,Boolean unassessable){}
}
@@ -0,0 +1,147 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
/** MySQL 8 CTEs: bounded by one version, warehouse and date range; no order/item/stage reads. */
public final class AnalyticsReadSql {
private AnalyticsReadSql(){}
private static final String KEY_FILTER=" source_account=#{p.account} AND warehouse_code=#{p.warehouse}"
+" AND (#{p.customer} IS NULL OR customer_code=#{p.customer}) AND (#{p.sku} IS NULL OR sku_code=#{p.sku}) ";
private static String candidates(boolean salesOnly){
String sql="WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"
+KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+"),"
+" dim AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name,"
+"COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit,"
+"CASE WHEN (s.sku_code IS NOT NULL AND s.quantity_unit IS NULL) OR (i.sku_code IS NOT NULL AND i.quantity_unit IS NULL) THEN 0 "
+"WHEN s.quantity_unit IS NOT NULL AND i.quantity_unit IS NOT NULL AND s.quantity_unit<>i.quantity_unit THEN 0 "
+"WHEN COALESCE(s.quantity_unit,i.quantity_unit)<>'PCS' OR COALESCE(s.quantity_unit,i.quantity_unit) IS NULL THEN 0 ELSE 1 END profile_unit_valid,"
+"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence,"
+"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence "
+"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} "
+"AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code "
+"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} "
+"AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code),"
+" evidence AS (SELECT dim.*,CASE WHEN sales_evidence IS NULL THEN stock_evidence WHEN stock_evidence IS NULL THEN sales_evidence "
+"ELSE LEAST(sales_evidence,stock_evidence) END evidence_at_utc FROM dim),";
if(salesOnly){
int start=sql.indexOf("CASE WHEN (s.sku_code IS NOT NULL"),end=sql.indexOf("profile_unit_valid,",start);
sql=sql.substring(0,start)+"CASE WHEN s.sku_code IS NOT NULL THEN CASE WHEN s.quantity_unit='PCS' THEN 1 ELSE 0 END WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END "+sql.substring(end);
sql=sql.replace("COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit","CASE WHEN s.sku_code IS NOT NULL THEN s.quantity_unit ELSE i.quantity_unit END quantity_unit");
}
return sql;
}
private static String cte(String kind){
String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')";
String observedStart="CONVERT_TZ(GREATEST(evidence_at_utc,#{p.coveredFrom}),'+00:00','+08:00')";
String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')";
String sql=candidates("SUMMARY".equals(kind))+" bounds AS (SELECT evidence.*,"
+"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.completeFrom} IS NOT NULL AND #{p.completeUntil} IS NOT NULL THEN "
+"GREATEST(#{p.from},#{p.completeFrom},TIMESTAMPADD(DAY,CASE WHEN CAST("+localEvidence+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+localEvidence+"))) END full_start,"
+"LEAST(#{p.until},#{p.completeUntil},#{p.anchor}) full_end,"
+"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN GREATEST(#{p.from},DATE("+observedStart+")) END observed_start,"
+"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN LEAST(#{p.until},TIMESTAMPADD(DAY,CASE WHEN CAST("+observedEnd+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+observedEnd+"))) END observed_end "
+"FROM evidence),"
+" ranged AS (SELECT b.customer_code,b.sku_code,SUM(d.outbound_quantity) observed_sum,COUNT(d.id) actual_days,"
+"SUM(d.outbound_order_count) order_sum,MIN(d.quantity_unit) min_unit,MAX(d.quantity_unit) max_unit,"
+"SUM(CASE WHEN d.business_date>=#{p.completeFrom} AND d.business_date<#{p.completeUntil} AND d.business_date<#{p.anchor} THEN 1 ELSE 0 END) actual_complete,"
+"SUM(CASE WHEN d.business_date>=b.full_start AND d.business_date<b.full_end THEN 1 ELSE 0 END) actual_full_span,"
+"SUM(CASE WHEN d.business_date>=b.observed_start AND d.business_date<b.observed_end THEN 1 ELSE 0 END) actual_observed_span "
+"FROM bounds b JOIN ll_selfop_sku_daily_sales d ON d.sales_run_id=#{p.salesRun} AND d.source_account=#{p.account} "
+"AND d.warehouse_code=#{p.warehouse} AND d.customer_code=b.customer_code AND d.sku_code=b.sku_code "
+"AND d.business_date>=#{p.from} AND d.business_date<#{p.until} GROUP BY b.customer_code,b.sku_code),"
+" metric_sales AS (SELECT customer_code,sku_code,"
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity,"
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders,"
+"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity,"
+"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity ELSE NULL END) today_quantity,"
+"MIN(quantity_unit) min_unit,MAX(quantity_unit) max_unit "
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER
+" AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<TIMESTAMPADD(DAY,1,#{p.anchor}) "
+"GROUP BY customer_code,sku_code),"
+" counted AS (SELECT b.*,r.observed_sum,r.order_sum,"
+"CASE WHEN b.profile_unit_valid=1 AND (r.min_unit IS NULL OR (r.min_unit=b.quantity_unit AND r.max_unit=b.quantity_unit)) "
+"AND (m.min_unit IS NULL OR (m.min_unit=b.quantity_unit AND m.max_unit=b.quantity_unit)) THEN 1 ELSE 0 END unit_valid,"
+"CASE WHEN #{p.formalSales}=1 THEN GREATEST(0,COALESCE(TIMESTAMPDIFF(DAY,b.full_start,b.full_end),0)) "
+"-COALESCE(r.actual_full_span,0)+COALESCE(r.actual_complete,0) ELSE 0 END complete_count,"
+"CASE WHEN #{p.salesKnown}=1 THEN GREATEST(0,COALESCE(TIMESTAMPDIFF(DAY,b.observed_start,b.observed_end),0)) "
+"-COALESCE(r.actual_observed_span,0)+COALESCE(r.actual_days,0) ELSE 0 END observed_count,"
+"m.window_quantity,m.window_orders,m.sales30_quantity,m.today_quantity "
+"FROM bounds b LEFT JOIN ranged r ON r.customer_code=b.customer_code AND r.sku_code=b.sku_code "
+"LEFT JOIN metric_sales m ON m.customer_code=b.customer_code AND m.sku_code=b.sku_code),"
+" result AS (SELECT c.customer_code,c.sku_code,c.product_name,c.quantity_unit,c.evidence_at_utc,c.unit_valid,"
+"CASE WHEN c.unit_valid=1 AND c.observed_count>0 THEN COALESCE(c.observed_sum,0) END observed_quantity,"
+"c.order_sum sku_order_count,CASE WHEN c.unit_valid=1 THEN c.complete_count ELSE 0 END complete_day_count,"
+"CASE WHEN c.unit_valid=1 THEN GREATEST(0,c.observed_count-c.complete_count) ELSE 0 END partial_day_count,"
+"TIMESTAMPDIFF(DAY,#{p.from},#{p.until})-CASE WHEN c.unit_valid=1 THEN c.observed_count ELSE 0 END unknown_day_count,"
+"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_quantity,0) END window_quantity,"
+"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_orders,0) END window_sku_order_count,"
+"CASE WHEN c.unit_valid=1 AND #{p.complete30}=1 THEN COALESCE(c.sales30_quantity,0) END sales30_quantity,"
+"CASE WHEN c.unit_valid=1 AND #{p.salesKnown}=1 THEN c.today_quantity END today_quantity,"
+"st.physical_quantity,st.available_quantity,st.frozen_quantity,st.transit_quantity,st.observed_at_utc inventory_observed_at_utc,"
+"CASE WHEN st.sku_code IS NULL THEN 'UNKNOWN' WHEN st.quality_status='INVALID' THEN 'INVALID' "
+"WHEN st.quality_status<>'VALID' OR st.available_quantity IS NULL OR st.physical_quantity IS NULL OR st.frozen_quantity IS NULL OR st.transit_quantity IS NULL THEN 'UNKNOWN' "
+"WHEN st.available_quantity<0 OR st.physical_quantity<0 OR st.frozen_quantity<0 OR st.transit_quantity<0 THEN 'INVALID' "
+"WHEN #{p.stockFresh}=0 THEN 'STALE' ELSE 'VALID' END inventory_quality,st.quality_reason inventory_reason "
+"FROM counted c LEFT JOIN ll_selfop_inventory_snapshot st ON st.snapshot_run_id=#{p.inventoryRun} AND st.source_account=#{p.account} "
+"AND st.warehouse_code=#{p.warehouse} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code AND st.product_quality=0) ";
if("SUMMARY".equals(kind)){
int start=sql.indexOf(" metric_sales AS (");int end=sql.indexOf(" counted AS (",start);
sql=sql.substring(0,start)+" metric_sales AS (SELECT customer_code,sku_code,CAST(NULL AS DECIMAL(20,6)) window_quantity,"
+"CAST(NULL AS DECIMAL(20,0)) window_orders,CAST(NULL AS DECIMAL(20,6)) sales30_quantity,"
+"CAST(NULL AS DECIMAL(20,6)) today_quantity,CAST(NULL AS CHAR(32)) min_unit,CAST(NULL AS CHAR(32)) max_unit FROM bounds WHERE 1=0),"+sql.substring(end);
// A sales chart does not require inventory quantities or freshness. Keep only profile evidence.
int stockJoin=sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot st");
sql=sql.substring(0,stockJoin)+"LEFT JOIN (SELECT CAST(NULL AS DECIMAL(20,0)) snapshot_run_id,CAST(NULL AS CHAR(64)) source_account,"
+"CAST(NULL AS CHAR(64)) warehouse_code,CAST(NULL AS CHAR(64)) customer_code,CAST(NULL AS CHAR(128)) sku_code,"
+"CAST(NULL AS DECIMAL(20,6)) physical_quantity,CAST(NULL AS DECIMAL(20,6)) available_quantity,"
+"CAST(NULL AS DECIMAL(20,6)) frozen_quantity,CAST(NULL AS DECIMAL(20,6)) transit_quantity,CAST(NULL AS DATETIME) observed_at_utc,"
+"CAST(NULL AS CHAR(16)) quality_status,CAST(NULL AS CHAR(64)) quality_reason,CAST(NULL AS DECIMAL(6,0)) product_quality) st ON 1=0) ";
}
return sql;
}
private static String filter(String kind){
if("HOT".equals(kind))return " WHERE window_quantity>0 ";
if("SLOW".equals(kind))return " WHERE window_quantity IS NOT NULL AND inventory_quality='VALID' AND available_quantity>0 "
+"AND evidence_at_utc<=CONVERT_TZ(CAST(TIMESTAMPADD(DAY,-30,#{p.anchor}) AS DATETIME),'+08:00','+00:00') ";
return "";
}
private static String order(String kind){
if("HOT".equals(kind))return "window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC";
if("SLOW".equals(kind))return "window_quantity ASC,available_quantity DESC,sku_code ASC,customer_code ASC";
if("SUMMARY".equals(kind))return "observed_quantity IS NULL,observed_quantity DESC,sku_code ASC,customer_code ASC";
return "sku_code ASC,customer_code ASC";
}
public static String page(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p");
return typed(cte(p.kind())+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count,"
+"complete_day_count,partial_day_count,unknown_day_count,window_quantity,window_sku_order_count,sales30_quantity,today_quantity,"
+"physical_quantity,available_quantity,frozen_quantity,transit_quantity,inventory_observed_at_utc,inventory_quality,inventory_reason "
+"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind())+" LIMIT #{p.size} OFFSET #{p.offset}",p);
}
public static String count(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p");
// Candidate totals do not scan daily rows for normal lists.
if(!"HOT".equals(p.kind())&&!"SLOW".equals(p.kind()))return typed("SELECT COUNT(*) FROM (SELECT customer_code,sku_code FROM ll_selfop_sku_profile "
+"WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile "
+"WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+") candidate_keys",p);
return typed(cte(p.kind())+"SELECT LEAST(COUNT(*),100) FROM result"+filter(p.kind()),p);
}
public static String excluded(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p");
String reason="HOT".equals(p.kind())?"CASE WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'ZERO_OUTBOUND' END":
"CASE WHEN inventory_quality<>'VALID' THEN CONCAT('INVENTORY_',inventory_quality) WHEN available_quantity<=0 THEN 'NO_AVAILABLE_INVENTORY' "
+"WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'OBSERVATION_INSUFFICIENT' END";
return typed(cte(p.kind())+"SELECT "+reason+" reason,COUNT(*) total FROM result WHERE NOT ("+filter(p.kind()).replace(" WHERE ","").strip()+") OR window_quantity IS NULL"
+("SLOW".equals(p.kind())?" OR evidence_at_utc IS NULL":"")+" GROUP BY reason",p);
}
private static String typed(String sql,AnalyticsSqlRequest p){
String filters=" source_account=#{p.account} AND warehouse_code=#{p.warehouse} ";
if(p.customer()!=null)filters+="AND customer_code=#{p.customer} ";
if(p.sku()!=null)filters+="AND sku_code=#{p.sku} ";
sql=sql.replace(KEY_FILTER,filters);
// Explicit expression types preserve NULL semantics and avoid inference from bare JDBC parameters.
for(String key:java.util.List.of("from","until","anchor","windowFrom","completeFrom","completeUntil"))
sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");
for(String key:java.util.List.of("coveredFrom","coveredUntil"))
sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATETIME)");
return sql;
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
public final class AnalyticsResponses {
private AnalyticsResponses(){}
public record Context(Long salesRunId,Long inventoryRunId,String salesScopeHash,String inventoryScopeHash,
Instant queryAsOf,Instant queryExpiresAt,String businessTimezone,LocalDate anchorDate,
LocalDate rangeStartDate,LocalDate rangeEndDateExclusive,Integer calculationWindowDays,
LocalDate completeDayFrom,LocalDate completeDayUntil,Instant salesCoveredFrom,Instant salesCoveredUntil,
Instant salesPublishedAt,Instant inventoryObservedFrom,Instant inventoryObservedUntil,
String salesStatus,String inventoryStatus,boolean rebuildRequired,String revisionReviewStatus,List<String> reasonCodes){}
public record Page<T>(Context context,List<T> records,int current,int size,long total,long pages,Map<String,Long> excludedReasons){}
public record SalesSummary(String sourceAccount,String warehouseCode,String customerCode,String sku,String productName,
String quantityUnit,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,List<String> reasonCodes,
int completeDayCount,int partialDayCount,int unknownDayCount){}
public record Point(LocalDate businessDate,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,
List<String> reasonCodes,String quantityUnit,Instant coveredFrom,Instant coveredUntil){}
public record Trend(Context context,SalesSummary summary,List<Point> points){}
public record Metric(String sourceAccount,String customerCode,String warehouseCode,String sku,String productName,
String quantityUnit,BigDecimal outboundQuantity,Long skuOutboundOrderCount,BigDecimal todayObservedQuantity,
BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,BigDecimal transitQuantity,
Instant inventoryObservedAt,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays,
String inventoryQuality,String salesCoverage,boolean observationEligible,Instant earliestReliableEvidenceAt,
String status,List<String> reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity,
BigDecimal outboundQuantity30d,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate,
List<String> attentionReasons,Map<String,String> quantityStatuses){}
public record OwnerOption(String customerCode,String customerName){}
public record QuantityTotal(BigDecimal overallTotal,BigDecimal knownSubtotal,long unknownSkuCount,String dataStatus,List<String> reasonCodes){}
public record RiskShare(Long matchedSkuCount,BigDecimal matchedPhysicalQuantity,BigDecimal physicalQuantityRatio,
Long knownMatchedSkuCount,BigDecimal knownMatchedPhysicalQuantity,long unassessableSkuCount,
BigDecimal unassessableKnownPhysicalQuantity,List<String> reasonCodes){}
public record OwnerOverview(Context context,String customerCode,String customerName,Long skuCount,QuantityTotal physicalSkuCount,
QuantityTotal physicalQuantity,QuantityTotal availableQuantity,QuantityTotal frozenQuantity,QuantityTotal defectivePhysicalQuantity,
QuantityTotal transitQuantity,QuantityTotal outboundQuantity30d,QuantityTotal averageDailyOutboundQuantity,
RiskShare noOutbound30d,RiskShare coverageAboveThreshold){}
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Bound parameters only, generated by the validated service context. */
public record AnalyticsSqlRequest(String account,String warehouse,String customer,String sku,long salesRun,long inventoryRun,
LocalDate from,LocalDate until,LocalDate anchor,LocalDate windowFrom,LocalDate completeFrom,LocalDate completeUntil,
LocalDateTime coveredFrom,LocalDateTime coveredUntil,boolean salesKnown,boolean formalSales,boolean completeWindow,
boolean complete30,boolean stockFresh,long offset,int size,String kind) {}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDate;
import java.time.Instant;
import org.springframework.format.annotation.DateTimeFormat;
/** Single canonical constructor supports Spring ModelAttribute binding. */
public record CollectionQuery(String sourceAccount, String warehouseCode, String dataFamily,
String taskType, String status, String taskNo,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateUntil,
Integer current, Integer size,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant listUpperCreatedAt,
Long listUpperId) {}
@@ -0,0 +1,350 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*;
@Service
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public class CollectionQueryService {
private final CollectionReadMapper mapper;
private final SelfopAnalyticsProperties config;
private final ObjectMapper json;
private final Clock clock;
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final Set<String> TERMINAL = Set.of("ROUTED","PUBLISHED","REUSED","YIELDED","FAILED","ABORTED","SAMPLE_DONE");
private static final Set<String> PROGRESS_FIELDS = Set.of("progressScope","countBasis","attemptNo","scanRound","phase",
"completed","total","totalKnown","unit","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt",
"validationState","invalidCount","unknownCount","pendingReviewCount","countUnit","summaryAsOf","directoryComplete");
private static final Set<String> COUNTS = Set.of("completed","total","invalidCount","unknownCount","pendingReviewCount","attemptNo","scanRound");
private static final Set<String> UNITS = Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","SKU","BATCH","NONE");
@Autowired
public CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json) {
this(mapper, config, json, Clock.systemUTC());
}
CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json, Clock clock) {
this.mapper=mapper; this.config=config; this.json=json; this.clock=clock;
}
private record Context(Map<Long,Task> tasks,Map<Long,Attempt> latest,Map<Long,Attempt> published) {}
private Context context(String account,List<Task> base,List<Long> extraPublished) {
Map<Long,Task> tasks=new HashMap<>();for(Task t:base)tasks.put(t.id(),t);
Set<Long> related=new HashSet<>();
for(Task t:base){if(t.validationRootTaskId()!=null)related.add(t.validationRootTaskId());if(t.upstreamTaskId()!=null)related.add(t.upstreamTaskId());}
related.removeAll(tasks.keySet());
if(!related.isEmpty())for(Task t:mapper.tasksByIds(account,new ArrayList<>(related)))tasks.put(t.id(),t);
Map<Long,Attempt> latest=new HashMap<>();
if(!tasks.isEmpty())for(Attempt a:mapper.latestAttempts(account,new ArrayList<>(tasks.keySet())))latest.put(a.taskId(),a);
Set<Long> versions=new HashSet<>(extraPublished);versions.remove(null);
for(Task t:tasks.values())if(t.resultPublishedRunId()!=null)versions.add(t.resultPublishedRunId());
Map<Long,Attempt> published=new HashMap<>();
if(!versions.isEmpty())for(Attempt a:mapper.publishedRuns(account,new ArrayList<>(versions)))published.put(a.id(),a);
return new Context(tasks,latest,published);
}
public CollectionResponses.Overview overview(CollectionQuery query) {
validate(query, false);
List<Scope> scopes=mapper.scopes(query.sourceAccount(),query.warehouseCode());
Map<String,Scope> persisted=new HashMap<>();
for(Scope scope:scopes) persisted.put(scope.warehouseCode()+":"+scope.dataFamily(),scope);
var rows=new ArrayList<CollectionResponses.Warehouse>();
var protection=protection(query.sourceAccount());
Map<String,CollectionResponses.InventoryDailySamples> inventorySamples=inventorySamples(query);
List<Long> scopeIds=scopes.stream().filter(s->config.getWarehouses().containsKey(s.warehouseCode())).map(Scope::id).toList();
List<Task> currentTasks=scopeIds.isEmpty()?List.of():mapper.currentTasks(query.sourceAccount(),scopeIds);
List<Task> successes=scopeIds.isEmpty()?List.of():mapper.lastSuccessfulTasks(query.sourceAccount(),scopeIds);
Task active=mapper.sharedActiveAccount(query.sourceAccount());
List<Task> all=new ArrayList<>(currentTasks);all.addAll(successes);if(active!=null)all.add(active);
Context context=context(query.sourceAccount(),all,scopes.stream().map(Scope::publishedRunId).filter(Objects::nonNull).toList());
Set<String> barriers=config.getWarehouses().isEmpty()?Set.of():new HashSet<>(mapper.barrierWarehouses(query.sourceAccount(),new ArrayList<>(config.getWarehouses().keySet())));
for(String warehouse:new TreeSet<>(config.getWarehouses().keySet())) {
if(query.warehouseCode()!=null&&!warehouse.equals(query.warehouseCode())) continue;
for(String family:List.of("OUTBOUND","INVENTORY")) {
if(query.dataFamily()!=null&&!family.equals(query.dataFamily()))continue;
Scope scope=persisted.get(warehouse+":"+family);
Task current=currentTasks.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null);
Task last=successes.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null);
Attempt published=scope==null?null:context.published().get(scope.publishedRunId());
if(published!=null&&(!warehouse.equals(published.warehouseCode())||!Objects.equals(scope.id(),published.scopeId())
||!Objects.equals(scope.scopeHash(),published.scopeHash())))published=null;
Task shared="OUTBOUND".equals(family)&&sharedWindowRelated(active,scope)?active:null;
String blocked=executionBlock(warehouse,protection);
boolean enabled=blocked==null;
boolean publication="BUSINESS".equals(config.getCollectionMode())&&"production".equals(config.getEnvironment())
&&("INVENTORY".equals(family)||config.isSalesPublicationEnabled());
String initial="INVENTORY".equals(family)?published!=null&&"COMPLETE".equals(published.coverageStatus())?"COMPLETE":"UNKNOWN"
:scope==null||scope.initialFromUtc()==null||scope.initialUntilUtc()==null?"UNKNOWN"
:published!=null&&"COMPLETE".equals(published.coverageStatus())&&published.coveredFromUtc()!=null&&published.coveredUntilUtc()!=null
&&!published.coveredFromUtc().isAfter(scope.initialFromUtc())&&!published.coveredUntilUtc().isBefore(scope.initialUntilUtc())
&&!Integer.valueOf(1).equals(scope.rebuildRequired())&&!barriers.contains(warehouse)?"COMPLETE":"PARTIAL";
Instant observation=published==null?null:utc("INVENTORY".equals(family)?published.fetchFinishedAtUtc():published.coveredUntilUtc());
String freshness=published==null?"NOT_COLLECTED":scope.staleAfterSeconds()==null||scope.staleAfterSeconds()<=0?"UNKNOWN"
:observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH";
rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked,
enabled?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()),
scope==null?null:utc(scope.initialUntilUtc()),initial,published==null?null:utc(published.coveredFromUtc()),
published==null?null:utc(published.coveredUntilUtc()),last==null?null:last.taskNo(),last==null||context.latest().get(last.id())==null?null:utc(context.latest().get(last.id()).finishedAtUtc()),
published==null?null:utc(published.publishedAtUtc()),"INVENTORY".equals(family)&&published!=null?utc(published.fetchStartedAtUtc()):null,
"INVENTORY".equals(family)&&published!=null?utc(published.fetchFinishedAtUtc()):null,freshness,
"OUTBOUND".equals(family)&&scope!=null&&(Integer.valueOf(1).equals(scope.rebuildRequired())||barriers.contains(warehouse)),reviewStatus(scope),
current==null?null:taskView(current,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection,
"INVENTORY".equals(family)?inventorySamples.get(warehouse):null));
}
}
return new CollectionResponses.Overview(query.sourceAccount(),config.getEnvironment(),config.getCollectionMode(),clock.instant(),rows);
}
private Map<String,CollectionResponses.InventoryDailySamples> inventorySamples(CollectionQuery query) {
if("OUTBOUND".equals(query.dataFamily()))return Map.of();
List<String> warehouses=config.getWarehouses().keySet().stream()
.filter(w->query.warehouseCode()==null||w.equals(query.warehouseCode())).sorted().toList();
if(warehouses.isEmpty())return Map.of();
LocalDate until=LocalDate.now(clock.withZone(BUSINESS_ZONE)),from=until.minusDays(30);
Map<String,Set<LocalDate>> dates=new HashMap<>();
for(InventorySample sample:mapper.inventoryDailySamples(query.sourceAccount(),warehouses,from,until)) {
if(sample.sampleDate()!=null&&!sample.sampleDate().isBefore(from)&&sample.sampleDate().isBefore(until))
dates.computeIfAbsent(sample.warehouseCode(),k->new HashSet<>()).add(sample.sampleDate());
}
Map<String,LocalDate> starts=new HashMap<>();
for(InventorySample sample:mapper.inventoryAccumulationStarts(query.sourceAccount(),warehouses))
if(sample.sampleDate()!=null)starts.put(sample.warehouseCode(),sample.sampleDate());
Map<String,CollectionResponses.InventoryDailySamples> result=new HashMap<>();
for(String warehouse:warehouses) {
Set<LocalDate> observed=dates.getOrDefault(warehouse,Set.of());
List<LocalDate> missing=from.datesUntil(until).filter(d->!observed.contains(d)).toList();
result.put(warehouse,new CollectionResponses.InventoryDailySamples(from,until,30,30-missing.size(),missing,starts.get(warehouse)));
}
return result;
}
public CollectionResponses.Page<CollectionResponses.Task> tasks(CollectionQuery query) {
validate(query,true);
var f=filter(query); Bound bound=bound(query,false,f);
if(bound==null)return empty(query);
var scopes=scopeMap(query); var protection=protection(query.sourceAccount());
List<Task> tasks=mapper.tasks(f,bound);Context context=context(query.sourceAccount(),tasks,List.of());
var rows=tasks.stream().map(t->taskView(t,scopes.get(t.dataFamily()),protection,context)).toList();
return new CollectionResponses.Page<>(current(query),size(query),mapper.taskCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows);
}
public CollectionResponses.TaskDetail taskDetail(String taskNo,CollectionQuery query) {
validate(query,true);
if(taskNo==null||taskNo.isBlank()||taskNo.length()>64)throw new BizException("任务号无效");
Task task=mapper.task(query.sourceAccount(),query.warehouseCode(),taskNo);
if(task==null)throw new BizException("任务不存在或不属于所选仓库");
Scope scope=scopeMap(query).get(task.dataFamily());
if(scope==null||(query.dataFamily()!=null&&!query.dataFamily().equals(task.dataFamily())))throw new BizException("任务范围与所选仓库不一致");
Context context=context(query.sourceAccount(),List.of(task),List.of());
Task shared=relatedUpstream(task,context);
var protection=protection(query.sourceAccount());
return new CollectionResponses.TaskDetail(taskView(task,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection,clock.instant());
}
public CollectionResponses.Page<CollectionResponses.Attempt> runs(CollectionQuery query) {
validate(query,true);
var f=filter(query); Bound bound=bound(query,true,f);
if(bound==null)return empty(query);
List<Attempt> runs=mapper.runs(f,bound);
Map<Long,Task> tasks=new HashMap<>();List<Long> taskIds=runs.stream().map(Attempt::taskId).distinct().toList();
if(!taskIds.isEmpty())for(Task t:mapper.tasksByIds(query.sourceAccount(),taskIds))tasks.put(t.id(),t);
Map<String,Scope> scopes=scopeMap(query);var protection=protection(query.sourceAccount());
Context context=context(query.sourceAccount(),new ArrayList<>(tasks.values()),List.of());
var rows=runs.stream().map(r->{Task t=tasks.get(r.taskId());return attemptView(r,t,
t!=null&&retryBlock(t,scopes.get(t.dataFamily()),protection,context)==null);}).toList();
return new CollectionResponses.Page<>(current(query),size(query),mapper.runCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows);
}
private <T> CollectionResponses.Page<T> empty(CollectionQuery q) {
// A zero id freezes an empty first page; later inserts cannot enter it.
return new CollectionResponses.Page<>(current(q),size(q),0,clock.instant(),0L,clock.instant(),List.of());
}
private Map<String,Scope> scopeMap(CollectionQuery q) {
Map<String,Scope> scopes=new HashMap<>();
for(Scope scope:mapper.scopes(q.sourceAccount(),q.warehouseCode()))scopes.put(scope.dataFamily(),scope);
return scopes;
}
private boolean sharedWindowRelated(Task active,Scope scope) {
if(active==null||scope==null||active.queryFromUtc()==null||active.queryUntilUtc()==null)return false;
return overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.initialFromUtc(),scope.initialUntilUtc())
||overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.discoveryFromUtc(),scope.discoveryUntilUtc());
}
private static boolean overlaps(LocalDateTime from,LocalDateTime until,LocalDateTime otherFrom,LocalDateTime otherUntil) {
return otherFrom!=null&&otherUntil!=null&&from.isBefore(otherUntil)&&until.isAfter(otherFrom);
}
private String reviewStatus(Scope scope) {
if(scope==null||scope.reviewTargetDays()==null||scope.reviewTargetDays()<1)return "UNKNOWN";
if(scope.knownOrderRoundFinishedAtUtc()==null||scope.historyRoundFinishedAtUtc()==null)return "PENDING";
Instant deadline=clock.instant().minusSeconds(scope.reviewTargetDays()*86400L);
return utc(scope.knownOrderRoundFinishedAtUtc()).isBefore(deadline)||utc(scope.historyRoundFinishedAtUtc()).isBefore(deadline)?"OVERDUE":"REVIEWED";
}
private Task relatedUpstream(Task task,Context context) {
if(task.upstreamTaskId()==null)return null;
Task parent=context.tasks().get(task.upstreamTaskId());
return parent!=null&&"ACCOUNT_DISCOVERY".equals(parent.taskRole())&&Objects.equals(task.sourceAccount(),parent.sourceAccount())?parent:null;
}
private CollectionResponses.Task taskView(Task t,Scope scope,CollectionResponses.Protection protection,Context context) {
Attempt latest=context.latest().get(t.id());
String blocked=retryBlock(t,scope,protection,context);
Task root=t.validationRootTaskId()==null?null:context.tasks().get(t.validationRootTaskId());
Attempt result=context.published().get(t.resultPublishedRunId());
String resultState=t.resultPublishedRunId()==null?"NOT_PUBLISHED":result==null||scope==null||!Objects.equals(scope.id(),result.scopeId())
||!Objects.equals(t.warehouseCode(),result.warehouseCode())||!Objects.equals(t.scopeHash(),result.scopeHash())?"RESULT_UNAVAILABLE"
:latest!=null&&"REUSED".equals(latest.status())?"REUSED":latest!=null&&"PUBLISHED".equals(latest.status())?"PUBLISHED":"RESULT_AVAILABLE";
return new CollectionResponses.Task(t.taskNo(),t.taskType(),t.dataFamily(),t.warehouseCode(),t.taskRole(),t.executionPurpose(),
utc(t.queryFromUtc()),utc(t.queryUntilUtc()),t.status(),t.applyState(),t.attemptCount(),latest==null?null:latest.attemptNo(),
resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()),
blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()),
validation(root==null?t:root),latest==null?null:attemptView(latest,t,blocked==null));
}
private String retryBlock(Task task,Scope scope,CollectionResponses.Protection protection,Context context) {
if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK";
if(!"BUSINESS".equals(task.executionPurpose()))return "VALIDATION_NOT_RETRYABLE";
String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled;
if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS";
if(!Set.of("DEAD","RETRY_WAIT").contains(task.status()))return "TASK_NOT_RETRYABLE";
if(scope==null||!Objects.equals(scope.scopeHash(),task.scopeHash()))return "SCOPE_CHANGED";
if(scope.leaseOwner()!=null&&scope.leaseUntilUtc()!=null&&clock.instant().isBefore(utc(scope.leaseUntilUtc())))return "SCOPE_LEASE_BUSY";
Task parent=relatedUpstream(task,context);
if(task.upstreamTaskId()!=null&&(parent==null||!"SUCCEEDED".equals(parent.status())))return "UPSTREAM_NOT_READY";
return null;
}
private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) {
boolean terminal=TERMINAL.contains(run.status());
boolean same=task!=null&&Objects.equals(run.attemptNo(),task.attemptCount());
Map<String,Object> progress=progress(terminal?run.progressSnapshotJson():same?task.progressJson():null);
Instant updated=terminal?progressInstant(progress,"summaryAsOf"):same?utc(task.progressUpdatedAtUtc()):null;
Instant started=utc(run.fetchStartedAtUtc()),finished=utc(run.finishedAtUtc()),heartbeat=utc(run.heartbeatAtUtc());
String health=terminal?"TERMINATED":heartbeat==null||!clock.instant().isBefore(heartbeat.plusSeconds(config.getLeaseSeconds()))?"UNCONFIRMED":"HEALTHY";
Long elapsed=started==null?null:Math.max(0,Duration.between(started,finished==null?clock.instant():finished).getSeconds());
return new CollectionResponses.Attempt(run.id(),run.taskNo(),run.attemptNo(),run.status(),run.lastPhase(),run.executionPurpose(),
started,finished,progressInstant(progress,"stageStartedAt"),updated,heartbeat,health,elapsed,run.errorCode(),safeMessage(run.errorMessage()),
retryAllowed&&same&&"BUSINESS".equals(run.executionPurpose())&&Set.of("FAILED","ABORTED","YIELDED").contains(run.status()),
run.coverageStatus(),utc(run.coveredFromUtc()),utc(run.coveredUntilUtc()),utc(run.publishedAtUtc()),progress);
}
private String safeMessage(String error) {
if(error==null)return null;
// Errors are already sanitized by the write side; avoid exposing arbitrary payloads here.
return "采集尝试失败,请根据错误码查看受控诊断记录";
}
private Instant progressInstant(Map<String,Object> progress,String field) {
Object value=progress.get(field);
try{return value instanceof String text?Instant.parse(text):null;}catch(DateTimeException error){return null;}
}
private CollectionResponses.Protection protection(String account) {
var p=mapper.protection(account);
JsonNode budget=p==null?null:boundedObject(p.apiBudgetJson());
return new CollectionResponses.Protection(p==null?null:utc(p.apiCooldownUntilUtc()),p==null?null:p.apiPauseReason(),
config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"),
budget==null||!budget.path("budgetDate").isTextual()?null:budget.path("budgetDate").textValue());
}
private String executionBlock(String warehouse,CollectionResponses.Protection protection) {
if("OFF".equals(config.getCollectionMode()))return "COLLECTION_OFF";
if(!Set.of("BUSINESS","VALIDATION").contains(config.getCollectionMode()))return "COLLECTION_MODE_INVALID";
if("BUSINESS".equals(config.getCollectionMode())&&!"production".equals(config.getEnvironment()))return "BUSINESS_ENVIRONMENT_REJECTED";
if(!config.isExecutionEnabled())return "EXECUTION_DISABLED";
if(!config.getWarehouses().containsKey(warehouse))return "WAREHOUSE_NOT_CONFIGURED";
var w=config.getWarehouses().get(warehouse);
if(!"Asia/Shanghai".equals(w.getBusinessTimezone())||!"Asia/Shanghai".equals(w.getSourceTimezone())||!"PCS".equals(config.getQuantityUnit()))return "TIMEZONE_OR_UNIT_NOT_CONFIRMED";
if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED";
if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN";
if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified()
||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID";
if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED";
if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate())
&&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED";
return null;
}
private Instant nextSchedule(String warehouse,String family) {
var w=config.getWarehouses().get(warehouse);
try {var next=CronExpression.parse("OUTBOUND".equals(family)?w.getOutboundCron():w.getInventoryCron()).next(clock.instant().atZone(BUSINESS_ZONE));return next==null?null:next.toInstant();}
catch(IllegalArgumentException ex){return null;}
}
private CollectionReadMapper.Filter filter(CollectionQuery q) {
long offset=(long)(current(q)-1)*size(q);if(offset>Integer.MAX_VALUE)throw new BizException("分页范围过大");
return new CollectionReadMapper.Filter(q.sourceAccount(),q.warehouseCode(),q.dataFamily(),q.taskType(),q.status(),q.taskNo(),
q.dateFrom()==null?null:LocalDateTime.ofInstant(q.dateFrom().atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC),
q.dateUntil()==null?null:LocalDateTime.ofInstant(q.dateUntil().plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC),
(int)offset,size(q));
}
private Bound bound(CollectionQuery q,boolean run,CollectionReadMapper.Filter f) {
if(q.listUpperId()!=null)return new Bound(LocalDateTime.ofInstant(q.listUpperCreatedAt(),ZoneOffset.UTC),q.listUpperId());
return run?mapper.runBound(f):mapper.taskBound(f);
}
private void validate(CollectionQuery q,boolean warehouseRequired) {
if(q==null||q.sourceAccount()==null||q.sourceAccount().isBlank()||!q.sourceAccount().equals(config.getSourceAccount()))throw new BizException("来源账号未配置或无权访问");
if(warehouseRequired&&(q.warehouseCode()==null||q.warehouseCode().isBlank()))throw new BizException("必须选择仓库");
if(q.warehouseCode()!=null&&!config.getWarehouses().containsKey(q.warehouseCode()))throw new BizException("仓库不在配置范围内");
if(q.dataFamily()!=null&&!Set.of("OUTBOUND","INVENTORY").contains(q.dataFamily()))throw new BizException("任务族无效");
if(current(q)<1||size(q)<1||size(q)>100)throw new BizException("分页参数无效");
if((q.listUpperCreatedAt()==null)!=(q.listUpperId()==null)||q.listUpperId()!=null&&q.listUpperId()<0)throw new BizException("分页快照上界无效");
if(current(q)>1&&q.listUpperId()==null)throw new BizException("翻页必须携带首屏快照上界");
if(q.dateFrom()!=null&&q.dateUntil()!=null&&q.dateUntil().isBefore(q.dateFrom()))throw new BizException("创建日期范围倒置");
if(q.taskNo()!=null&&(q.taskNo().isBlank()||q.taskNo().length()>64))throw new BizException("任务号无效");
}
private static int current(CollectionQuery q){return q.current()==null?1:q.current();}
private static int size(CollectionQuery q){return q.size()==null?20:q.size();}
private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);}
/** Unknown/invalid quantities stay null; arbitrary raw payload fields never pass through. */
Map<String,Object> progress(String source) {
JsonNode root=boundedObject(source);if(root==null)return Map.of();
Map<String,Object> result=projectProgress(root);
if(root.path("stages").isArray()) {
List<Map<String,Object>> stages=new ArrayList<>();
if(root.path("stages").size()>16)return Map.of();
root.path("stages").forEach(stage->{if(stage.isObject())stages.add(projectProgress(stage));});
result.put("stages",stages);
}
return Collections.unmodifiableMap(result);
}
private Map<String,Object> projectProgress(JsonNode node) {
Map<String,Object> result=new LinkedHashMap<>();
for(String field:PROGRESS_FIELDS) {
JsonNode value=node.get(field);if(value==null)continue;
if(COUNTS.contains(field))result.put(field,value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null);
else if(Set.of("totalKnown","directoryComplete").contains(field))result.put(field,value.isBoolean()?value.booleanValue():null);
else if(value.isTextual()&&value.textValue().length()<=128)result.put(field,value.textValue());
}
if(result.containsKey("unit")&&!UNITS.contains(result.get("unit")))result.put("unit",null);
if(result.containsKey("countUnit")&&!UNITS.contains(result.get("countUnit")))result.put("countUnit",null);
if(result.containsKey("countBasis")&&!Set.of("TASK_CUMULATIVE","ATTEMPT_ONLY").contains(result.get("countBasis")))result.put("countBasis",null);
if(result.containsKey("progressScope")&&!Set.of("ACCOUNT_SHARED","WAREHOUSE").contains(result.get("progressScope")))result.put("progressScope",null);
if(!Boolean.TRUE.equals(result.get("totalKnown")))result.put("total",null);
if("NONE".equals(result.get("unit"))) {
result.put("completed",null);result.put("total",null);result.put("totalKnown",false);
}
return result;
}
private JsonNode boundedObject(String value) {
if(value==null||value.getBytes(StandardCharsets.UTF_8).length>8192)return null;
try {JsonNode node=json.readTree(value);return node!=null&&node.isObject()?node:null;}catch(Exception ex){return null;}
}
private CollectionResponses.Validation validation(Task root) {
if(!"VALIDATION".equals(root.executionPurpose()))return null;
JsonNode budget=boundedObject(root.validationBudgetJson());
Map<String,Object> limits=new LinkedHashMap<>(),consumed=new LinkedHashMap<>();
for(String key:List.of("maxPages","maxPageSize","maxDistinctDetails","maxDetailHttp","maxInventoryPagesPerWarehouse","maxHttp"))limits.put(key,number(budget,key));
for(String key:List.of("pages","detailHttp","http","confirmedHttp"))consumed.put(key,number(budget,key));
consumed.put("distinctDetails",budget==null||!budget.path("detailKeys").isArray()?null:(long)budget.path("detailKeys").size());
// detailKeys themselves contain upstream identities and are never exposed.
return new CollectionResponses.Validation(root.taskNo(),Collections.unmodifiableMap(limits),Collections.unmodifiableMap(consumed),
budgetInstant(budget,"deadlineAtUtc"),budget==null?null:fixedReason(budget.path("stopReason")));
}
private String fixedReason(JsonNode value) {
if(value==null||value.isNull()||!value.isTextual())return null;
return value.textValue().matches("[A-Z0-9_]{1,64}")?value.textValue():"STOP_REASON_UNAVAILABLE";
}
private Long number(JsonNode root,String field) {
JsonNode value=root==null?null:root.get(field);
return value!=null&&value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null;
}
private Instant budgetInstant(JsonNode root,String field) {
try{return root==null||!root.path(field).isTextual()?null:utc(LocalDateTime.parse(root.path(field).textValue()));}catch(DateTimeException ex){return null;}
}
}
@@ -0,0 +1,115 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.List;
import org.apache.ibatis.annotations.*;
import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*;
@Mapper
public interface CollectionReadMapper {
String DAILY_SAMPLE_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_scope s ON s.id=r.scope_id"
+ " AND s.source_account=r.source_account AND s.warehouse_code=r.warehouse_code AND s.data_family='INVENTORY'"
+ " WHERE r.source_account=#{account} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS'"
+ " AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE'"
+ " AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1'"
+ " AND r.warehouse_code IN <foreach collection='warehouses' item='w' open='(' close=')' separator=','>#{w}</foreach>";
@Select("<script>SELECT r.warehouse_code,r.inventory_daily_sample_date AS sample_date" + DAILY_SAMPLE_FROM
+ " AND r.inventory_daily_sample_date &gt;= #{from} AND r.inventory_daily_sample_date &lt; #{until}"
+ " ORDER BY r.warehouse_code,r.inventory_daily_sample_date</script>")
List<InventorySample> inventoryDailySamples(@Param("account") String account,
@Param("warehouses") List<String> warehouses, @Param("from") LocalDate from, @Param("until") LocalDate until);
@Select("<script>SELECT r.warehouse_code,MIN(r.inventory_daily_sample_date) AS sample_date" + DAILY_SAMPLE_FROM
+ " AND r.inventory_daily_sample_date IS NOT NULL GROUP BY r.warehouse_code</script>")
List<InventorySample> inventoryAccumulationStarts(@Param("account") String account,
@Param("warehouses") List<String> warehouses);
record Filter(String account, String warehouse, String family, String type, String status,
String taskNo, LocalDateTime from, LocalDateTime until, int offset, int size) {}
String SCOPE = "id,warehouse_code,data_family,scope_hash,business_timezone,schedule_cron,stale_after_seconds,"
+ "initial_from_utc,initial_until_utc,discovery_from_utc,discovery_until_utc,rebuild_required,published_run_id,"
+ "known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days,lease_owner,lease_until_utc";
String TASK = "t.id,t.task_no,t.source_account,t.warehouse_code,s.data_family,t.task_role,t.task_type,"
+ "t.execution_purpose,t.scope_hash,t.status,t.apply_state,t.attempt_count,t.query_from_utc,t.query_until_utc,"
+ "t.next_retry_at_utc,t.upstream_task_id,t.validation_root_task_id,t.result_published_run_id,"
+ "t.progress_json,t.progress_updated_at_utc,t.validation_budget_json,t.created_at,t.updated_at";
String ATTEMPT = "r.id,r.task_id,t.task_no,r.attempt_no,r.status,r.last_phase,r.execution_purpose,"
+ "r.fetch_started_at_utc,r.fetch_finished_at_utc,r.build_started_at_utc,r.finished_at_utc,"
+ "r.published_at_utc,r.heartbeat_at_utc,r.progress_snapshot_json,r.coverage_status,"
+ "r.covered_from_utc,r.covered_until_utc,r.error_code,r.error_message,r.created_at,r.warehouse_code,r.scope_id,r.scope_hash";
String TASK_FROM = " FROM ll_selfop_sync_task t JOIN ll_selfop_sync_scope s ON s.id=t.scope_id "
+ "AND s.source_account=t.source_account AND s.warehouse_code=t.warehouse_code ";
String RUN_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id "
+ "AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code "
+ "JOIN ll_selfop_sync_scope s ON s.id=r.scope_id AND s.id=t.scope_id ";
String FILTER = " WHERE t.source_account=#{f.account} AND t.warehouse_code=#{f.warehouse} "
+ "AND t.task_role='WAREHOUSE_SYNC' "
+ "<if test='f.family != null'>AND s.data_family=#{f.family} </if>"
+ "<if test='f.type != null'>AND t.task_type=#{f.type} </if>"
+ "<if test='f.taskNo != null'>AND t.task_no=#{f.taskNo} </if>";
String TASK_FILTER = FILTER + "<if test='f.status != null'>AND t.status=#{f.status} </if>"
+ "<if test='f.from != null'>AND t.created_at &gt;= #{f.from} </if>"
+ "<if test='f.until != null'>AND t.created_at &lt; #{f.until} </if>";
String RUN_FILTER = FILTER + "<if test='f.status != null'>AND r.status=#{f.status} </if>"
+ "<if test='f.from != null'>AND r.created_at &gt;= #{f.from} </if>"
+ "<if test='f.until != null'>AND r.created_at &lt; #{f.until} </if>";
String TASK_BOUND = " AND (t.created_at &lt; #{b.createdAt} OR (t.created_at=#{b.createdAt} AND t.id &lt;= #{b.id})) ";
String RUN_BOUND = " AND (r.created_at &lt; #{b.createdAt} OR (r.created_at=#{b.createdAt} AND r.id &lt;= #{b.id})) ";
@Select("<script>SELECT " + SCOPE + " FROM ll_selfop_sync_scope WHERE source_account=#{account}"
+ "<if test='warehouse != null'> AND warehouse_code=#{warehouse}</if> ORDER BY warehouse_code,data_family</script>")
List<Scope> scopes(@Param("account") String account, @Param("warehouse") String warehouse);
@Select("<script>SELECT t.created_at,t.id" + TASK_FROM + TASK_FILTER + " ORDER BY t.created_at DESC,t.id DESC LIMIT 1</script>")
Bound taskBound(@Param("f") Filter filter);
@Select("<script>SELECT r.created_at,r.id" + RUN_FROM + RUN_FILTER + " ORDER BY r.created_at DESC,r.id DESC LIMIT 1</script>")
Bound runBound(@Param("f") Filter filter);
@Select("<script>SELECT COUNT(*)" + TASK_FROM + TASK_FILTER + TASK_BOUND + "</script>")
long taskCount(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("<script>SELECT " + TASK + TASK_FROM + TASK_FILTER + TASK_BOUND
+ " ORDER BY t.created_at DESC,t.id DESC LIMIT #{f.size} OFFSET #{f.offset}</script>")
List<Task> tasks(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("<script>SELECT COUNT(*)" + RUN_FROM + RUN_FILTER + RUN_BOUND + "</script>")
long runCount(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("<script>SELECT " + ATTEMPT + RUN_FROM + RUN_FILTER + RUN_BOUND
+ " ORDER BY r.created_at DESC,r.id DESC LIMIT #{f.size} OFFSET #{f.offset}</script>")
List<Attempt> runs(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("SELECT " + TASK + TASK_FROM + " WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} AND t.task_no=#{taskNo}")
Task task(@Param("account") String account, @Param("warehouse") String warehouse, @Param("taskNo") String taskNo);
@Select("SELECT api_cooldown_until_utc,api_pause_reason,api_budget_json FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} LIMIT 1")
Protection protection(@Param("account") String account);
@Select("<script>SELECT " + TASK + " FROM ll_selfop_sync_task t LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id"
+ " WHERE t.source_account=#{account} AND t.id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach></script>")
List<Task> tasksByIds(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + ATTEMPT + " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id"
+ " JOIN (SELECT id,ROW_NUMBER() OVER(PARTITION BY task_id ORDER BY attempt_no DESC,id DESC) rn FROM ll_selfop_sync_run"
+ " WHERE task_id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach>) ranked"
+ " ON ranked.id=r.id AND ranked.rn=1 WHERE r.source_account=#{account}</script>")
List<Attempt> latestAttempts(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + ATTEMPT + " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id"
+ " WHERE r.source_account=#{account} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC'"
+ " AND r.id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach></script>")
List<Attempt> publishedRuns(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + TASK + TASK_FROM
+ " JOIN (SELECT id,ROW_NUMBER() OVER(PARTITION BY scope_id ORDER BY created_at DESC,id DESC) rn FROM ll_selfop_sync_task"
+ " WHERE scope_id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach>) ranked"
+ " ON ranked.id=t.id AND ranked.rn=1 WHERE t.source_account=#{account}</script>")
List<Task> currentTasks(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + TASK + TASK_FROM
+ " JOIN (SELECT id,ROW_NUMBER() OVER(PARTITION BY scope_id ORDER BY updated_at DESC,id DESC) rn FROM ll_selfop_sync_task"
+ " WHERE status='SUCCEEDED' AND execution_purpose='BUSINESS' AND result_published_run_id IS NOT NULL AND scope_id IN"
+ " <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach>) ranked"
+ " ON ranked.id=t.id AND ranked.rn=1 WHERE t.source_account=#{account}</script>")
List<Task> lastSuccessfulTasks(@Param("account") String account,@Param("ids") List<Long> ids);
// The one account active task is separately related to warehouses by actual persisted windows.
@Select("SELECT " + TASK + " FROM ll_selfop_outbound_discovery_scope d JOIN ll_selfop_sync_task t ON t.id=d.active_task_id"
+ " AND t.source_account=d.source_account LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id"
+ " WHERE d.source_account=#{account} AND t.task_role='ACCOUNT_DISCOVERY' LIMIT 1")
Task sharedActiveAccount(@Param("account") String account);
@Select("<script>SELECT DISTINCT s.warehouse_code FROM ll_selfop_sync_scope s WHERE s.source_account=#{account}"
+ " AND s.data_family='OUTBOUND' AND s.warehouse_code IN"
+ " <foreach collection='warehouses' item='w' open='(' close=')' separator=','>#{w}</foreach>"
+ " AND EXISTS (SELECT 1 FROM ll_selfop_sync_task t WHERE t.source_account=s.source_account"
+ " AND t.execution_purpose='BUSINESS' AND t.apply_state IN ('APPLYING','BLOCKED')"
+ " AND (t.warehouse_code=s.warehouse_code OR JSON_CONTAINS(t.affected_warehouse_codes_json,JSON_QUOTE(s.warehouse_code))))</script>")
List<String> barrierWarehouses(@Param("account") String account,@Param("warehouses") List<String> warehouses);
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDateTime;
import java.time.LocalDate;
/** Narrow read projections: no raw manifests, stage payloads or business detail tables. */
public final class CollectionReadRows {
private CollectionReadRows() {}
public record Bound(LocalDateTime createdAt, Long id) {}
public record InventorySample(String warehouseCode, LocalDate sampleDate) {}
public record Scope(Long id, String warehouseCode, String dataFamily, String scopeHash,
String businessTimezone, String scheduleCron, Integer staleAfterSeconds,
LocalDateTime initialFromUtc, LocalDateTime initialUntilUtc,
LocalDateTime discoveryFromUtc, LocalDateTime discoveryUntilUtc,
Integer rebuildRequired, Long publishedRunId,
LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc, Integer reviewTargetDays,
String leaseOwner, LocalDateTime leaseUntilUtc) {}
public record Task(Long id, String taskNo, String sourceAccount, String warehouseCode,
String dataFamily, String taskRole, String taskType, String executionPurpose,
String scopeHash, String status, String applyState, Integer attemptCount,
LocalDateTime queryFromUtc, LocalDateTime queryUntilUtc, LocalDateTime nextRetryAtUtc,
Long upstreamTaskId, Long validationRootTaskId, Long resultPublishedRunId,
String progressJson, LocalDateTime progressUpdatedAtUtc, String validationBudgetJson,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record Attempt(Long id, Long taskId, String taskNo, Integer attemptNo,
String status, String lastPhase, String executionPurpose,
LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc,
LocalDateTime buildStartedAtUtc, LocalDateTime finishedAtUtc,
LocalDateTime publishedAtUtc, LocalDateTime heartbeatAtUtc,
String progressSnapshotJson, String coverageStatus,
LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc,
String errorCode, String errorMessage, LocalDateTime createdAt, String warehouseCode, Long scopeId, String scopeHash) {}
public record Protection(LocalDateTime apiCooldownUntilUtc, String apiPauseReason, String apiBudgetJson) {}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public final class CollectionResponses {
private CollectionResponses() {}
public record Page<T>(int current, int size, long total, Instant listUpperCreatedAt,
Long listUpperId, Instant observedAt, List<T> records) {}
public record Overview(String sourceAccount, String environmentLabel, String collectionMode,
Instant observedAt, List<Warehouse> warehouses) {}
public record Warehouse(String warehouseCode, String warehouseName, String dataFamily,
boolean executionEnabled, boolean publicationEnabled, String disabledReason,
Instant nextScheduledAt, Instant observedAt, Instant initialFrom, Instant initialUntil,
String initialCoverageStatus, Instant salesCoveredFrom, Instant salesCoveredUntil,
String lastSuccessfulTaskNo, Instant lastSucceededAt, Instant lastPublishedAt,
Instant inventoryObservedFrom, Instant inventoryObservedUntil, String dataFreshness,
boolean rebuildRequired, String revisionReviewStatus, Task currentTask,
Task sharedUpstream, Protection protection, InventoryDailySamples inventoryDailySamples) {}
public record InventoryDailySamples(LocalDate windowFrom, LocalDate windowUntil, int expectedDays,
int sampleCount, List<LocalDate> missingDates, LocalDate accumulationStartedOn) {}
public record Task(String taskNo, String taskType, String dataFamily, String warehouseCode,
String taskRole, String executionPurpose, Instant queryFrom, Instant queryUntil,
String taskStatus, String applyState, Integer attemptCount, Integer latestAttemptNo,
String resultPublicationState, Long resultPublishedRunId, Instant nextRetryAt,
List<String> allowedActions, String actionBlockReason, Instant progressUpdatedAt,
Map<String,Object> progress, Validation validation, Attempt latestAttempt) {}
public record TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {}
public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase,
String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt,
Instant progressUpdatedAt, Instant heartbeatAt, String executionHealth, Long elapsedSeconds,
String errorCode, String failureSummary, boolean retryable, String coverageStatus,
Instant coveredFrom, Instant coveredUntil, Instant publishedAt, Map<String,Object> progress) {}
public record Protection(Instant accountCooldownUntil, String accountPauseReason,
Integer dailyRequestBudget, Long reservedHttpRequests, Long confirmedHttpRequests, String budgetDate) {}
public record Validation(String validationSessionNo, Map<String,Object> budgetLimits,
Map<String,Object> budgetConsumed, Instant deadlineAt, String stopReason) {}
}

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