feat: add self-operated analytics and enforce Lastleg module boundaries
This commit is contained in:
@@ -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}
|
||||
|
||||
Vendored
+11
@@ -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=
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 进入;与客户物流订单和仓储作业库存分开。
|
||||
|
||||
@@ -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));
|
||||
@@ -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=
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)。
|
||||
+35
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -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)
|
||||
|
||||
+67
-4
@@ -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) { }
|
||||
}
|
||||
|
||||
+31
@@ -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) { }
|
||||
}
|
||||
+32
-2
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
+11
@@ -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; }
|
||||
}
|
||||
+131
@@ -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); }
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+219
-64
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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'}
|
||||
|
||||
+70
-7
@@ -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"))
|
||||
|
||||
+226
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
-224
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+3
-39
@@ -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);
|
||||
}
|
||||
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
+12
-29
@@ -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>();
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+23
@@ -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";
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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
|
||||
|
||||
+9
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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) { }
|
||||
}
|
||||
|
||||
+46
@@ -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);}
|
||||
}
|
||||
+4
-1
@@ -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);
|
||||
|
||||
+14
@@ -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; }
|
||||
}
|
||||
+25
@@ -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) { }
|
||||
}
|
||||
+248
@@ -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); }
|
||||
}
|
||||
+94
@@ -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,"平台自营分析只读权限不足");
|
||||
}
|
||||
}
|
||||
+140
@@ -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; }
|
||||
}
|
||||
+179
@@ -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");
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+16
@@ -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) { }
|
||||
}
|
||||
+68
@@ -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();}
|
||||
}
|
||||
+11
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
+163
@@ -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) <= #{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);
|
||||
}
|
||||
+33
@@ -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) { }
|
||||
}
|
||||
+159
@@ -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());
|
||||
}
|
||||
}
|
||||
+245
@@ -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");
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
+27
@@ -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、关注、排序或分页条件");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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))");}
|
||||
}
|
||||
+40
@@ -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+"必填且不能超长");}
|
||||
}
|
||||
+267
@@ -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));}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
+39
@@ -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){}
|
||||
}
|
||||
+147
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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){}
|
||||
}
|
||||
+10
@@ -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) {}
|
||||
+14
@@ -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) {}
|
||||
+350
@@ -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;}
|
||||
}
|
||||
}
|
||||
+115
@@ -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 >= #{from} AND r.inventory_daily_sample_date < #{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 >= #{f.from} </if>"
|
||||
+ "<if test='f.until != null'>AND t.created_at < #{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 >= #{f.from} </if>"
|
||||
+ "<if test='f.until != null'>AND r.created_at < #{f.until} </if>";
|
||||
String TASK_BOUND = " AND (t.created_at < #{b.createdAt} OR (t.created_at=#{b.createdAt} AND t.id <= #{b.id})) ";
|
||||
String RUN_BOUND = " AND (r.created_at < #{b.createdAt} OR (r.created_at=#{b.createdAt} AND r.id <= #{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);
|
||||
}
|
||||
+34
@@ -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) {}
|
||||
}
|
||||
+41
@@ -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
Reference in New Issue
Block a user