Merge remote-tracking branch 'origin/test' into test

# Conflicts:
#	deploy/sql/README.md
This commit is contained in:
2026-10-15 15:02:34 +08:00
65 changed files with 2866 additions and 144 deletions
+9
View File
@@ -141,6 +141,15 @@ baiyue:
max-rows: 200000 max-rows: 200000
max-attempts: 3 max-attempts: 3
batch-size: 200 batch-size: 200
SELFOP_INVENTORY_TURNOVER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches
required-permission: platform:self-operated-analytics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 100
OVERSEAS_INVENTORY_LEDGER: OVERSEAS_INVENTORY_LEDGER:
provider-service: lastleg-service provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
-1
View File
@@ -30,7 +30,6 @@ SELFOP_SOURCE_ACCOUNT=lingxing-primary
# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。 # 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。
SELFOP_DAILY_REQUEST_BUDGET=0 SELFOP_DAILY_REQUEST_BUDGET=0
SELFOP_MIN_REQUEST_INTERVAL_MS=5000 SELFOP_MIN_REQUEST_INTERVAL_MS=5000
SELFOP_EXECUTION_ENABLED=false
SELFOP_SALES_PUBLICATION_ENABLED=false SELFOP_SALES_PUBLICATION_ENABLED=false
REDIS_PASSWORD= REDIS_PASSWORD=
AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
+5 -1
View File
@@ -30,6 +30,8 @@
客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。 客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。
历史库存还原基线为 `lastleg-service/src/main/resources/db/selfop-inventory-history.sql`,新增流水事实、历史日版本和日库存三表;同步 Lastleg 与海外空库基线。仅执行这三表 CREATE,不重放原始库存或销量表。匹配版本复用 `sync_task/run/stage` 的 `INVENTORY_FLOW_HISTORY`、`INVENTORY_HISTORY_BUILD` 与 `INVENTORY_FLOW/HISTORY_INPUT`,原库存快照不改写。完成三表并部署 Integration、Lastleg、Admin 后,才启用 `baiyue.self-operated-analytics.inventory-history-enabled=true`;默认关闭,历史目标30日优先再60日。保留当前账号出口、1200ms间隔和所有被历史版本引用的证据;现有销量副本淘汰不得删除这些引用。
## 唯一入口与区域边界 ## 唯一入口与区域边界
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
@@ -37,7 +39,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | | 国内 | `头程数据库统一增量-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` | 199 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 202 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -150,4 +152,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。 `migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。 货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
@@ -0,0 +1,11 @@
-- 自营库存周转后端导出权限;不建表,不修改销量、库存或采集任务。
USE baiyue_iam;
SET NAMES utf8mb4;
START TRANSACTION;
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
(5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,5118002,0,CURRENT_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM iam_role_permission WHERE role_id=4000001 AND permission_id=5118002 AND tenant_id=0);
COMMIT;
@@ -8323,10 +8323,12 @@ INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,perm
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'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), (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), (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),
(5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'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); (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 INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)), (4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118002,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)), (4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)),
+86 -1
View File
@@ -4007,7 +4007,7 @@ CREATE TABLE ll_selfop_sync_task (
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
@@ -4386,3 +4386,88 @@ CREATE TABLE ll_selfop_product (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables;
USE baiyue_lastleg;
-- 历史库存基线;执行前必须取得指定环境的建表授权。
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识',
product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知',
cell_no VARCHAR(128) NULL COMMENT '来源库位编码',
inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号',
bill_no VARCHAR(128) NULL COMMENT '来源业务单号',
source_no VARCHAR(128) NULL COMMENT '来源关联单号',
bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原',
book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空',
book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文',
delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量',
close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存',
source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文',
event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空',
business_date DATE NULL COMMENT '北京时间业务日期',
quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空',
content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要',
revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号',
stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据',
first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试',
last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试',
last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻',
validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知',
validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id),
KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc),
KEY idx_selfop_flow_day (source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
id BIGINT NOT NULL COMMENT '不可变历史日版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
business_date DATE NOT NULL COMMENT '北京时间日末所属日期',
scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要',
rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本',
build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试',
publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识',
sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本',
anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本',
input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要',
evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用',
status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败',
head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空',
valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数',
unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数',
created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻',
published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻',
superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻',
invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻',
invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因',
rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot),
UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date),
KEY idx_selfop_history_group (publication_group_id,status),
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
product_quality INT NOT NULL COMMENT '来源库存品质',
physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零',
quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知',
quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因',
anchor_reference BIGINT NULL COMMENT '独立库存锚点版本',
evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要',
source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原',
PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality),
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
@@ -21,9 +21,11 @@ import java.time.LocalDateTime;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.Set;
@Service @Service
public class ExportTaskService { public class ExportTaskService {
private static final String TURNOVER = "SELFOP_INVENTORY_TURNOVER";
@Resource private ExportTaskRepository repository; @Resource private ExportTaskRepository repository;
@Resource private ExportStorage storage; @Resource private ExportStorage storage;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -34,6 +36,7 @@ public class ExportTaskService {
public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) { public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) {
requirePlatform(user); requirePlatform(user);
String type = request.exportType().trim().toUpperCase(Locale.ROOT); String type = request.exportType().trim().toUpperCase(Locale.ROOT);
if (TURNOVER.equals(type) && user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "平台自营导出不支持代登录");
String format = request.format().trim().toUpperCase(Locale.ROOT); String format = request.format().trim().toUpperCase(Locale.ROOT);
var definition = registry.require(type); var definition = registry.require(type);
if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式"); if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式");
@@ -135,6 +138,9 @@ public class ExportTaskService {
} }
private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) { private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) {
if (TURNOVER.equals(task.getExportType()) && (user.impersonated() || !user.permissions().contains("platform:self-operated-analytics:export"))) {
throw new BizException(ResultCode.FORBIDDEN, "缺少平台自营导出权限");
}
if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return; if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return;
try { try {
if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false) if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false)
@@ -172,11 +178,14 @@ public class ExportTaskService {
} }
values.forEach((key, value) -> { values.forEach((key, value) -> {
if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效"); if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
validateParameterValue(value, 1); if (TURNOVER.equals(type) && "snapshotContext".equals(key)) {
if (!(value instanceof String text) || text.isBlank() || text.length() > 16000)
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转查询版本无效,请刷新后重新导出");
} else validateParameterValue(value, 1);
}); });
try { try {
int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type) int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type)
? 512 * 1024 : 4096; ? 512 * 1024 : TURNOVER.equals(type) ? 64 * 1024 : 4096;
if (objectMapper.writeValueAsBytes(values).length > maxBytes) { if (objectMapper.writeValueAsBytes(values).length > maxBytes) {
throw new BizException(ResultCode.VALIDATION_FAILED, throw new BizException(ResultCode.VALIDATION_FAILED,
"导出筛选条件不能超过 " + (maxBytes / 1024) + "KB"); "导出筛选条件不能超过 " + (maxBytes / 1024) + "KB");
@@ -210,6 +219,22 @@ public class ExportTaskService {
} }
private void validateTypeParameters(String type, Map<String, Object> parameters) { private void validateTypeParameters(String type, Map<String, Object> parameters) {
if (TURNOVER.equals(type)) {
Map<String,Object> values=parameters==null?Map.of():parameters;
if (!values.keySet().containsAll(Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","snapshotContext","columns","expectedTotal"))
|| !Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","hideZeroInventory","snapshotContext","columns","expectedTotal").containsAll(values.keySet()))
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出筛选无效");
if (!(values.get("columns") instanceof java.util.List<?> columns) || columns.isEmpty() || columns.size()>30
|| columns.stream().anyMatch(v -> !(v instanceof String)) || new java.util.HashSet<>(columns).size()!=columns.size())
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出列无效");
if (values.containsKey("customerCodes") && (!(values.get("customerCodes") instanceof java.util.List<?> owners) || owners.size()>100 || owners.stream().anyMatch(v -> !(v instanceof String))))
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转货主范围无效");
try {
long count=new java.math.BigDecimal(String.valueOf(values.get("expectedTotal"))).longValueExact();
if(count<0 || count>200000) throw new ArithmeticException();
} catch(RuntimeException e) { throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出条数无效"); }
return;
}
if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return; if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return;
Map<String, Object> values = parameters == null ? Map.of() : parameters; Map<String, Object> values = parameters == null ? Map.of() : parameters;
if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) { if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) {
+9
View File
@@ -224,6 +224,15 @@ baiyue:
max-rows: 200000 max-rows: 200000
max-attempts: 3 max-attempts: 3
batch-size: 200 batch-size: 200
SELFOP_INVENTORY_TURNOVER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches
required-permission: platform:self-operated-analytics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 100
OVERSEAS_INVENTORY_LEDGER: OVERSEAS_INVENTORY_LEDGER:
provider-service: lastleg-service provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
@@ -0,0 +1,50 @@
openapi: 3.0.3
info:
title: 库存周转后台导出
version: 1.0.0
description: File 下载中心生成 XLSX,Lastleg 内部 Provider 按固定库存周转版本供数。浏览器不分页取数、不生成文件。
paths:
/api/platform/v1/files/export-tasks:
post:
summary: 提交库存周转 Excel 导出
description: |
仅平台、非代登录身份及 platform:self-operated-analytics:export 权限。
保留列表的 sourceAccount、warehouseCode、endDate、baseWindowDays、groupBy、customerCodes、sku、focus、coverageDaysThreshold、turnoverStatus、sortBy、hideZeroInventory、snapshotContext。
columns 为当前显示列的有序白名单键,服务端决定列名、格式并追加周转率证据列;expectedTotal 为已查询总条数,范围0至200000。
不接受 current/size/includeOverview。snapshotContext 最长16000字符,整组参数最大64KiB。空结果生成只有表头的文件。
后台每批最多100条,沿用固定上下文30分钟有效期;过期、服务重启或历史版本失效须刷新列表重新提交,不自动改用新版本。
版本或条数变化、分页缺失、超行数、生成失败时不提供部分文件。下载中心复用进度、临时失败退避重试、归属校验及过期规则。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [exportType, format, taskName, parameters]
properties:
exportType: {type: string, enum: [SELFOP_INVENTORY_TURNOVER]}
format: {type: string, enum: [XLSX]}
taskName: {type: string, maxLength: 128}
parameters:
type: object
required: [sourceAccount, warehouseCode, endDate, baseWindowDays, snapshotContext, columns, expectedTotal]
properties:
snapshotContext: {type: string, minLength: 1, maxLength: 16000}
columns: {type: array, minItems: 1, maxItems: 30, uniqueItems: true, items: {type: string}}
expectedTotal: {type: integer, minimum: 0, maximum: 200000}
additionalProperties: true
responses:
'200': {description: ApiResponse 包装下载中心任务;业务错误遵循通用 code 契约}
'403': {description: 无平台自营导出权限或非平台身份}
/api/platform/v1/files/export-tasks/{taskId}/download:
get:
summary: 下载后台生成的库存周转 Excel
description: 校验任务归属、完成状态、有效期,以及当前非代登录身份和平台自营导出权限。
parameters:
- {name: taskId, in: path, required: true, schema: {type: string}}
responses:
'200':
description: XLSX 文件
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema: {type: string, format: binary}
@@ -13,6 +13,7 @@ class LastlegExportDefinitionContractTest {
Map<?, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml")); Map<?, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml"));
Map<String, String> expected = Map.ofEntries( Map<String, String> expected = Map.ofEntries(
Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"), Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"),
Map.entry("SELFOP_INVENTORY_TURNOVER", "platform:self-operated-analytics:export"),
Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"), Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"), Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"), Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"),
@@ -28,7 +29,7 @@ class LastlegExportDefinitionContractTest {
assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path")); assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path"));
assertEquals(entry.getValue(), definition.get("required-permission")); assertEquals(entry.getValue(), definition.get("required-permission"));
assertEquals("PLATFORM", definition.get("app-scope")); assertEquals("PLATFORM", definition.get("app-scope"));
assertEquals(200, definition.get("batch-size")); assertEquals(entry.getKey().equals("SELFOP_INVENTORY_TURNOVER") ? 100 : 200, definition.get("batch-size"));
var controlled = new ExportDefinitionRegistry.Definition(); var controlled = new ExportDefinitionRegistry.Definition();
controlled.setProviderService("lastleg-service"); controlled.setProviderService("lastleg-service");
controlled.setProviderPath(String.valueOf(definition.get("provider-path"))); controlled.setProviderPath(String.valueOf(definition.get("provider-path")));
@@ -39,6 +39,23 @@ class ExportTaskServiceTest {
@MockBean private ExportStateService states; @MockBean private ExportStateService states;
@MockBean private ExportMetrics metrics; @MockBean private ExportMetrics metrics;
@Resource private ExportTaskService service; @Resource private ExportTaskService service;
@Test void turnoverKeepsBoundedSignedContextAndRequiresIndependentExportPermission() {
var definition=definition();definition.setRequiredPermission("platform:self-operated-analytics:export");
when(registry.require("SELFOP_INVENTORY_TURNOVER")).thenReturn(definition);
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(c->new ExportStateService.CreationResult(c.getArgument(0),true));
Map<String,Object> parameters=new LinkedHashMap<>();
parameters.put("sourceAccount","demo");parameters.put("warehouseCode","W");parameters.put("endDate","2026-10-14");parameters.put("baseWindowDays",30);
parameters.put("snapshotContext","x".repeat(15000));parameters.put("columns",List.of("sku","quantityTurnoverRate"));parameters.put("expectedTotal",205);
var request=new CreateExportTaskRequest("SELFOP_INVENTORY_TURNOVER","XLSX","库存周转",parameters);
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:list")),request));
assertEquals("PENDING",service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request).status());
parameters.put("snapshotContext","x".repeat(16001));
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
parameters.put("snapshotContext","fixed");parameters.put("current",2);
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
parameters.remove("current");parameters.remove("columns");
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
}
@Test @Test
void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() { void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
@@ -94,6 +111,15 @@ class ExportTaskServiceTest {
assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id()); assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id());
} }
@Test void turnoverDownloadAndRetryRecheckExportPermissionEvenWithDownloadAll() {
var task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(2L);
task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setStatus("SUCCEEDED");when(repository.find(9L)).thenReturn(task);
var reader=user(1L,Set.of("platform:download:all","platform:self-operated-analytics:list"));
assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.download(reader,9L)).getResultCode());
assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.retry(reader,9L)).getResultCode());
verifyNoInteractions(storage);
}
@Test @Test
void rejectsParametersDeeperThanTwoLevels() { void rejectsParametersDeeperThanTwoLevels() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
@@ -66,6 +66,24 @@ class GenericXlsxExportRunnerTest {
assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken()); assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken());
} }
@Test void turnoverWorkbookPreservesColumnOrderPercentUnknownAndLiteralSku() throws Exception {
var columns=List.of(new ExportColumn("quantityTurnoverRate","期间周转率",ExportColumnType.STRING,24),
new ExportColumn("sku","SKU",ExportColumnType.STRING,24),new ExportColumn("historyDays","合格历史库存天数",ExportColumnType.STRING,24));
var batch=new ExportBatchResponse(1,"sha256:turnover","fixed",columns,List.of(
Map.of("quantityTurnoverRate","125%","sku","=SUM(1,2)","historyDays","30/30"),
Map.of("quantityTurnoverRate","—","sku","未知商品","historyDays","29/30"),
Map.of("quantityTurnoverRate","0%","sku","零销量","historyDays","30/30")),"3",false,3L);
when(provider.batch(anyString(),eq("SELFOP_INVENTORY_TURNOVER"),any(),any())).thenReturn(ApiResponse.success(batch));
var task=new FileExportTask();task.setId(18L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);
task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setTaskName("库存周转");task.setParametersJson("{\"snapshotContext\":\"fixed\"}");
var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(100);
var result=runner.run(task,definition,new AtomicBoolean(),ignored->{});
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) {
var sheet=workbook.getSheetAt(0);assertEquals("期间周转率",sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("125%",sheet.getRow(1).getCell(0).getStringCellValue());assertEquals("'=SUM(1,2)",sheet.getRow(1).getCell(1).getStringCellValue());
assertEquals("—",sheet.getRow(2).getCell(0).getStringCellValue());assertEquals("0%",sheet.getRow(3).getCell(0).getStringCellValue());assertEquals(3,result.rows());
}
}
@Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception { @Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception {
var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9", var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9",
List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)), List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)),
@@ -1,4 +1,4 @@
-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。 -- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限及后台导出权限独立。
USE baiyue_iam; USE baiyue_iam;
SET NAMES utf8mb4; 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 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
@@ -8,10 +8,12 @@ INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,perm
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'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), (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), (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),
(5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'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); (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 INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)), (4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118002,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)), (4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)),
@@ -78,6 +78,24 @@ public class LingxingInternalController {
return ApiResponse.success(client.queryDetail(request, context(context))); return ApiResponse.success(client.queryDetail(request, context(context)));
} }
@Operation(summary = "单SKU最多31日库存流水诊断,仅第一页最多1000条")
@PostMapping("/inventory-flow/query")
public ApiResponse<QueryPage<InventoryFlow>> inventoryFlow(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
@Valid @RequestBody InventoryFlowRequest request) {
tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context)));
}
@Operation(summary = "读取固定单仓最多一天的全部货主品质库存流水单页")
@PostMapping("/inventory-flow-page/query")
public ApiResponse<QueryPage<InventoryFlow>> inventoryFlowPage(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
@Valid @RequestBody InventoryFlowPageRequest request) {
tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlowPage(request,context(context)));
}
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页") @Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
@PostMapping("/product-inventory/query") @PostMapping("/product-inventory/query")
public ApiResponse<QueryPage<ProductStock>> inventory( public ApiResponse<QueryPage<ProductStock>> inventory(
@@ -6,7 +6,7 @@ import java.time.Instant;
public final class LingxingEgressDtos { public final class LingxingEgressDtos {
private LingxingEgressDtos() { } private LingxingEgressDtos() { }
public enum CollectionMode { OFF, VALIDATION, BUSINESS } public enum CollectionMode { OFF, VALIDATION, BUSINESS }
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS } public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW }
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId, public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
String sessionId, String attemptId, String exclusiveGrantId) { String sessionId, String attemptId, String exclusiveGrantId) {
public boolean wellFormed() { public boolean wellFormed() {
@@ -20,6 +20,15 @@ public final class LingxingWmsDtos {
public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { } public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record ProductQueryRequest(@Min(1) int current, @Min(1) @Max(100) int size) { } public record ProductQueryRequest(@Min(1) int current, @Min(1) @Max(100) int size) { }
public record InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku,
@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime) { }
public record InventoryFlowPageRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotNull LocalDateTime startTime,@NotNull LocalDateTime endTime,
@Min(1) int current,@Min(1) @Max(1000) int size) { }
public record InventoryFlow(String id, String customerCode, String whCode, String productSku,
String cellNo, String innerBatchNo, String billNo, String billType, Integer productQuality,
BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime, String sourceNo) { }
public record Customer(String customerCode, String customerName, String status) { } public record Customer(String customerCode, String customerName, String status) { }
public record Product(String customerCode, String sku, String productName, public record Product(String customerCode, String sku, String productName,
BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit, BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit,
@@ -162,6 +162,49 @@ public final class LingxingWmsClient {
(data, metadata) -> inventory(data, request, metadata)); (data, metadata) -> inventory(data, request, metadata));
} }
/** Single bounded diagnostic page; never changes the current-inventory collection protocol. */
public QueryPage<InventoryFlow> queryInventoryFlow(InventoryFlowRequest request, EgressContext context) {
if(request==null)throw badRequest();
requireInput(request.warehouseCode(),64);requireInput(request.customerCode(),64);requireInput(request.sku(),128);
if(request.startTime()==null||request.endTime()==null||!request.startTime().isBefore(request.endTime())
||request.endTime().isAfter(request.startTime().plusDays(31)))throw badRequest();
Map<String,Object> body=pagination(1,1000);
body.put("whCode",request.warehouseCode());body.put("customerCode",request.customerCode());
body.put("barcodeAccurate",request.sku());body.put("startTime",SOURCE_TIME.format(request.startTime()));
body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc");
return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW,
(data,metadata)->mappedPage(data,1,1000,"current","size",true,metadata,row->{
if(!request.warehouseCode().equals(requiredText(row,"whCode",64))
||!request.customerCode().equals(requiredText(row,"customerCode",64))
||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200);
String id=requiredText(row,"id",64);
return new InventoryFlow(id,request.customerCode(),request.warehouseCode(),request.sku(),
optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),optionalText(row,"billNo"),optionalText(row,"billType"),
requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),quantity(row.get("bookQty")),
optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo"));
}));
}
/** One fixed warehouse interval page. Scheduling and coverage belong to Lastleg. */
public QueryPage<InventoryFlow> queryInventoryFlowPage(InventoryFlowPageRequest request,EgressContext context) {
if(request==null) throw badRequest();
requireInput(request.warehouseCode(),64);validatePagination(request.current(),request.size(),1000);
if(request.startTime()==null || request.endTime()==null || !request.startTime().isBefore(request.endTime())
|| request.endTime().isAfter(request.startTime().plusDays(1))) throw badRequest();
Map<String,Object> body=pagination(request.current(),request.size());
body.put("whCode",request.warehouseCode());body.put("startTime",SOURCE_TIME.format(request.startTime()));
body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc");
return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW,
(data,metadata)->mappedPage(data,request.current(),request.size(),"current","size",true,metadata,row->{
String warehouse=requiredText(row,"whCode",64);
if(!request.warehouseCode().equals(warehouse)) throw invalidResponse("FLOW_IDENTITY_MISMATCH",200);
return new InventoryFlow(requiredText(row,"id",64),requiredText(row,"customerCode",64),warehouse,
requiredText(row,"productSku",128),optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),
optionalText(row,"billNo"),optionalText(row,"billType"),requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),
quantity(row.get("bookQty")),optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo"));
}));
}
private QueryPage<ProductStock> inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) { private QueryPage<ProductStock> inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) {
Set<List<Object>> keys = new HashSet<>(); Set<List<Object>> keys = new HashSet<>();
return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> { return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> {
@@ -203,6 +246,9 @@ public final class LingxingWmsClient {
JsonNode json; JsonNode json;
try { json = envelope(response.body()); } try { json = envelope(response.body()); }
catch (LingxingWmsException error) { catch (LingxingWmsException error) {
// This optional endpoint may lack permission while ordinary collection remains authorized.
if(operation==Operation.INVENTORY_FLOW&&Integer.valueOf(11008).equals(error.sourceCode()))
throw upstreamFailure("FLOW_PERMISSION_DENIED","当前账号没有库存流水接口权限",false,200,11008);
if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) { if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) {
handle.rateLimited(retryAfter(response)); handle.rateLimited(retryAfter(response));
} }
@@ -316,7 +362,7 @@ public final class LingxingWmsClient {
} }
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) { 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 -> { QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", size == 100, metadata, record -> {
String deliveryNo = requiredText(record, "deliveryNo", 128); String deliveryNo = requiredText(record, "deliveryNo", 128);
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200); if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
int status = requiredInt(record.get("status"), "STATUS"); int status = requiredInt(record.get("status"), "STATUS");
@@ -340,7 +386,7 @@ public final class LingxingWmsClient {
} }
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField, private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) { String sizeField, boolean allowEmptyDefaultSize, AttemptMetadata metadata, Function<JsonNode, T> projection) {
JsonNode records = data.get("records"); JsonNode records = data.get("records");
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200); if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
@@ -350,7 +396,8 @@ public final class LingxingWmsClient {
int sourceSize = requiredInt(data.get(sizeField), "SIZE"); int sourceSize = requiredInt(data.get(sizeField), "SIZE");
int pages = requiredInt(data.get("pages"), "PAGES"); int pages = requiredInt(data.get("pages"), "PAGES");
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200); if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1 // Verified for outbound size 100 and the bounded inventory-flow query only.
boolean emptyException = allowEmptyDefaultSize && current == 1 && sourceCurrent == 1
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty(); && sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200); if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
long expectedPages = total / size + (total % size == 0 ? 0 : 1); long expectedPages = total / size + (total % size == 0 ? 0 : 1);
@@ -51,6 +51,63 @@ x-lastleg-egress-coordination:
security: security:
- InternalServiceToken: [] - InternalServiceToken: []
paths: paths:
/internal/integrations/lingxing/wms/inventory-flow-page/query:
post:
summary: One frozen warehouse inventory-flow page
description: >-
Shared INVENTORY_FLOW permit, one upstream request, no automatic retry. Fixed warehouse and
inclusive Beijing start/end bounds at most one day, current>=1, size 1..1000, sorted by createTime asc.
Customer/SKU filters are absent. Lastleg validates persisted task/page binding, page counts,
duplicate IDs and ordering before admitting coverage. Only quantity/identity whitelist fields return.
Empty first-page current=1,size=10,total=0,pages=0,records=[] is normalized to the requested size.
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [warehouseCode, startTime, endTime, current, size]
properties:
warehouseCode: {type: string, minLength: 1, maxLength: 64}
startTime: {type: string, format: date-time, description: Beijing local datetime without offset}
endTime: {type: string, format: date-time, description: Inclusive bound, at most one day after start}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
responses:
'200':
description: Envelope of QueryPage with current/size/total/pages/records and source pagination evidence, or sanitized ProviderFailure. Rows retain string id, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty and string bookChangQty/createTime.
/internal/integrations/lingxing/wms/inventory-flow/query:
post:
summary: One SKU inventory-flow diagnostic page
description: >-
Calls stock/stockCellBook/page through the existing INVENTORY_FLOW permit. Exact warehouse/customer/SKU,
at most 31 days, current=1 and size=1000 fixed. No automatic next page or retry. Source quality and signed
quantities are preserved, not translated to inventory-snapshot quality. Source 11008 only rejects this optional
operation; authentication, rate-limit and uncertain-result account protection remain effective.
Source movement IDs must be strings. An empty first response with current=1, size=10, total=0,
pages=0 and records=[] is normalized to requested size=1000 while retaining sourceSize=10.
Other pagination mismatches remain errors.
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [warehouseCode, customerCode, sku, startTime, endTime]
properties:
warehouseCode: {type: string, maxLength: 64}
customerCode: {type: string, maxLength: 64}
sku: {type: string, maxLength: 128}
startTime: {type: string, format: date-time, description: Beijing local datetime without offset}
endTime: {type: string, format: date-time, description: Beijing local datetime without offset}
responses:
'200':
description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published.
/internal/integrations/lingxing/wms/customers/query: /internal/integrations/lingxing/wms/customers/query:
post: post:
summary: Query one page of customers using the existing account egress permit summary: Query one page of customers using the existing account egress permit
@@ -315,7 +372,7 @@ components:
{"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0} {"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0}
EgressOperation: EgressOperation:
type: string type: string
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS] enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW]
EgressMetadata: EgressMetadata:
type: object type: object
required: [attemptId, actualHttpAttempts, accountPaused] required: [attemptId, actualHttpAttempts, accountPaused]
@@ -22,6 +22,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
/** Synthetic loopback transport only; no supplier or application services. */ /** Synthetic loopback transport only; no supplier or application services. */
class LingxingWmsClientTest { class LingxingWmsClientTest {
@Test void warehouseFlowPageKeepsScopeAndUsesSharedPermit() throws Exception {
response.set(page("current","size",2,1,2,2,"{\"id\":\"F2\",\"productQuality\":0,\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"sourceNo\":\"SOURCE\",\"bookChangQty\":\"-1\"}"));
var request=new InventoryFlowPageRequest("W",LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-10-02T00:00:00"),2,1);
var result=client().queryInventoryFlowPage(request,context());
assertThat(result.records().get(0).sourceNo()).isEqualTo("SOURCE");
var business=mapper.readTree(body.get()).path("data");
assertThat(business.path("whCode").asText()).isEqualTo("W");
assertThat(business.has("customerCode")).isFalse();assertThat(business.has("productSku")).isFalse();
assertThat(business.path("current").asInt()).isEqualTo(2);
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
assertThrows(LingxingWmsException.class,()->client().queryInventoryFlowPage(new InventoryFlowPageRequest("W",request.startTime(),request.endTime().plusSeconds(1),1,1000),context()));
assertThat(calls.get()).isEqualTo(1);
}
@Test void customerDirectoryKeepsNamesAndUsesAccountGate() { @Test void customerDirectoryKeepsNamesAndUsesAccountGate() {
response.set(page("page","pageSize",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}")); response.set(page("page","pageSize",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}"));
var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context()); var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context());
@@ -311,6 +324,73 @@ class LingxingWmsClientTest {
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + 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())); assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
} }
private InventoryFlowRequest flow() {return new InventoryFlowRequest("W-DEMO","C-DEMO","SKU-DEMO",
LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));}
@Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception {
response.set(page("current","size",1,1000,1,1,"""
{"id":"9223372036854775806","customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO",
"productQuality":0,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"}
"""));
var result=client().queryInventoryFlow(flow(),context());
assertThat(path.get()).isEqualTo("/openapi/v2/stock/stockCellBook/page");
var sent=mapper.readTree(body.get()).path("data");
assertThat(sent.path("barcodeAccurate").asText()).isEqualTo("SKU-DEMO");
assertThat(sent.path("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse();
assertThat(result.records().get(0).id()).isEqualTo("9223372036854775806");
assertThat(result.records().get(0).productQuality()).isZero();
assertThat(result.records().get(0).bookQty()).isEqualByComparingTo("-3");
assertThat(result.records().get(0).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1);
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
}
@Test void flowRejectsNumericIdInsteadOfCoercingUnverifiedSourceTypes() {
response.set(page("current","size",1,1000,1,1,"""
{"id":123,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO",
"productQuality":0,"bookQty":-1,"bookChangQty":"-1","closeQty":9}
"""));
contract(()->client().queryInventoryFlow(flow(),context()),"TEXT_FIELD_TYPE_NUMBER");
}
@Test void flowAcceptsVerifiedEmptyFirstPageAndPreservesSourcePagination() {
response.set(page("current","size",1,10,0,0,""));
var result=client().queryInventoryFlow(flow(),context());
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
assertThat(result.current()).isEqualTo(1);
assertThat(result.size()).isEqualTo(1000);
assertThat(result.sourceSize()).isEqualTo(10);
assertThat(calls.get()).isEqualTo(1);
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
}
@Test void flowEmptyExceptionDoesNotAcceptNonemptyOrMalformedPagination() {
var c=client();
response.set(page("current","size",1,10,1,1,"{}"));
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
response.set(page("current","size",2,10,0,0,""));
contract(()->c.queryInventoryFlow(flow(),context()),"CURRENT_MISMATCH");
response.set(page("current","size",1,10,0,1,""));
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
response.set(page("current","size",1,20,0,0,""));
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
}
@Test void flowEmptyExceptionDoesNotRelaxInventoryOrOtherOutboundSizes() {
var c=client();response.set(page("current","size",1,10,0,0,""));
contract(()->c.queryInventory(new InventoryQueryRequest("W-DEMO",1,1000),context()),"SIZE_MISMATCH");
contract(()->c.queryPage(query(1,20),context()),"SIZE_MISMATCH");
}
@Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() {
var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}");
var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context()));
assertThat(error.code()).isEqualTo("LINGXING_WMS_FLOW_PERMISSION_DENIED");
assertThat(completion.get().accountPaused()).isFalse();
response.set(page("current","size",1,1,1,1,ORDER));
assertThat(c.queryPage(query(1,1),context()).records()).hasSize(1);assertThat(calls.get()).isEqualTo(2);
}
@Test void flowRejectsUnboundedWindowBeforeSendingAndWrongSkuAfterResponse() {
var q=flow();var c=client();
assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(new InventoryFlowRequest(q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.startTime().plusDays(32)),context()));
assertThat(calls.get()).isZero();
response.set(page("current","size",1,1000,1,1,"{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"productSku\":\"OTHER\"}"));
contract(()->c.queryInventoryFlow(q,context()),"FLOW_IDENTITY_MISMATCH");
}
private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); } private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); }
private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) { private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) {
var clock = new LingxingEgressGateTest.MutableClock(NOW); var clock = new LingxingEgressGateTest.MutableClock(NOW);
@@ -19,7 +19,7 @@ test('selfoperated analytics is independent of customer outbound and inventory s
assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound'))); assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound')));
assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics')); assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics'));
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics' assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics'
|| edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }]); || edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }, { source: 'shared-technical', target: 'selfoperated-analytics', count: 1 }]);
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{ assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{
source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard', source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard',
}]); }]);
@@ -16,6 +16,7 @@ import com.baiyue.lastleg.fleet.quote.FleetQuoteModels;
import com.baiyue.lastleg.fleet.quote.FleetQuoteService; import com.baiyue.lastleg.fleet.quote.FleetQuoteService;
import com.baiyue.lastleg.fleet.reporting.TripOperationsModels; import com.baiyue.lastleg.fleet.reporting.TripOperationsModels;
import com.baiyue.lastleg.fleet.reporting.TripOperationsService; import com.baiyue.lastleg.fleet.reporting.TripOperationsService;
import com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverExport;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -41,6 +42,7 @@ public class LastlegExportProviderService {
@Resource private VehicleApplicationService vehicles; @Resource private VehicleApplicationService vehicles;
@Resource private FleetQuoteService fleetQuotes; @Resource private FleetQuoteService fleetQuotes;
@Resource private TripOperationsService operations; @Resource private TripOperationsService operations;
@Resource private InventoryTurnoverExport turnover;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Resource private Validator validator; @Resource private Validator validator;
@Value("${baiyue.security.internal-service-token}") private String signingKey; @Value("${baiyue.security.internal-service-token}") private String signingKey;
@@ -51,15 +53,16 @@ public class LastlegExportProviderService {
} }
if (request.taskId() == null || request.requestedBy() == null || request.dispatchVersion() < 1 if (request.taskId() == null || request.requestedBy() == null || request.dispatchVersion() < 1
|| request.limit() < 1 || request.limit() > 200) throw invalid("导出批次参数无效"); || request.limit() < 1 || request.limit() > 200) throw invalid("导出批次参数无效");
List<ExportColumn> columns;
try { columns = LastlegExportColumns.columns(type); }
catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); }
int offset = cursor(request.cursor()); int offset = cursor(request.cursor());
if (offset % request.limit() != 0) throw invalid("导出游标无效"); if (offset % request.limit() != 0) throw invalid("导出游标无效");
String snapshot = signature(type, request); String snapshot = signature(type, request);
boolean first = request.snapshotToken() == null; boolean first = request.snapshotToken() == null;
if ((first && offset != 0) || (!first && !MessageDigest.isEqual(snapshot.getBytes(StandardCharsets.UTF_8), if ((first && offset != 0) || (!first && !MessageDigest.isEqual(snapshot.getBytes(StandardCharsets.UTF_8),
request.snapshotToken().getBytes(StandardCharsets.UTF_8)))) throw invalid("导出筛选快照不匹配"); request.snapshotToken().getBytes(StandardCharsets.UTF_8)))) throw invalid("导出筛选快照不匹配");
if (InventoryTurnoverExport.TYPE.equals(type)) return turnover.batch(request, offset, snapshot);
List<ExportColumn> columns;
try { columns = LastlegExportColumns.columns(type); }
catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); }
Map<String, Object> parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters()); Map<String, Object> parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters());
String customer = text(parameters.remove("customerId")); String customer = text(parameters.remove("customerId"));
if (customer != null && !customer.matches("[1-9][0-9]*")) throw invalid("客户参数无效"); if (customer != null && !customer.matches("[1-9][0-9]*")) throw invalid("客户参数无效");
@@ -33,6 +33,7 @@ import java.util.function.Function;
public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
private static final String PREFIX = "/internal/integrations/lingxing/wms"; private static final String PREFIX = "/internal/integrations/lingxing/wms";
private static final Set<String> PROVIDER_ERROR_CODES = Set.of( private static final Set<String> PROVIDER_ERROR_CODES = Set.of(
"LINGXING_WMS_FLOW_PERMISSION_DENIED",
"LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED", "LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED",
"LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED", "LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED",
"LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED", "LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED",
@@ -122,6 +123,19 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
quantity(n,"wmsLength"),quantity(n,"wmsWidth"),quantity(n,"wmsHeight"),optional(n,"wmsSizeUnit"))); quantity(n,"wmsLength"),quantity(n,"wmsWidth"),quantity(n,"wmsHeight"),optional(n,"wmsSizeUnit")));
} }
public JsonNode inventoryFlow(SelfopRequestContext context,String warehouse,String customer,String sku,
LocalDateTime start,LocalDateTime end) {
gate(context);argument(warehouse,64);argument(customer,64);argument(sku,128);
return post(context,"/inventory-flow/query",Map.of("warehouseCode",warehouse,"customerCode",customer,"sku",sku,
"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end)));
}
public JsonNode inventoryFlowPage(SelfopRequestContext context,String warehouse,LocalDateTime start,LocalDateTime end,int current,int size) {
gate(context);argument(warehouse,64);paging(current,size,1000);
return post(context,"/inventory-flow-page/query",Map.of("warehouseCode",warehouse,"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),
"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),"current",current,"size",size));
}
private Summary summary(String account, JsonNode n) { private Summary summary(String account, JsonNode n) {
List<SelfopPayloads.Item> items = null; List<SelfopPayloads.Item> items = null;
JsonNode products = n.get("productList"); JsonNode products = n.get("productList");
@@ -160,9 +174,10 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY; case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY;
case "/customers/query" -> Operation.CUSTOMERS; case "/customers/query" -> Operation.CUSTOMERS;
case "/products/query" -> Operation.PRODUCTS; case "/products/query" -> Operation.PRODUCTS;
case "/inventory-flow/query", "/inventory-flow-page/query" -> Operation.INVENTORY_FLOW;
default -> throw failure("EGRESS_OPERATION_INVALID"); default -> throw failure("EGRESS_OPERATION_INVALID");
}; };
int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : op==Operation.INVENTORY_FLOW?1000:0;
String warehouse=(String)fields.get("warehouseCode"); String warehouse=(String)fields.get("warehouseCode");
String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null; 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)); var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields));
@@ -0,0 +1,23 @@
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.sync.InventoryFlowProbeService;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
@RestController
@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics/inventory-flow-probe")
public class PlatformInventoryFlowProbeController {
private final InventoryFlowProbeService service;
public PlatformInventoryFlowProbeController(InventoryFlowProbeService service){this.service=service;}
@PostMapping
@PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')")
public ApiResponse<InventoryFlowProbeService.Result> probe(@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody InventoryFlowProbeService.Request request) {
if(user==null||!Long.valueOf(0).equals(user.tenantId())||!"PLATFORM".equals(user.appCode())||user.impersonated()
||!user.permissions().contains("platform:self-operated-analytics:sync"))throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return ApiResponse.success(service.probe(request,user.username()));
}
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.*;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.request.*;
import com.baiyue.lastleg.selfoperated.analytics.sync.SelfopLeaseService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** One page per dispatch. Only the HTTP/page checkpoint holds the shared account lease. */
@Service
public class InventoryFlowCollector {
private final SelfopAnalyticsProperties config;
private final SelfopSyncTaskMapper tasks; private final SelfopSyncRunMapper runs; private final SelfopSyncStageMapper stages;
private final SelfopCoordinationMapper locks; private final SelfopLeaseService leases;
private final RestLingxingAnalyticsClient client; private final JdbcTemplate jdbc; private final TransactionTemplate tx;
private final String session=UUID.randomUUID().toString();
public InventoryFlowCollector(SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,
SelfopSyncStageMapper stages,SelfopCoordinationMapper locks,SelfopLeaseService leases,RestLingxingAnalyticsClient client,
JdbcTemplate jdbc,TransactionTemplate tx){this.config=config;this.tasks=tasks;this.runs=runs;this.stages=stages;this.locks=locks;
this.leases=leases;this.client=client;this.jdbc=jdbc;this.tx=tx;}
public record Checkpoint(String session,int nextPage,LocalDateTime lastTime) {}
public void plan(long accountId,InventoryFlowSlice slice,LocalDate round) {
String digest=hash(slice),key="continuous:history:"+round+":"+digest;
tx.executeWithoutResult(s->{
locks.lockDiscovery(accountId);
if(tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount()).eq("request_key",key))>0)return;
var now=utcNow();var task=new SelfopSyncTask();task.setId(IdWorker.getId());
task.setTaskNo("LXHIST-"+UUID.randomUUID().toString().replace("-",""));task.setSourceAccount(config.getSourceAccount());
task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(accountId);task.setTaskType(InventoryFlowSlice.TYPE);
task.setExecutionPurpose("BUSINESS");task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now));
task.setRequestKey(key);task.setRequestHash(digest);task.setScopeHash(digest);task.setQueryScopeJson(json(slice));
task.setQueryFromUtc(LocalDateTime.ofInstant(slice.from(),ZoneOffset.UTC));task.setQueryUntilUtc(LocalDateTime.ofInstant(slice.until(),ZoneOffset.UTC));
task.setPlannedAtUtc(round.atStartOfDay());task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);
task.setStatus("READY");task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now);tasks.insert(task);
});
}
public boolean priorityWork() {
return !locks.dueContinuous(utcNow(),1,config.getSourceAccount()).isEmpty();
}
public void page() {
if(priorityWork())return;
var pending=tasks.selectList(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount())
.eq("task_type",InventoryFlowSlice.TYPE).in("status",List.of("READY","RUNNING","RETRY_WAIT"))
.and(q->q.isNull("next_retry_at_utc").or().le("next_retry_at_utc",utcNow())).orderByDesc("query_until_utc").orderByAsc("id").last("LIMIT 1"));
if(pending.isEmpty())return;
var selected=pending.get(0);var lease=leases.acquireDiscovery(selected.getDiscoveryScopeId(),"history-"+session);
try {
if(priorityWork())return;
var run=tx.execute(s->{
leases.verify(lease);var task=locks.lockTask(selected.getId());
require(Set.of("READY","RUNNING","RETRY_WAIT").contains(task.getStatus()),"HISTORY_TASK_NOT_READY");
var prior=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",task.getId()).orderByDesc("attempt_no").last("LIMIT 1"));
if(prior!=null&&"FETCHING".equals(prior.getStatus())&&prior.getProgressSnapshotJson()!=null
&&session.equals(read(prior.getProgressSnapshotJson(),Checkpoint.class).session())) {
prior.setFenceToken(lease.fence());runs.updateById(prior);return prior;
}
if(prior!=null&&"FETCHING".equals(prior.getStatus())) {
prior.setStatus("ABORTED");prior.setErrorCode("HISTORY_RESTART_PAGE_ONE");prior.setFinishedAtUtc(utcNow());runs.updateById(prior);
}
if(task.getAttemptCount()>=config.getMaxAttempts()){task.setStatus("DEAD");tasks.updateById(task);return null;}
var now=utcNow();var next=new SelfopSyncRun();next.setId(IdWorker.getId());next.setTaskId(task.getId());
next.setAttemptNo(task.getAttemptCount()+1);next.setSourceAccount(task.getSourceAccount());next.setExecutionPurpose("BUSINESS");
next.setRunRole("ACCOUNT_DISCOVERY");next.setScopeHash(task.getScopeHash());next.setScopeSnapshotJson(task.getQueryScopeJson());
next.setFenceToken(lease.fence());next.setStatus("FETCHING");next.setLastPhase("FETCHING");next.setCoverageStatus("UNKNOWN");
next.setPageCount(0);next.setUniqueObjectCount(0L);next.setFetchStartedAtUtc(now);next.setCreatedAt(now);next.setUpdatedAt(now);
next.setProgressSnapshotJson(json(new Checkpoint(session,1,null)));runs.insert(next);
task.setStatus("RUNNING");task.setAttemptCount(next.getAttemptNo());task.setUpdatedAt(now);tasks.updateById(task);return next;
});
if(run==null)return;
var task=tasks.selectById(selected.getId());var slice=read(task.getQueryScopeJson(),InventoryFlowSlice.class);
var checkpoint=read(run.getProgressSnapshotJson(),Checkpoint.class);
try {
var page=slice.validate(client.inventoryFlowPage(SelfopRequestContext.forTask(task,run,config),slice.warehouse(),slice.start(),slice.end(),checkpoint.nextPage(),slice.size()),
checkpoint.nextPage(),run.getSourceTotalCount(),checkpoint.lastTime());
tx.executeWithoutResult(s->{
leases.verify(lease);var current=locks.lockTask(task.getId());require("RUNNING".equals(current.getStatus()),"HISTORY_TASK_STOPPED");
var now=utcNow();
for(var row:page.rows()) {
var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(run.getId());stage.setStageBatchId(run.getId());
stage.setLastFetchRunId(run.getId());stage.setWarehouseCode(slice.warehouse());stage.setObjectType("INVENTORY_FLOW");
stage.setObjectKeyHash(hash(row.path("id").asText()));stage.setContentHash(hash(row));stage.setPayloadJson(json(row));
stage.setFirstPageNo(checkpoint.nextPage());stage.setObservedAtUtc(now);stage.setValidationStatus("VALID");stage.setCreatedAt(now);stage.setUpdatedAt(now);
stages.insert(stage); // Duplicate IDs across pages roll back this entire page.
}
run.setPageCount(checkpoint.nextPage());run.setSourceTotalCount(page.total());run.setUniqueObjectCount(run.getUniqueObjectCount()+page.rows().size());
run.setProgressSnapshotJson(json(new Checkpoint(session,checkpoint.nextPage()+1,page.lastTime())));run.setUpdatedAt(now);run.setHeartbeatAtUtc(now);
if(checkpoint.nextPage()>=page.pages()) {
require(run.getUniqueObjectCount()==page.total(),"HISTORY_PAGE_COUNT_MISMATCH");
run.setStatus("VALIDATED");run.setLastPhase("VALIDATED");run.setFetchFinishedAtUtc(now);
// Coverage becomes usable only after the separate local merge finishes.
current.setStatus("WAITING_SOURCE");
}
runs.updateById(run);current.setProgressUpdatedAtUtc(now);current.setUpdatedAt(now);tasks.updateById(current);
});
}catch(EgressRejectedException e) {
tx.executeWithoutResult(s->{leases.verify(lease);jdbc.update("UPDATE ll_selfop_sync_task SET next_retry_at_utc=?,updated_at=? WHERE id=? AND status='RUNNING'",
e.waitUntilUtc()==null?utcNow().plusSeconds(30):e.waitUntilUtc(),utcNow(),task.getId());});
}catch(RuntimeException e) {
tx.executeWithoutResult(s->{leases.verify(lease);run.setStatus("FAILED");run.setErrorCode("HISTORY_FETCH_FAILED");
run.setErrorMessage("分页失败,完整片重试;账号不确定结果仍受出口暂停保护");run.setFinishedAtUtc(utcNow());runs.updateById(run);
boolean drift="HISTORY_PAGE_DRIFT_OR_INVALID".equals(e.getMessage())||e instanceof org.springframework.dao.DuplicateKeyException;
long seconds=Duration.between(slice.start(),slice.end()).getSeconds();
if(drift&&seconds>3600) {
var midpoint=slice.start().plusSeconds(seconds/2);
plan(task.getDiscoveryScopeId(),new InventoryFlowSlice(slice.warehouse(),slice.start(),midpoint,1000),task.getPlannedAtUtc().toLocalDate());
plan(task.getDiscoveryScopeId(),new InventoryFlowSlice(slice.warehouse(),midpoint,slice.end(),1000),task.getPlannedAtUtc().toLocalDate());
}
jdbc.update("UPDATE ll_selfop_sync_task SET status=?,next_retry_at_utc=?,updated_at=? WHERE id=? AND status='RUNNING'",
drift&&seconds>3600||task.getAttemptCount()>=config.getMaxAttempts()?"DEAD":"RETRY_WAIT",utcNow().plusSeconds(60),utcNow(),task.getId());});
}
}finally{leases.release(lease);}
}
}
@@ -0,0 +1,91 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.*;
import java.util.*;
/** Small local transactions; facts and invalidation commit together, independently of HTTP. */
@Repository
public class InventoryFlowFacts {
private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final InventoryHistoryVersions versions;
public InventoryFlowFacts(JdbcTemplate jdbc,TransactionTemplate tx,InventoryHistoryVersions versions){this.jdbc=jdbc;this.tx=tx;this.versions=versions;}
public boolean mergeNext(String account) {
var pending=jdbc.queryForList("SELECT r.id,t.id task_id,t.query_scope_json FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND t.status='WAITING_SOURCE' AND r.status='VALIDATED' ORDER BY r.id LIMIT 1",account,InventoryFlowSlice.TYPE);
if(pending.isEmpty())return false;
long run=((Number)pending.get(0).get("id")).longValue(),task=((Number)pending.get(0).get("task_id")).longValue();
var slice=SelfopSupport.read(string(pending.get(0).get("query_scope_json")),InventoryFlowSlice.class);
tx.executeWithoutResult(s->{
versions.lockWarehouse(account,slice.warehouse());
var state=jdbc.queryForMap("SELECT status,COALESCE(applied_fact_revision,0) checkpoint FROM ll_selfop_sync_run WHERE id=? FOR UPDATE",run);
if(!"VALIDATED".equals(state.get("status")))return;
long after=((Number)state.get("checkpoint")).longValue();
var batch=jdbc.queryForList("SELECT id,payload_json,content_hash,observed_at_utc FROM ll_selfop_sync_stage WHERE run_id=? AND object_type='INVENTORY_FLOW' AND id>? ORDER BY id LIMIT 500",run,after);
boolean changed=false;
for(var stage:batch)changed|=merge(account,slice,run,stage);
if(changed)versions.invalidate(account,slice.warehouse(),run,"FLOW_FACT_REVISED");
if(!batch.isEmpty()) {
jdbc.update("UPDATE ll_selfop_sync_run SET applied_fact_revision=?,updated_at=? WHERE id=?",batch.get(batch.size()-1).get("id"),now(),run);return;
}
// A previously observed row disappearing is uncertainty, not an invented reversal.
int missing=jdbc.update("UPDATE ll_selfop_inventory_flow SET validation_status='UNKNOWN',validation_reason='SOURCE_FLOW_MISSING',revision_no=revision_no+1 WHERE source_account=? AND warehouse_code=? AND event_at_utc>=? AND event_at_utc<? AND validation_reason IS NULL AND last_run_id<>? AND last_observed_at<(SELECT fetch_started_at_utc FROM ll_selfop_sync_run WHERE id=?) LIMIT 100",
account,slice.warehouse(),utc(slice.from()),utc(slice.until()),run,run);
if(missing>0){versions.invalidate(account,slice.warehouse(),run,"SOURCE_FLOW_MISSING");return;}
jdbc.update("UPDATE ll_selfop_sync_run SET status='FACTS_APPLIED',last_phase='FACTS_APPLIED',coverage_status='COMPLETE',covered_from_utc=?,covered_until_utc=?,finished_at_utc=?,updated_at=? WHERE id=?",
utc(slice.from()),utc(slice.until()),now(),now(),run);
jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',updated_at=? WHERE id=? AND status='WAITING_SOURCE'",now(),task);
});
return true;
}
private boolean merge(String account,InventoryFlowSlice slice,long run,Map<String,Object> stage) {
var row=SelfopSupport.read(string(stage.get("payload_json")),JsonNode.class);String id=row.path("id").asText();
var old=jdbc.queryForList("SELECT id,customer_code,sku_code,product_quality,content_hash,revision_no,validation_reason,last_observed_at FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND source_flow_id=?",account,slice.warehouse(),id);
var observed=instant(stage.get("observed_at_utc"));
if(!old.isEmpty()&&instant(old.get(0).get("last_observed_at")).isAfter(observed))return false;
if(!old.isEmpty()) {
var prior=old.get(0);
if("FLOW_IDENTITY_CONFLICT".equals(prior.get("validation_reason")))return false;
if(!Objects.equals(prior.get("customer_code"),row.path("customerCode").asText())
||!Objects.equals(prior.get("sku_code"),row.path("productSku").asText())
||((Number)prior.get("product_quality")).intValue()!=row.path("productQuality").asInt(-1)) {
jdbc.update("UPDATE ll_selfop_inventory_flow SET validation_status='UNKNOWN',validation_reason='FLOW_IDENTITY_CONFLICT',revision_no=revision_no+1 WHERE id=?",prior.get("id"));
return true;
}
}
if(!old.isEmpty()&&Objects.equals(old.get(0).get("content_hash"),stage.get("content_hash"))&&old.get(0).get("validation_reason")==null){
jdbc.update("UPDATE ll_selfop_inventory_flow SET last_run_id=?,last_observed_at=? WHERE id=?",run,utc(observed),old.get(0).get("id"));return false;
}
var fields=new LinkedHashMap<String,Object>();
fields.put("source_account",account);fields.put("warehouse_code",slice.warehouse());fields.put("source_flow_id",id);
fields.put("customer_code",row.path("customerCode").asText());fields.put("sku_code",row.path("productSku").asText());fields.put("product_quality",row.path("productQuality").asInt(-1));
for(var name:Map.of("cell_no","cellNo","inner_batch_no","innerBatchNo","bill_no","billNo","source_no","sourceNo","bill_type","billType").entrySet())fields.put(name.getKey(),nullable(row,name.getValue()));
var at=LocalDateTime.parse(row.path("createTime").asText().replace(' ','T'));
fields.put("book_qty",number(row,"bookQty"));fields.put("book_change_qty_raw",nullable(row,"bookChangQty"));fields.put("delta_quantity",number(row,"bookChangQty"));fields.put("close_quantity",number(row,"closeQty"));
fields.put("source_create_time",row.path("createTime").asText());fields.put("event_at_utc",at.minusHours(8));fields.put("business_date",at.toLocalDate());fields.put("quantity_unit","PCS");
fields.put("content_hash",stage.get("content_hash"));fields.put("stage_id",stage.get("id"));fields.put("last_run_id",run);fields.put("last_observed_at",utc(observed));
fields.put("validation_status","VALID");fields.put("validation_reason",null);
fields.put("revision_no",old.isEmpty()?1L:((Number)old.get(0).get("revision_no")).longValue()+1);
var named=new NamedParameterJdbcTemplate(jdbc);
if(old.isEmpty()) {
fields.put("id",IdWorker.getId());fields.put("first_run_id",run);
named.update("INSERT INTO ll_selfop_inventory_flow ("+String.join(",",fields.keySet())+") VALUES (:"+String.join(",:",fields.keySet())+")",fields);
}else{
String sets=String.join(",",fields.keySet().stream().map(k->k+"=:"+k).toList());fields.put("id",old.get(0).get("id"));
named.update("UPDATE ll_selfop_inventory_flow SET "+sets+" WHERE id=:id",fields);
}
return true;
}
static BigDecimal number(JsonNode row,String field){try{var v=new BigDecimal(row.path(field).asText());return v.scale()<=6&&v.precision()-v.scale()<=14?v:null;}catch(RuntimeException e){return null;}}
static String nullable(JsonNode row,String field){return row.path(field).isNull()||row.path(field).isMissingNode()?null:row.path(field).asText();}
static String string(Object value){return value instanceof byte[] b?new String(b,java.nio.charset.StandardCharsets.UTF_8):String.valueOf(value);}
static Instant instant(Object v){return v instanceof Timestamp t?t.toLocalDateTime().toInstant(ZoneOffset.UTC):((LocalDateTime)v).toInstant(ZoneOffset.UTC);}
static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
/** Fixed inclusive provider bounds; persisted coverage is half open at the next second. */
public record InventoryFlowSlice(String warehouse, LocalDateTime start, LocalDateTime end, int size) {
public static final String TYPE="INVENTORY_FLOW_HISTORY";
public InventoryFlowSlice {
if(warehouse==null||warehouse.isBlank()||warehouse.length()>64||start==null||end==null
||!start.isBefore(end)||end.isAfter(start.plusDays(1))||start.getNano()!=0||end.getNano()!=0||size!=1000)
throw new IllegalArgumentException("HISTORY_SLICE_INVALID");
}
public Map<String,Object> request(int page) {
if(page<1)throw new IllegalArgumentException("HISTORY_PAGE_INVALID");
return Map.of("warehouseCode",warehouse,"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),
"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),"current",page,"size",size);
}
public String requestHash(int page){return SelfopSupport.hash(request(page));}
public Instant from(){return start.toInstant(ZoneOffset.ofHours(8));}
public Instant until(){return end.plusSeconds(1).toInstant(ZoneOffset.ofHours(8));}
public record Page(long total,long pages,LocalDateTime lastTime,List<JsonNode> rows) {}
public Page validate(JsonNode response,int page,Long previousTotal,LocalDateTime previousLast) {
if(response==null||!response.path("records").isArray()||response.path("current").asInt()!=page
||response.path("size").asInt()!=size)throw invalid();
// Internal Long fields are serialized as decimal strings by LongIdJacksonConfiguration.
var totalNode=response.path("total");
if(!(totalNode.isIntegralNumber()||totalNode.isTextual())||!totalNode.asText().matches("[0-9]{1,19}"))throw invalid();
long total;
try{total=Long.parseLong(totalNode.asText());}catch(NumberFormatException e){throw invalid();}
long pages=total==0?0:(total-1)/size+1;
if(total<0||pages>Integer.MAX_VALUE||response.path("pages").asLong(-1)!=pages
||previousTotal!=null&&total!=previousTotal||page>Math.max(1,pages)
||response.path("records").size()!=Math.min(size,Math.max(0,total-(long)(page-1)*size)))throw invalid();
var ids=new HashSet<String>();var rows=new ArrayList<JsonNode>();var last=previousLast;
for(var row:response.path("records")) {
String id=text(row,"id",64);text(row,"customerCode",64);text(row,"productSku",128);
if(!warehouse.equals(text(row,"whCode",64))||!ids.add(id))throw invalid();
LocalDateTime at;
try{at=LocalDateTime.parse(row.path("createTime").asText().replace(' ','T'));}catch(RuntimeException e){throw invalid();}
if(at.isBefore(start)||at.isAfter(end)||last!=null&&at.isBefore(last))throw invalid();
last=at;rows.add(row);
}
return new Page(total,pages,last,List.copyOf(rows));
}
static String text(JsonNode row,String field,int max) {
var value=row.path(field);String text=value.asText();
if(!value.isTextual()||text.isBlank()||text.length()>max)throw invalid();return text;
}
private static IllegalStateException invalid(){return new IllegalStateException("HISTORY_PAGE_DRIFT_OR_INVALID");}
}
@@ -0,0 +1,186 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Local reconstruction worker. Source requests and account leases are deliberately absent. */
@Service
public class InventoryHistoryBuilder {
public static final String TYPE="INVENTORY_HISTORY_BUILD";
private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final InventoryHistoryVersions versions;
private final SelfopSyncTaskMapper tasks;private final SelfopSyncRunMapper runs;private final SelfopSyncStageMapper stages;
public InventoryHistoryBuilder(JdbcTemplate jdbc,TransactionTemplate tx,InventoryHistoryVersions versions,
SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopSyncStageMapper stages){this.jdbc=jdbc;this.tx=tx;this.versions=versions;this.tasks=tasks;this.runs=runs;this.stages=stages;}
public record StockRun(long id,String scopeHash,Instant from,Instant until) {}
public StockRun anchor(String account,String warehouse,int offset) {
var rows=jdbc.query("SELECT r.id,r.scope_hash,r.fetch_started_at_utc,r.fetch_finished_at_utc FROM ll_selfop_sync_run r JOIN ll_selfop_sync_scope s ON s.id=r.scope_id AND s.scope_hash=r.scope_hash WHERE s.source_account=? AND s.warehouse_code=? AND s.data_family='INVENTORY' AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.coverage_status='COMPLETE' AND r.source_total_count=r.unique_object_count AND r.page_count>0 AND r.fetch_started_at_utc<=r.fetch_finished_at_utc ORDER BY r.published_at_utc DESC,r.id DESC LIMIT 1 OFFSET ?",
(rs,n)->new StockRun(rs.getLong(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime().toInstant(ZoneOffset.UTC),rs.getTimestamp(4).toLocalDateTime().toInstant(ZoneOffset.UTC)),account,warehouse,offset);
return rows.isEmpty()?null:rows.get(0);
}
private record Frozen(InventoryHistoryVersions.Build build,List<InventoryHistoryVersions.Candidate> candidates,
long task,StockRun anchor,StockRun reference,LocalDate from,LocalDate until,List<Interval> coverage,List<Long> coverageRuns) {}
public void build(String account,String warehouse,int days) {
Frozen frozen=tx.execute(s->freeze(account,warehouse,Math.max(30,Math.min(60,days))));
if(frozen==null)return;
try {
long count=0;String owner="",sku="";
while(true) {
// Keyset paging bounds memory independently of the warehouse's SKU count.
var identities=candidates(frozen,owner,sku);
if(identities.isEmpty())break;
var dailyBatches=new HashMap<LocalDate,List<Daily>>();
for(var identity:identities) {
var input=input(frozen,identity);var daily=reconstruct(input);
String crosscheck=crosscheck(input.anchors(),referenceAnchors(frozen,identity),input.flows(),frozen.coverage());
if(crosscheck!=null)daily=daily.stream().map(d->new Daily(d.date(),d.key(),null,"UNKNOWN",crosscheck,d.anchorRun())).toList();
var evidence=flowEvidence(frozen,identity);
String evidenceHash=hash(evidence);
tx.executeWithoutResult(s->{
requireBuilding(frozen);saveInput(frozen,identity,evidence,evidenceHash);
});
for(var d:daily)dailyBatches.computeIfAbsent(d.date(),k->new ArrayList<>()).add(d);
owner=identity.owner();sku=identity.sku();count++;
}
for(var candidate:frozen.candidates()) {
var rows=dailyBatches.getOrDefault(candidate.date(),List.of());
for(int start=0;start<rows.size();start+=500)versions.append(candidate,rows.subList(start,Math.min(rows.size(),start+500)),hash(List.of(frozen.build().run(),candidate.date())));
}
heartbeat(frozen);
}
for(var c:frozen.candidates())versions.ready(c,count);
tx.executeWithoutResult(s->{
versions.lockWarehouse(account,warehouse);requireBuilding(frozen);
versions.publish(frozen.build(),frozen.candidates());
jdbc.update("UPDATE ll_selfop_sync_run SET status='FACTS_APPLIED',last_phase='HISTORY_PUBLISHED',published_at_utc=?,finished_at_utc=?,updated_at=? WHERE id=?",utcNow(),utcNow(),utcNow(),frozen.build().run());
jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=?,updated_at=? WHERE id=?",frozen.build().run(),utcNow(),frozen.task());
});
}catch(RuntimeException failure) {
tx.executeWithoutResult(s->{
versions.lockWarehouse(account,warehouse);
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='FAILED' WHERE build_run_id=? AND status IN ('BUILDING','READY')",frozen.build().run());
jdbc.update("UPDATE ll_selfop_sync_run SET status='FAILED',error_code='HISTORY_BUILD_FAILED',error_message='输入变化或构建失败,保留候选证据等待下轮重新构建',finished_at_utc=?,updated_at=? WHERE id=? AND status='BUILDING'",utcNow(),utcNow(),frozen.build().run());
jdbc.update("UPDATE ll_selfop_sync_task SET status='DEAD',updated_at=? WHERE id=? AND status='RUNNING'",utcNow(),frozen.task());
});
throw failure;
}
}
private Frozen freeze(String account,String warehouse,int days) {
versions.lockWarehouse(account,warehouse);
var active=jdbc.queryForList("SELECT t.id,r.id run_id,r.heartbeat_at_utc FROM ll_selfop_sync_task t JOIN ll_selfop_sync_run r ON r.task_id=t.id WHERE t.source_account=? AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND t.task_type=? AND t.status='RUNNING' AND r.status='BUILDING' FOR UPDATE",account,warehouse,TYPE);
if(!active.isEmpty()) {
var row=active.get(0);var beat=row.get("heartbeat_at_utc");
if(beat!=null&&InventoryFlowFacts.instant(beat).isAfter(Instant.now().minusSeconds(300)))return null;
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='FAILED' WHERE build_run_id=? AND status IN ('BUILDING','READY')",row.get("run_id"));
jdbc.update("UPDATE ll_selfop_sync_run SET status='ABORTED',error_code='HISTORY_BUILD_INTERRUPTED',finished_at_utc=? WHERE id=?",utcNow(),row.get("run_id"));
jdbc.update("UPDATE ll_selfop_sync_task SET status='DEAD',updated_at=? WHERE id=?",utcNow(),row.get("id"));
}
// A quarantined identity cannot silently move to a different customer/SKU/quality.
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND validation_reason='FLOW_IDENTITY_CONFLICT'",Long.class,account,warehouse)>0)return null;
var anchor=anchor(account,warehouse,0);var reference=anchor(account,warehouse,1);
if(anchor==null||reference==null)return null;
LocalDate until=anchor.from().atOffset(ZoneOffset.ofHours(8)).toLocalDate();
Instant retainedFrom=until.minusDays(366).atStartOfDay().toInstant(ZoneOffset.ofHours(8));
Instant coverageFrom=reference.from().isBefore(retainedFrom)?reference.from():retainedFrom;
var coverageRows=jdbc.queryForList("SELECT r.id,r.covered_from_utc,r.covered_until_utc FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND r.status='FACTS_APPLIED' AND r.coverage_status='COMPLETE' AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND r.covered_until_utc>? ORDER BY r.covered_from_utc",account,InventoryFlowSlice.TYPE,warehouse,utc(coverageFrom));
var coverage=union(coverageRows.stream().map(r->new Interval(InventoryFlowFacts.instant(r.get("covered_from_utc")),InventoryFlowFacts.instant(r.get("covered_until_utc")))).toList());
var coverageRuns=coverageRows.stream().map(r->((Number)r.get("id")).longValue()).toList();
LocalDate from=until;
while(from.isAfter(until.minusDays(days))&&covered(coverage,from.minusDays(1).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),anchor.until().plusNanos(1)))from=from.minusDays(1);
if(from.equals(until))return null;
var heads=versions.heads(account,warehouse,until.minusDays(366),until);
// Corrections rebuild every retained date, or remain invalid until its coverage returns.
if(!heads.isEmpty()&&heads.keySet().stream().min(LocalDate::compareTo).orElseThrow().isBefore(from)) {
LocalDate earliest=heads.keySet().iterator().next();
if(!covered(coverage,earliest.atStartOfDay().toInstant(ZoneOffset.ofHours(8)),anchor.until().plusNanos(1)))return null;
from=earliest;
}
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND r.status='VALIDATED' AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND t.query_until_utc>? AND t.query_from_utc<?",Long.class,account,InventoryFlowSlice.TYPE,warehouse,from.atStartOfDay().minusHours(8),utc(anchor.until()))>0)return null;
var sales=jdbc.queryForList("SELECT published_run_id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='OUTBOUND'",account,warehouse);
Long salesRun=sales.isEmpty()||sales.get(0).get("published_run_id")==null?null:((Number)sales.get(0).get("published_run_id")).longValue();
String inputHash=versions.factHash(account,warehouse);
String signature=hash(Arrays.asList(RULE,CALCULATION_REVISION,inputHash,anchor,reference,from,until,salesRun,coverage));
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=? AND status='SUCCEEDED'",Long.class,account,TYPE,signature)>0)return null;
// Failed identical inputs get bounded attempts, instead of an infinite hot loop.
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=?",Long.class,account,TYPE,signature)>=3)return null;
long taskId=IdWorker.getId(),runId=IdWorker.getId(),group=IdWorker.getId();var now=utcNow();
String evidence=json(Map.of("warehouse",warehouse,"anchor",anchor,"reference",reference,"coverageRuns",coverageRuns,"inputStagesRun",runId,"rule",RULE,"calculationRevision",CALCULATION_REVISION));
var task=new SelfopSyncTask();task.setId(taskId);task.setTaskNo("LXRESTORE-"+UUID.randomUUID().toString().replace("-",""));
task.setSourceAccount(account);task.setDiscoveryScopeId(jdbc.queryForObject("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,account));task.setExecutionPurpose("BUSINESS");
task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType(TYPE);task.setRequestKey("history-build:"+UUID.randomUUID());task.setRequestHash(signature);
task.setScopeHash(anchor.scopeHash());task.setQueryScopeJson(evidence);task.setStatus("RUNNING");task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);task.setCreatedAt(now);task.setUpdatedAt(now);tasks.insert(task);
var run=new SelfopSyncRun();run.setId(runId);run.setTaskId(taskId);run.setSourceAccount(account);
run.setExecutionPurpose("BUSINESS");run.setRunRole("ACCOUNT_DISCOVERY");run.setScopeHash(anchor.scopeHash());run.setScopeSnapshotJson(evidence);run.setFenceToken(0L);run.setAttemptNo(1);
run.setBasePublishedRunId(anchor.id());run.setReferenceSalesRunId(salesRun);run.setStatus("BUILDING");run.setLastPhase("BUILDING");run.setCoverageStatus("COMPLETE");run.setPageCount(0);run.setUniqueObjectCount(0L);
run.setHeartbeatAtUtc(now);run.setCreatedAt(now);run.setUpdatedAt(now);runs.insert(run);
var build=new InventoryHistoryVersions.Build(account,warehouse,anchor.scopeHash(),runId,group,anchor.id(),salesRun,inputHash,evidence,Map.copyOf(heads));
var candidates=new ArrayList<InventoryHistoryVersions.Candidate>();for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1))candidates.add(versions.create(build,day));
jdbc.update("UPDATE ll_selfop_sync_run SET progress_snapshot_json=? WHERE id=?",json(Map.of("candidates",candidates,"expectedHeads",heads,"inputHash",inputHash)),runId);
return new Frozen(build,List.copyOf(candidates),taskId,anchor,reference,from,until,coverage,coverageRuns);
}
private List<Sku> candidates(Frozen f,String afterOwner,String afterSku) {
return jdbc.query("SELECT customer_code,sku_code FROM (SELECT customer_code,sku_code FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=? UNION SELECT customer_code,sku_code FROM ll_selfop_sku_daily_sales WHERE sales_run_id=? AND business_date>=? AND business_date<? UNION SELECT customer_code,sku_code FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? UNION SELECT d.customer_code,d.sku_code FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.source_account=? AND v.warehouse_code=? AND v.head_slot=1) c WHERE customer_code>? OR (customer_code=? AND sku_code>?) ORDER BY customer_code,sku_code LIMIT 100",
(rs,n)->new Sku(rs.getString(1),rs.getString(2)),f.anchor().id(),f.build().sales(),f.from(),f.until(),f.build().account(),f.build().warehouse(),f.build().account(),f.build().warehouse(),afterOwner,afterOwner,afterSku);
}
private List<Anchor> anchors(StockRun run,Sku sku) {
return jdbc.query("SELECT s.product_quality,s.physical_quantity,s.quality_status,s.quality_reason,p.quantity_unit FROM ll_selfop_inventory_snapshot s LEFT JOIN ll_selfop_sku_profile p ON p.profile_run_id=s.snapshot_run_id AND p.customer_code=s.customer_code AND p.sku_code=s.sku_code WHERE s.snapshot_run_id=? AND s.customer_code=? AND s.sku_code=?",
(rs,n)->new Anchor(new Key(sku,rs.getInt(1)),("VALID".equals(rs.getString(3))||Set.of("FIELD_MISSING","NEGATIVE_QUANTITY").contains(Objects.toString(rs.getString(4),"")))?rs.getBigDecimal(2):null,
rs.getString(5),run.from(),run.until(),run.id()),run.id(),sku.owner(),sku.sku());
}
private List<Anchor> referenceAnchors(Frozen f,Sku sku){return anchors(f.reference(),sku);}
private Input input(Frozen f,Sku sku) {
Instant dayStart=f.from().atStartOfDay().toInstant(ZoneOffset.ofHours(8));
Instant referenceStart=f.reference().from().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
Instant evidenceFrom=referenceStart.isBefore(dayStart)?referenceStart:dayStart;
var flows=jdbc.query("SELECT source_flow_id,product_quality,cell_no,inner_batch_no,bill_type,delta_quantity,book_qty,close_quantity,event_at_utc,validation_reason FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND customer_code=? AND sku_code=? AND (event_at_utc IS NULL OR (event_at_utc>=? AND event_at_utc<=?)) ORDER BY event_at_utc,source_flow_id",
(rs,n)->new Flow(rs.getString(1),new Key(sku,rs.getInt(2)),rs.getString(3),rs.getString(4),rs.getString(5),rs.getString(10)==null?rs.getBigDecimal(6):null,rs.getBigDecimal(7),rs.getBigDecimal(8),rs.getTimestamp(9)==null?null:rs.getTimestamp(9).toLocalDateTime().toInstant(ZoneOffset.UTC)),
f.build().account(),f.build().warehouse(),sku.owner(),sku.sku(),utc(evidenceFrom),utc(f.anchor().until()));
return new Input(f.from(),f.until(),Set.of(sku),flows,anchors(f.anchor(),sku),f.coverage());
}
private Object flowEvidence(Frozen f,Sku sku) {
return Map.of("sku",sku,"anchor",f.anchor(),"reference",f.reference(),"flowStages",jdbc.queryForList("SELECT source_flow_id,revision_no,stage_id,validation_reason FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND customer_code=? AND sku_code=? ORDER BY source_flow_id",f.build().account(),f.build().warehouse(),sku.owner(),sku.sku()));
}
static String crosscheck(List<Anchor> current,List<Anchor> prior,List<Flow> flows,List<Interval> coverage) {
if(current.isEmpty()||prior.isEmpty())return "INDEPENDENT_ANCHOR_MISSING";
if(!current.stream().map(Anchor::key).collect(java.util.stream.Collectors.toSet()).equals(prior.stream().map(Anchor::key).collect(java.util.stream.Collectors.toSet())))return "INDEPENDENT_ANCHOR_QUALITY_MISSING";
for(var a:current) {
var p=prior.stream().filter(v->v.key().equals(a.key())).findFirst().orElseThrow();
if(p.quantity()==null||a.quantity()==null||!"PCS".equals(p.unit())||!"PCS".equals(a.unit()))return "INDEPENDENT_ANCHOR_INVALID";
if(!p.observedUntil().isBefore(a.observedFrom())||!covered(coverage,p.observedFrom(),a.observedUntil().plusNanos(1)))return "INDEPENDENT_ANCHOR_COVERAGE_MISSING";
BigDecimal expected=p.quantity();
for(var flow:flows)if(flow.key().equals(a.key())) {
if(flow.at()==null||flow.delta()==null)return "INDEPENDENT_ANCHOR_FLOW_UNKNOWN";
if((!flow.at().isBefore(p.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))&&!flow.at().isAfter(p.observedUntil()))
||(!flow.at().isBefore(a.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))&&!flow.at().isAfter(a.observedUntil())))return "ANCHOR_TIME_AMBIGUOUS";
if(flow.at().isAfter(p.observedUntil())&&flow.at().isBefore(a.observedFrom()))expected=expected.add(flow.delta());
}
if(expected.compareTo(a.quantity())!=0)return "INDEPENDENT_ANCHOR_MISMATCH";
}
return null;
}
private void saveInput(Frozen f,Sku sku,Object evidence,String digest) {
var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(f.build().run());stage.setStageBatchId(f.build().run());stage.setLastFetchRunId(f.build().run());stage.setWarehouseCode(f.build().warehouse());
stage.setObjectType("HISTORY_INPUT");stage.setObjectKeyHash(hash(sku));stage.setContentHash(digest);stage.setPayloadJson(json(evidence));stage.setFirstPageNo(1);stage.setValidationStatus("VALID");stage.setObservedAtUtc(utcNow());stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage);
}
private void requireBuilding(Frozen f){require("BUILDING".equals(jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run WHERE id=? FOR UPDATE",String.class,f.build().run())),"HISTORY_BUILD_LOST");}
private void heartbeat(Frozen f){jdbc.update("UPDATE ll_selfop_sync_run SET heartbeat_at_utc=?,updated_at=? WHERE id=? AND status='BUILDING'",utcNow(),utcNow(),f.build().run());}
private static List<Interval> union(List<Interval> input) {
var result=new ArrayList<Interval>();
for(var next:input.stream().sorted(Comparator.comparing(Interval::from)).toList()) {
if(result.isEmpty()||result.get(result.size()-1).until().isBefore(next.from()))result.add(next);
else {var old=result.remove(result.size()-1);result.add(new Interval(old.from(),old.until().isAfter(next.until())?old.until():next.until()));}
}
return List.copyOf(result);
}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
}
@@ -0,0 +1,192 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
/** Pure reconstruction over frozen, complete source intervals. Never invents absent stock. */
public final class InventoryHistoryCalculator {
public static final String RULE = "FLOW_EOD_V1";
public static final int CALCULATION_REVISION = 2;
private static final Set<String> VERIFIED_TYPES = Set.of("DO", "PA", "IMO", "IAT", "RI");
private static final ZoneOffset BUSINESS = ZoneOffset.ofHours(8);
private InventoryHistoryCalculator() { }
public record Sku(String owner, String sku) { }
public record Key(Sku sku, int quality) { }
public record Interval(Instant from, Instant until) {
public Interval { if (from == null || until == null || !from.isBefore(until)) throw new IllegalArgumentException("INVALID_COVERAGE"); }
}
public record Flow(String id, Key key, String cell, String batch, String type,
BigDecimal delta, BigDecimal bookQuantity, BigDecimal closing, Instant at) { }
public record Anchor(Key key, BigDecimal quantity, String unit, Instant observedFrom, Instant observedUntil, long runId) { }
public record Daily(LocalDate date, Key key, BigDecimal quantity, String status, String reason, long anchorRun) { }
public record Input(LocalDate from, LocalDate until, Set<Sku> candidates, List<Flow> flows,
List<Anchor> anchors, List<Interval> coverage) { }
public static List<Daily> reconstruct(Input input) {
Objects.requireNonNull(input);
if (input.from()==null || input.until()==null || !input.from().isBefore(input.until())
|| input.until().isAfter(input.from().plusDays(366))) throw new IllegalArgumentException("INVALID_HISTORY_WINDOW");
var byKey = new HashMap<Key,List<Flow>>();
var anchors = new HashMap<Key,Anchor>();
var reasons = new HashMap<Key,String>();
var ids = new HashMap<String,Flow>();
var keys = new HashSet<Key>();
for (Anchor a : input.anchors()) {
requireKey(a.key()); keys.add(a.key());
if (anchors.putIfAbsent(a.key(),a)!=null) reasons.put(a.key(),"ANCHOR_DUPLICATE");
}
for (Flow f : input.flows()) {
requireKey(f.key()); keys.add(f.key());
if (f.id()==null || f.id().isBlank() || f.id().length()>64) { reasons.put(f.key(),"FLOW_ID_INVALID"); continue; }
Flow old=ids.putIfAbsent(f.id(),f);
if (old!=null) {
if (!old.equals(f)) { reasons.put(old.key(),"FLOW_ID_CONFLICT"); reasons.put(f.key(),"FLOW_ID_CONFLICT"); }
continue;
}
byKey.computeIfAbsent(f.key(),k->new ArrayList<>()).add(f);
}
var observedSkus=new HashSet<Sku>();for(var key:keys)observedSkus.add(key.sku());
for (Sku sku : input.candidates()) if (!observedSkus.contains(sku)) keys.add(new Key(sku,0));
var result = new ArrayList<Daily>();
var ordered = keys.stream().sorted(Comparator.comparing((Key k)->k.sku().owner()).thenComparing(k->k.sku().sku()).thenComparingInt(Key::quality)).toList();
for (Key key : ordered) {
Anchor a=anchors.get(key);
List<Flow> rows=byKey.getOrDefault(key,List.of());
String failure=reasons.get(key);
if (failure==null) failure=validateAnchor(a);
if (failure==null && key.quality()!=0 && key.quality()!=1) failure="UNKNOWN_QUALITY";
if (failure==null) failure=validateRows(rows,a);
for (LocalDate day=input.from();day.isBefore(input.until());day=day.plusDays(1)) {
Instant boundary=day.plusDays(1).atStartOfDay().toInstant(BUSINESS);
String reason=failure;
if (reason==null && a.observedFrom().isBefore(boundary)) reason="ANCHOR_BEFORE_DAY_END";
if (reason==null && !covered(input.coverage(),boundary,a.observedUntil().plusNanos(1))) reason="FLOW_COVERAGE_GAP";
BigDecimal quantity=null;
if (reason==null) {
quantity=a.quantity();
for (Flow f:rows) if (!f.at().isBefore(boundary) && f.at().isBefore(a.observedFrom())) quantity=quantity.subtract(f.delta());
if (quantity.signum()<0) { quantity=null; reason="NEGATIVE_RECONSTRUCTED_STOCK"; }
}
result.add(new Daily(day,key,quantity,reason==null?"VALID":"UNKNOWN",reason,a==null?0:a.runId()));
}
}
return List.copyOf(result);
}
private static String validateAnchor(Anchor a) {
if (a==null) return "ANCHOR_MISSING";
if (!"PCS".equals(a.unit())) return "UNIT_UNKNOWN";
if (!quantity(a.quantity()) || a.quantity().signum()<0) return "ANCHOR_QUANTITY_INVALID";
if (a.runId()<=0 || a.observedFrom()==null || a.observedUntil()==null || a.observedUntil().isBefore(a.observedFrom())) return "ANCHOR_INTERVAL_INVALID";
return null;
}
private static String validateRows(List<Flow> rows, Anchor anchor) {
var chains=new HashMap<List<String>,List<Flow>>();
for (Flow f:rows) {
if (f.at()==null || !quantity(f.delta()) || !quantity(f.bookQuantity()) || !quantity(f.closing())) return "FLOW_QUANTITY_OR_TIME_INVALID";
if (f.type()==null || !VERIFIED_TYPES.contains(f.type())) return "FLOW_TYPE_UNVERIFIED";
if (f.delta().compareTo(f.bookQuantity())!=0) return "FLOW_QUANTITY_CONFLICT";
if (f.closing().signum()<0 || f.closing().subtract(f.delta()).signum()<0) return "NEGATIVE_CHAIN_STOCK";
if (f.cell()==null || f.cell().isBlank() || f.batch()==null || f.batch().isBlank()) return "CHAIN_IDENTITY_MISSING";
// Source events have second resolution; an event in the anchor's first second is ambiguous.
if (!f.at().isBefore(anchor.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))
&& !f.at().isAfter(anchor.observedUntil())) return "ANCHOR_TIME_AMBIGUOUS";
chains.computeIfAbsent(List.of(f.cell(),f.batch()),k->new ArrayList<>()).add(f);
}
for (var chain:chains.values()) {
var seconds=new TreeMap<Instant,List<Flow>>();
for(Flow f:chain) seconds.computeIfAbsent(f.at(),k->new ArrayList<>()).add(f);
BigDecimal prior=null;
for(var group:seconds.values()) {
var resolved=resolveChain(group,prior);
if(resolved.reason()!=null) return resolved.reason();
prior=resolved.ending();
}
}
var deltas=new TreeMap<Instant,BigDecimal>(Comparator.reverseOrder());
for(var row:rows)if(row.at().isBefore(anchor.observedFrom()))deltas.merge(row.at(),row.delta(),BigDecimal::add);
BigDecimal balance=anchor.quantity();
for(var delta:deltas.values()) {
balance=balance.subtract(delta);
if(balance.signum()<0)return "NEGATIVE_RECONSTRUCTED_STOCK";
}
return null;
}
private record ChainResult(BigDecimal ending,String reason) { }
private static ChainResult resolveChain(List<Flow> group,BigDecimal prior) {
// Every flow is a distinct directed edge: opening balance -> closing balance.
// A valid order is an Euler trail, using every edge exactly once.
var degree=new TreeMap<BigDecimal,Integer>();
for(var f:group) {
degree.merge(f.closing().subtract(f.delta()),1,Integer::sum);
degree.merge(f.closing(),-1,Integer::sum);
}
BigDecimal start=null;int starts=0,ends=0;
for(var entry:degree.entrySet()) {
int difference=entry.getValue();
if(difference==1){start=entry.getKey();starts++;}
else if(difference==-1)ends++;
else if(difference!=0)return new ChainResult(null,"CHAIN_DISCONTINUITY");
}
if(!((starts==1&&ends==1)||(starts==0&&ends==0)))return new ChainResult(null,"CHAIN_DISCONTINUITY");
if(prior!=null) {
if(start!=null&&start.compareTo(prior)!=0)return new ChainResult(null,"CHAIN_DISCONTINUITY");
start=prior;
} else if(start==null)start=group.get(0).closing().subtract(group.get(0).delta());
var first=orderedTrail(group,start,false);
if(first==null)return new ChainResult(null,"CHAIN_DISCONTINUITY");
// Without a prior balance a circuit with multiple edges admits rotated orders.
if(prior==null&&starts==0&&group.size()>1)return new ChainResult(null,"CHAIN_ORDER_AMBIGUOUS");
// Hierholzer with opposite edge priorities yields the lexicographically first
// and last complete trails. Equality proves uniqueness, including parallel edges.
// Input indices only define opposing priorities; neither IDs nor response order
// are accepted as chronology. Two iterative passes avoid factorial search/recursion.
var last=orderedTrail(group,start,true);
if(!Arrays.equals(first,last))return new ChainResult(null,"CHAIN_ORDER_AMBIGUOUS");
return new ChainResult(group.get(first[first.length-1]).closing(),null);
}
private static int[] orderedTrail(List<Flow> group,BigDecimal start,boolean reverse) {
var outgoing=new TreeMap<BigDecimal,ArrayDeque<Integer>>();
for(int i=0;i<group.size();i++) {
var f=group.get(i);
outgoing.computeIfAbsent(f.closing().subtract(f.delta()),k->new ArrayDeque<>()).addLast(i);
}
var stack=new ArrayDeque<Integer>();
int[] trail=new int[group.size()];int position=trail.length;
BigDecimal balance=start;
while(true) {
var edges=outgoing.get(balance);
if(edges!=null&&!edges.isEmpty()) {
int edge=reverse?edges.removeLast():edges.removeFirst();
stack.push(edge);balance=group.get(edge).closing();
} else {
if(stack.isEmpty())break;
int edge=stack.pop();trail[--position]=edge;
var f=group.get(edge);balance=f.closing().subtract(f.delta());
}
}
if(position!=0)return null; // Disconnected edges cannot be silently discarded.
balance=start;
for(int edge:trail) {
var f=group.get(edge);
if(balance.add(f.delta()).compareTo(f.closing())!=0)return null;
balance=f.closing();
}
return trail;
}
public static boolean covered(List<Interval> intervals,Instant from,Instant until) {
Instant cursor=from;
for(Interval i:intervals.stream().sorted(Comparator.comparing(Interval::from)).toList()) {
if(i.until().isBefore(cursor)) continue;
if(i.from().isAfter(cursor)) return false;
if(i.until().isAfter(cursor)) cursor=i.until();
if(!cursor.isBefore(until)) return true;
}
return false;
}
private static boolean quantity(BigDecimal value) { return value!=null && value.scale()<=6 && value.precision()-value.scale()<=14; }
private static void requireKey(Key k) {
if(k==null || k.sku()==null || k.sku().owner()==null || k.sku().owner().isBlank() || k.sku().sku()==null || k.sku().sku().isBlank())
throw new IllegalArgumentException("HISTORY_IDENTITY_INVALID");
}
}
@@ -0,0 +1,100 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.Timestamp;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
/** Historical day heads have their own publication protocol, separate from raw stock runs. */
@Repository
public class InventoryHistoryVersions {
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
public InventoryHistoryVersions(JdbcTemplate jdbc,TransactionTemplate tx) { this.jdbc=jdbc;this.tx=tx; }
public record Build(String account,String warehouse,String scopeHash,long run,long group,long anchor,
Long sales,String inputHash,String evidence,Map<LocalDate,Long> expectedHeads) {
public Build { expectedHeads=Map.copyOf(expectedHeads); }
}
public record Candidate(long id,LocalDate date) { }
public void lockWarehouse(String account,String warehouse) {
var rows=jdbc.queryForList("SELECT id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='INVENTORY' FOR UPDATE",account,warehouse);
if(rows.size()!=1) throw new IllegalStateException("HISTORY_SCOPE_MISSING");
}
/** Monotone revisions and no deletion make this fingerprint stable under the warehouse lock. */
public String factHash(String account,String warehouse) {
return SelfopSupport.hash(jdbc.queryForMap("SELECT COUNT(*) n,COALESCE(SUM(revision_no),0) revisions,COALESCE(MAX(id),0) newest FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=?",account,warehouse));
}
public Map<LocalDate,Long> heads(String account,String warehouse,LocalDate from,LocalDate until) {
var result=new TreeMap<LocalDate,Long>();
jdbc.query("SELECT business_date,id FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND head_slot=1 AND business_date>=? AND business_date<? ORDER BY business_date",
rs->{result.put(rs.getDate(1).toLocalDate(),rs.getLong(2));},account,warehouse,from,until);
return result;
}
public Candidate create(Build build,LocalDate date) {
long id=IdWorker.getId();
jdbc.update("INSERT INTO ll_selfop_inventory_day_version (id,source_account,warehouse_code,business_date,scope_hash,rule_version,build_run_id,publication_group_id,sales_run_id,anchor_run_id,input_hash,evidence_json,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'BUILDING',?)",
id,build.account(),build.warehouse(),date,build.scopeHash(),RULE,build.run(),build.group(),build.sales(),build.anchor(),build.inputHash(),build.evidence(),now());
return new Candidate(id,date);
}
/** Bounded insert transaction; a failed batch never changes visible day heads. */
public void append(Candidate candidate,List<Daily> rows,String evidenceHash) {
if(rows.size()>500 || rows.stream().anyMatch(r->!candidate.date().equals(r.date()))) throw new IllegalArgumentException("HISTORY_BATCH_INVALID");
tx.executeWithoutResult(status->{
String state=jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=? FOR UPDATE",String.class,candidate.id());
if(!"BUILDING".equals(state)) throw new IllegalStateException("HISTORY_CANDIDATE_IMMUTABLE");
jdbc.batchUpdate("INSERT INTO ll_selfop_inventory_daily (day_version_id,customer_code,sku_code,product_quality,physical_quantity,quality_status,quality_reason,anchor_reference,evidence_hash,source) VALUES (?,?,?,?,?,?,?,?,?,'FLOW_RECONSTRUCTED')",
rows,500,(p,r)->{p.setLong(1,candidate.id());p.setString(2,r.key().sku().owner());p.setString(3,r.key().sku().sku());p.setInt(4,r.key().quality());
p.setBigDecimal(5,r.quantity());p.setString(6,r.status());p.setString(7,r.reason());
if(r.anchorRun()==0)p.setNull(8,java.sql.Types.BIGINT);else p.setLong(8,r.anchorRun());p.setString(9,evidenceHash);});
});
}
public void ready(Candidate candidate,long expectedSkuCount) {
tx.executeWithoutResult(s->{
String state=jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=? FOR UPDATE",String.class,candidate.id());
if(!"BUILDING".equals(state)) throw new IllegalStateException("HISTORY_CANDIDATE_IMMUTABLE");
var totals=jdbc.queryForMap("SELECT COUNT(*) total,COALESCE(SUM(valid),0) valid FROM (SELECT customer_code,sku_code,MIN(CASE WHEN quality_status='VALID' THEN 1 ELSE 0 END) valid FROM ll_selfop_inventory_daily WHERE day_version_id=? GROUP BY customer_code,sku_code) x",candidate.id());
long count=((Number)totals.get("total")).longValue(),valid=((Number)totals.get("valid")).longValue();
if(count!=expectedSkuCount) throw new IllegalStateException("HISTORY_CANDIDATE_INCOMPLETE");
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='READY',valid_sku_count=?,unknown_sku_count=? WHERE id=?",valid,count-valid,candidate.id());
});
}
/** Must be called in the same warehouse-locked transaction that accepts a source revision. */
public void invalidate(String account,String warehouse,long run,String reason) {
jdbc.update("UPDATE ll_selfop_inventory_day_version SET invalidated_at=COALESCE(invalidated_at,?),invalidation_reason=COALESCE(invalidation_reason,?),rebuild_run_id=? WHERE source_account=? AND warehouse_code=? AND status IN ('PUBLISHED','SUPERSEDED')",
now(),reason,run,account,warehouse);
}
public void publish(Build build,List<Candidate> candidates) {
if(candidates.isEmpty() || candidates.size()>366 || candidates.stream().map(Candidate::date).distinct().count()!=candidates.size()) throw new IllegalArgumentException("HISTORY_GROUP_INVALID");
tx.executeWithoutResult(s->{
lockWarehouse(build.account(),build.warehouse());
var stored=jdbc.queryForList("SELECT id,business_date,status FROM ll_selfop_inventory_day_version WHERE publication_group_id=? AND build_run_id=? AND source_account=? AND warehouse_code=? ORDER BY business_date FOR UPDATE",build.group(),build.run(),build.account(),build.warehouse());
var expected=candidates.stream().map(Candidate::id).collect(java.util.stream.Collectors.toSet());
if(stored.size()!=candidates.size() || stored.stream().anyMatch(r->!expected.contains(((Number)r.get("id")).longValue()))) throw new IllegalStateException("HISTORY_GROUP_INCOMPLETE");
for(var row:stored) {
var candidate=candidates.stream().filter(c->c.id()==((Number)row.get("id")).longValue()).findFirst().orElseThrow();
if(!candidate.date().toString().equals(row.get("business_date").toString()))throw new IllegalStateException("HISTORY_GROUP_DATE_MISMATCH");
}
if(stored.stream().allMatch(r->"PUBLISHED".equals(r.get("status")))) return;
if(!build.scopeHash().equals(jdbc.queryForObject("SELECT scope_hash FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='INVENTORY'",String.class,build.account(),build.warehouse())))throw new IllegalStateException("HISTORY_SCOPE_CHANGED");
if(!build.inputHash().equals(factHash(build.account(),build.warehouse()))) throw new IllegalStateException("HISTORY_INPUT_CHANGED");
if(stored.stream().anyMatch(r->!"READY".equals(r.get("status")))) throw new IllegalStateException("HISTORY_GROUP_NOT_READY");
for(var c:candidates) {
var heads=jdbc.queryForList("SELECT id FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND business_date=? AND head_slot=1",Long.class,build.account(),build.warehouse(),c.date());
Long old=heads.isEmpty()?null:heads.get(0);
if(!Objects.equals(old,build.expectedHeads().get(c.date()))) throw new IllegalStateException("HISTORY_HEAD_CHANGED");
}
var at=now();
for(var c:candidates) {
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='SUPERSEDED',head_slot=NULL,superseded_at=? WHERE source_account=? AND warehouse_code=? AND business_date=? AND head_slot=1",at,build.account(),build.warehouse(),c.date());
int updated=jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='PUBLISHED',head_slot=1,published_at=? WHERE id=? AND status='READY' AND invalidated_at IS NULL AND input_hash=?",at,c.id(),build.inputHash());
if(updated!=1) throw new IllegalStateException("HISTORY_CANDIDATE_CHANGED");
}
});
}
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.*;
import java.time.temporal.TemporalAdjusters;
import java.util.concurrent.*;
/** Two bounded lanes: provider pages never wait behind local reconstruction or the sales scheduler. */
@Component
public class InventoryHistoryWorker {
private static final Logger LOG=LoggerFactory.getLogger(InventoryHistoryWorker.class);
private final SelfopAnalyticsProperties config;private final InventoryFlowCollector collector;
private final InventoryFlowFacts facts;private final InventoryHistoryBuilder builder;private final JdbcTemplate jdbc;
private final ScheduledExecutorService http=Executors.newSingleThreadScheduledExecutor(r->{var t=new Thread(r,"selfop-history-pages");t.setDaemon(true);return t;});
private final ScheduledExecutorService local=Executors.newSingleThreadScheduledExecutor(r->{var t=new Thread(r,"selfop-history-local");t.setDaemon(true);return t;});
private Instant nextPlan=Instant.EPOCH;
private Instant nextBuild=Instant.EPOCH;
public InventoryHistoryWorker(SelfopAnalyticsProperties config,InventoryFlowCollector collector,InventoryFlowFacts facts,InventoryHistoryBuilder builder,JdbcTemplate jdbc){this.config=config;this.collector=collector;this.facts=facts;this.builder=builder;this.jdbc=jdbc;}
boolean enabled(){return config.isInventoryHistoryEnabled()&&SelfopCollectionPolicy.continuousBusiness(config)&&"PCS".equals(config.getQuantityUnit())&&"Asia/Shanghai".equals(config.getSourceQueryTimezone());}
@PostConstruct public void start(){http.scheduleWithFixedDelay(()->guard(this::page),10,1,TimeUnit.SECONDS);local.scheduleWithFixedDelay(()->guard(this::local),12000,100,TimeUnit.MILLISECONDS);}
@PreDestroy public void close(){http.shutdownNow();local.shutdownNow();}
private void guard(Runnable action){if(!enabled())return;try{action.run();}catch(RuntimeException e){LOG.warn("Historical inventory worker deferred ({})",e.getClass().getSimpleName());}}
void page(){
if(Instant.now().isAfter(nextPlan)){plan();nextPlan=Instant.now().plusSeconds(60);}
collector.page();
}
void local(){
facts.mergeNext(config.getSourceAccount());
if(Instant.now().isBefore(nextBuild))return;
nextBuild=Instant.now().plusSeconds(5);
for(String warehouse:config.getWarehouses().keySet())if(enabled())builder.build(config.getSourceAccount(),warehouse,config.getInventoryHistoryDays());
}
void plan(){
var now=LocalDateTime.now(ZoneOffset.ofHours(8));if(now.toLocalTime().isBefore(LocalTime.of(0,5)))return;
var ids=jdbc.queryForList("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,config.getSourceAccount());if(ids.size()!=1)return;
LocalDate today=now.toLocalDate();
for(String warehouse:config.getWarehouses().keySet()) {
var anchor=builder.anchor(config.getSourceAccount(),warehouse,0);if(anchor==null)continue;
long published=jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND head_slot=1 AND business_date>=? AND business_date<?",Long.class,config.getSourceAccount(),warehouse,today.minusDays(30),today);
int days=published<30?30:Math.max(30,Math.min(60,config.getInventoryHistoryDays()));
for(int age=1;age<=days;age++) {
LocalDate day=today.minusDays(age);
LocalDate round=age<=3?today:age<=30?today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)):LocalDate.of(2000,1,1);
collector.plan(ids.get(0),new InventoryFlowSlice(warehouse,day.atStartOfDay(),day.plusDays(1).atStartOfDay(),1000),round);
}
var end=LocalDateTime.ofInstant(anchor.until(),ZoneOffset.ofHours(8)).withNano(0).plusSeconds(1);
if(end.toLocalDate().equals(today)&&end.isAfter(today.atStartOfDay()))collector.plan(ids.get(0),new InventoryFlowSlice(warehouse,today.atStartOfDay(),end,1000),today);
}
}
}
@@ -15,11 +15,11 @@ public interface SelfopCoordinationMapper {
SelfopSyncTask lockTask(long id); SelfopSyncTask lockTask(long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun lockRun(long id); 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}") @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND task_type NOT IN ('INVENTORY_FLOW_PROBE','INVENTORY_FLOW_HISTORY','INVENTORY_HISTORY_BUILD') 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); List<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose);
// Started inventory work may resume across midnight; unstarted historical slots remain excluded. // Started inventory work may resume across midnight; unstarted historical slots remain excluded.
// Publication independently requires the planned day and observation day to match for daily samples. // Publication independently requires the planned day and observation day to match for daily samples.
@Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") @Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.task_type NOT IN ('INVENTORY_FLOW_HISTORY','INVENTORY_HISTORY_BUILD') AND t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}")
List<SelfopSyncTask> dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account); List<SelfopSyncTask> dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account);
@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}))") @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); long barriers(@Param("account") String account, @Param("warehouse") String warehouse);
@@ -15,6 +15,9 @@ public class SelfopAnalyticsProperties {
private String collectionMode = "OFF"; private String collectionMode = "OFF";
/** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */ /** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */
private boolean continuousBusinessCollectionEnabled = false; private boolean continuousBusinessCollectionEnabled = false;
/** Requires the history tables and both services from the same release. */
private boolean inventoryHistoryEnabled = false;
private int inventoryHistoryDays = 60;
private String environment = "test"; private String environment = "test";
private String exclusiveGrantId; private String exclusiveGrantId;
private LocalDateTime exclusiveGrantExpiresAtUtc; private LocalDateTime exclusiveGrantExpiresAtUtc;
@@ -37,6 +37,17 @@ public final class SelfopCollectionPolicy {
if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null
||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false; ||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false;
String key=task.getRequestKey();if(key==null)return false; String key=task.getRequestKey();if(key==null)return false;
if(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task.getTaskType())) {
try {
var slice=read(task.getQueryScopeJson(),com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.class);
return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&task.getDiscoveryScopeId()!=null&&task.getScopeId()==null
&&task.getWarehouseCode()==null&&task.getPlannedAtUtc()!=null&&task.getId().equals(task.getBusinessRootTaskId())
&&task.getScopeHash().equals(SelfopSupport.hash(slice))&&task.getRequestHash().equals(task.getScopeHash())
&&key.equals("continuous:history:"+task.getPlannedAtUtc().toLocalDate()+":"+task.getScopeHash())
&&java.time.LocalDateTime.ofInstant(slice.from(),java.time.ZoneOffset.UTC).equals(task.getQueryFromUtc())
&&java.time.LocalDateTime.ofInstant(slice.until(),java.time.ZoneOffset.UTC).equals(task.getQueryUntilUtc());
}catch(RuntimeException invalid){return false;}
}
if("MASTER_DATA".equals(task.getTaskType())) { if("MASTER_DATA".equals(task.getTaskType())) {
if(!"ACCOUNT_DISCOVERY".equals(task.getTaskRole())||task.getDiscoveryScopeId()==null||task.getScopeId()!=null if(!"ACCOUNT_DISCOVERY".equals(task.getTaskRole())||task.getDiscoveryScopeId()==null||task.getScopeId()!=null
||task.getWarehouseCode()!=null||task.getPlannedAtUtc()==null||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null)return false; ||task.getWarehouseCode()!=null||task.getPlannedAtUtc()==null||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null)return false;
@@ -9,11 +9,13 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
/** Tokens carry evidence, not authority: all versions and missing dates are reconstructed at asOf. */ /** Signed evidence fixes exact committed day versions; corrections explicitly revoke their validity. */
@Component @Component
public class InventoryTurnoverContext { public class InventoryTurnoverContext {
static final ZoneId ZONE=ZoneId.of("Asia/Shanghai"); static final ZoneId ZONE=ZoneId.of("Asia/Shanghai");
static final String RULE="DAILY_SCHEDULED_FIRST_V1"; static final String RULE="FLOW_EOD_V1";
// A service restart expires open pages/exports, which must request a fresh consistent snapshot.
private static final byte[] TOKEN_KEY=new java.security.SecureRandom().generateSeed(32);
private final AnalyticsReadMapper analytics; private final AnalyticsReadMapper analytics;
private final InventoryTurnoverReadMapper mapper; private final InventoryTurnoverReadMapper mapper;
private final ObjectMapper json; private final ObjectMapper json;
@@ -28,7 +30,7 @@ public class InventoryTurnoverContext {
} }
public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf, public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf,
Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild, Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild,
String samplesHash,LocalDate accumulationStartedOn) {} String samplesHash,LocalDate accumulationStartedOn,String rule,List<Sample> versions) {}
record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {} record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {}
Frozen resolve(InventoryTurnoverRequest q) { Frozen resolve(InventoryTurnoverRequest q) {
Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate()); Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate());
@@ -37,39 +39,59 @@ public class InventoryTurnoverContext {
if(asOf==null||asOf.isAfter(now)||!now.isBefore(asOf.plusSeconds(1800))) throw refresh(); if(asOf==null||asOf.isAfter(now)||!now.isBefore(asOf.plusSeconds(1800))) throw refresh();
LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate()); LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate());
if(supplied!=null&&(!Objects.equals(q.sourceAccount(),supplied.account())||!Objects.equals(q.warehouseCode(),supplied.warehouse()) if(supplied!=null&&(!Objects.equals(q.sourceAccount(),supplied.account())||!Objects.equals(q.warehouseCode(),supplied.warehouse())
||!end.equals(supplied.end())||q.days()!=supplied.days())) throw refresh(); ||!end.equals(supplied.end())||q.days()!=supplied.days()||!RULE.equals(supplied.rule())||supplied.versions()==null)) throw refresh();
var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode()); var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode());
var sales=version(scopes,"OUTBOUND",asOf); var stock=version(scopes,"INVENTORY",asOf); var sales=supplied==null?version(scopes,"OUTBOUND",asOf):fixed(supplied.salesRun());
var stock=supplied==null?version(scopes,"INVENTORY",asOf):fixed(supplied.inventoryRun());
var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20, var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20,
sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf);
// Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway.
var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC),ownerDirectory).context(aq,true,false,false,null); var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC),ownerDirectory).context(aq,true,false,false,null);
String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null); String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null);
var mapping=new TreeMap<LocalDate,Long>(); var mapping=new TreeMap<LocalDate,Long>();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1))) { List<InventoryTurnoverReadMapper.DayVersion> selected;
if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end) if(supplied==null)selected=mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1));
||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); else {
if(supplied.versions().size()>366||supplied.versions().stream().anyMatch(v->v==null||v.dayVersionId()==null||v.dayVersionId()<=0))throw refresh();
selected=supplied.versions().isEmpty()?List.of():mapper.fixedSamples(q.sourceAccount(),q.warehouseCode(),inventoryHash,supplied.versions().stream().map(Sample::dayVersionId).toList());
if(!selected.stream().map(v->new Sample(v.sampleDate(),v.dayVersionId())).toList().equals(supplied.versions()))throw refresh();
}
for(var sample:selected) {
if(sample.sampleDate()==null||sample.dayVersionId()==null||sample.dayVersionId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end)
||mapping.put(sample.sampleDate(),sample.dayVersionId())!=null) throw refresh();
} }
List<Sample> samples=new ArrayList<>(); List<Sample> samples=new ArrayList<>();
for(LocalDate date=end.minusDays(Math.max(30,q.days())-1);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); for(LocalDate date=end.minusDays(Math.max(30,q.days())-1);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date)));
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)); var began=supplied==null?mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)):supplied.accumulationStartedOn();
var c=snapshot.context(); var c=snapshot.context();
var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(), var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(),
c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began); c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began,RULE,samples.stream().filter(v->v.dayVersionId()!=null).toList());
if(supplied!=null&&!supplied.equals(evidence)) throw refresh(); if(supplied!=null&&!supplied.equals(evidence)) throw refresh();
return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c, return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c,
c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.runId()==null).map(Sample::date).toList(),began),snapshot); c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.dayVersionId()==null).map(Sample::date).toList(),began),snapshot);
} }
private AnalyticsReadRows.Run fixed(Long id){if(id==null||id==0)return null;var run=analytics.publishedRun(id);if(run==null)throw refresh();return run;}
private AnalyticsReadRows.Run version(List<AnalyticsReadRows.Scope> scopes,String family,Instant asOf) { private AnalyticsReadRows.Run version(List<AnalyticsReadRows.Scope> scopes,String family,Instant asOf) {
var scope=scopes.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null); var scope=scopes.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null);
return scope==null?null:mapper.versionAt(scope.id(),scope.scopeHash(),utc(asOf)); return scope==null?null:mapper.versionAt(scope.id(),scope.scopeHash(),utc(asOf));
} }
private Evidence decode(String token) { private Evidence decode(String token) {
try {var value=json.readValue(Base64.getUrlDecoder().decode(token),Evidence.class);if(value==null)throw refresh();return value;} catch(Exception e){throw refresh();} try {
if(token.length()>16000)throw refresh();var parts=token.split("\\.",-1);if(parts.length!=2)throw refresh();
byte[] compressed=Base64.getUrlDecoder().decode(parts[0]);
if(!java.security.MessageDigest.isEqual(sign(compressed),Base64.getUrlDecoder().decode(parts[1])))throw refresh();
try(var gzip=new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(compressed))){
byte[] bytes=gzip.readNBytes(65537);if(bytes.length>65536)throw refresh();
var value=json.readValue(bytes,Evidence.class);if(value==null)throw refresh();return value;
}
} catch(Exception e){throw refresh();}
} }
private String encode(Evidence evidence) { private String encode(Evidence evidence) {
try{return Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(evidence));}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);} try{var output=new java.io.ByteArrayOutputStream();try(var gzip=new java.util.zip.GZIPOutputStream(output)){gzip.write(json.writeValueAsBytes(evidence));}
byte[] data=output.toByteArray();var b64=Base64.getUrlEncoder().withoutPadding();return b64.encodeToString(data)+"."+b64.encodeToString(sign(data));
}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);}
} }
private static byte[] sign(byte[] bytes)throws java.security.GeneralSecurityException{var mac=javax.crypto.Mac.getInstance("HmacSHA256");mac.init(new javax.crypto.spec.SecretKeySpec(TOKEN_KEY,"HmacSHA256"));return mac.doFinal(bytes);}
private String samplesHash(List<Sample> samples) { private String samplesHash(List<Sample> samples) {
// Reconstruct the entire ordered calendar at asOf before comparing its digest. // Reconstruct the entire ordered calendar at asOf before comparing its digest.
// Keep the GET pagination token bounded even for a full year of samples. // Keep the GET pagination token bounded even for a full year of samples.
@@ -0,0 +1,133 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.export.ExportContracts.*;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
/** Download-center projection: the browser supplies column keys, never headings or cell values. */
@Service
public class InventoryTurnoverExport {
public static final String TYPE = "SELFOP_INVENTORY_TURNOVER";
private final InventoryTurnoverQueryService queries;
private final ObjectMapper json;
public InventoryTurnoverExport(InventoryTurnoverQueryService queries, ObjectMapper json) { this.queries=queries; this.json=json; }
private static final Map<String,String> LABELS = Map.ofEntries(
Map.entry("sku","SKU"), Map.entry("productName","商品名称"), Map.entry("customerCode","所属货主"),
Map.entry("physicalQuantity","实物件数"), Map.entry("availableQuantity","正品可售"), Map.entry("transitQuantity","在途"),
Map.entry("frozenQuantity","冻结件数"), Map.entry("defectiveQuantity","次品实物"), Map.entry("physicalSkuCount","有实物SKU数"),
Map.entry("inventoryVolumeM3","库存立方数"), Map.entry("outboundVolumesM3","出库立方数"), Map.entry("unitVolumeM3","单件体积"),
Map.entry("outbound7","7日出库"), Map.entry("outbound15","15日出库"), Map.entry("outbound30","30日出库"),
Map.entry("averageDailyOutboundQuantity","日均"), Map.entry("inventoryCoverageDays","预计可售天数"),
Map.entry("quantityTurnoverRate","期间周转率"), Map.entry("quantityTurnoverCount","期间件数周转"),
Map.entry("averagePhysicalQuantity","期间平均实物"), Map.entry("lastObservedOutboundDate","最近已观测出库日"),
Map.entry("noOutboundInWindow","无出库SKU / 实物"), Map.entry("slowCoverage","慢消化SKU / 待判断"),
Map.entry("period","统计期间"), Map.entry("periodOutbound","期间出库件数"), Map.entry("historySource","历史库存来源"),
Map.entry("historyDays","合格历史库存天数"), Map.entry("historyReason","历史库存说明"));
private static final List<String> REQUIRED = List.of("period","periodOutbound","averagePhysicalQuantity","quantityTurnoverRate","historySource","historyDays","historyReason");
public ExportBatchResponse batch(ExportBatchRequest request, int offset, String signature) {
if(request.limit()>100) throw invalid("库存周转每批最多100条");
Map<String,Object> parameters=new LinkedHashMap<>(request.parameters()==null?Map.of():request.parameters());
Object rawColumns=parameters.remove("columns"), rawTotal=parameters.remove("expectedTotal");
if(!(rawColumns instanceof List<?> selected)||selected.isEmpty()||selected.size()>LABELS.size()) throw invalid("导出列无效");
Set<String> keys=new LinkedHashSet<>();
for(Object key:selected) if(!(key instanceof String s)||!LABELS.containsKey(s)||!keys.add(s)) throw invalid("导出列重复或不支持");
keys.addAll(REQUIRED);
long expected;
try { expected=new BigDecimal(String.valueOf(rawTotal)).longValueExact(); }
catch(RuntimeException e) { throw invalid("导出条数无效"); }
if(expected<0||expected>200000||offset>expected) throw invalid("导出条数超出范围");
if(parameters.containsKey("current")||parameters.containsKey("size")||parameters.containsKey("includeOverview")) throw invalid("导出不接受列表分页参数");
InventoryTurnoverRequest.validateParameters(parameters.keySet());
parameters.put("current",offset/request.limit()+1);parameters.put("size",request.limit());parameters.put("includeOverview",false);
InventoryTurnoverRequest query;
try { query=json.convertValue(parameters,InventoryTurnoverRequest.class); }
catch(IllegalArgumentException e) { throw invalid("导出筛选无效"); }
if(query.snapshotContext()==null||query.snapshotContext().isBlank()||query.endDate()==null||query.baseWindowDays()==null) throw invalid("请先查询后导出");
boolean owner="OWNER".equals(query.group());
if(owner&&(keys.contains("sku")||keys.contains("productName")||keys.contains("unitVolumeM3")||keys.contains("inventoryCoverageDays"))
||!owner&&(keys.contains("physicalSkuCount")||keys.contains("noOutboundInWindow")||keys.contains("slowCoverage"))) throw invalid("导出列不适用于当前分组");
Response result=queries.query(query);
if(result.total()!=expected||!query.snapshotContext().equals(result.context().snapshotContext())
||result.rows().size()!=Math.min(request.limit(),expected-offset)) throw invalid("导出数据范围或版本已变化,请整体刷新后重新导出");
Set<List<String>> seen=new HashSet<>();
for(Row row:result.rows()) if(!seen.add(Arrays.asList(row.customerCode(),row.sku()))) throw invalid("导出数据重复");
List<ExportColumn> columns=keys.stream().map(key->new ExportColumn(key,title(key,query),ExportColumnType.STRING,24)).toList();
List<Map<String,Object>> rows=result.rows().stream().map(row->{
Map<String,Object> values=new LinkedHashMap<>();for(String key:keys)values.put(key,cell(row,key,query));return values;
}).toList();
String hash;
try { hash="sha256:"+HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(columns))); }
catch(Exception e) { throw new IllegalStateException("Cannot hash turnover export columns",e); }
long next=offset+rows.size();
return new ExportBatchResponse(1,hash,signature,request.snapshotToken()==null?columns:List.of(),rows,String.valueOf(next),next<expected,request.snapshotToken()==null?expected:null);
}
private static String title(String key,InventoryTurnoverRequest q) {
if(key.equals("outboundVolumesM3"))return q.days()+"日出库立方数";
if(key.equals("averageDailyOutboundQuantity"))return q.days()+"日日均";
return LABELS.get(key);
}
static String cell(Row r,String key,InventoryTurnoverRequest q) {
return switch(key) {
case "sku" -> text(r.sku()); case "productName" -> text(r.productName());
case "customerCode" -> r.customerName()==null||r.customerName().isBlank()?text(r.customerCode()):r.customerName();
case "physicalQuantity" -> total(r.physicalQuantity()); case "availableQuantity" -> total(r.availableQuantity());
case "transitQuantity" -> total(r.transitQuantity()); case "frozenQuantity" -> total(r.frozenQuantity()); case "defectiveQuantity" -> total(r.defectiveQuantity());
case "physicalSkuCount" -> number(r.physicalSkuCount(),2);
case "inventoryVolumeM3" -> volume(r.inventoryVolumeM3(),false);
case "outboundVolumesM3" -> volume(r.outboundVolumesM3().stream().filter(v->v.days()==q.days()).map(VolumeWindow::volume).findFirst().orElse(null),true);
case "unitVolumeM3" -> number(r.unitVolumeM3(),9)+(r.unitVolumeM3()==null?"":" m³")+";"+switch(text(r.dimensionSource())) { case "WAREHOUSE_MEASURED" -> "仓库实测尺寸";case "PRODUCT_DECLARED" -> "商品尺寸估算";default -> "缺少有效尺寸"; };
case "outbound7","outbound15","outbound30" -> window(r,Integer.parseInt(key.substring(8)));
case "periodOutbound" -> window(r,q.days());
case "averageDailyOutboundQuantity" -> number(r.averageDailyOutboundQuantity(),2);
case "inventoryCoverageDays" -> number(r.inventoryCoverageDays(),1)+";"+reason(r.coverageStatus());
case "averagePhysicalQuantity" -> number(r.averagePhysicalQuantity(),2);
case "quantityTurnoverRate" -> r.quantityTurnoverCount()==null?"—":number(r.quantityTurnoverCount().multiply(BigDecimal.valueOf(100)),2)+"%";
case "quantityTurnoverCount" -> r.quantityTurnoverCount()==null?"—":number(r.quantityTurnoverCount(),2)+" 次/"+q.days()+"日";
case "period" -> "截至 "+q.endDate()+",最近 "+q.days()+" 天";
case "historySource" -> "库存流水还原(日末实物库存)";
case "historyDays" -> r.qualifiedSampleDays()+"/"+r.requiredSampleDays();
case "historyReason" -> reasons(r.sampleReasonCodes());
case "lastObservedOutboundDate" -> r.lastObservedOutboundDate()==null?"已采集历史内未见出库":r.lastObservedOutboundDate().toString();
case "noOutboundInWindow" -> number(r.noOutboundInWindow().matchedSkuCount(),2)+" SKU / "+number(r.noOutboundInWindow().knownPhysicalQuantity(),2)+" 件;待判断 "+r.noOutboundInWindow().unknownSkuCount()+" SKU";
case "slowCoverage" -> q.coverageDaysThreshold()==null?"未设查询阈值":number(r.slowCoverage().matchedSkuCount(),2)+" SKU / "+r.slowCoverage().unknownSkuCount()+" SKU";
default -> throw invalid("不支持的导出列");
};
}
static String number(BigDecimal value,int decimals) {
if(value==null)return "—";
BigDecimal threshold=BigDecimal.ONE.movePointLeft(decimals);
if(value.signum()>0&&value.compareTo(threshold)<0)return "<"+threshold.setScale(decimals).toPlainString();
var f=new java.text.DecimalFormat("#,##0"+(decimals>0?"."+"#".repeat(decimals):""),java.text.DecimalFormatSymbols.getInstance(Locale.ROOT));
f.setRoundingMode(RoundingMode.HALF_UP);return f.format(value);
}
private static String total(Total t) {
if(t.value()!=null)return number(t.value(),2);
if(t.knownSubtotal()==null&&t.unknownSkuCount()==0)return reasons(t.reasonCodes());
return "已知 "+number(t.knownSubtotal(),2)+";"+t.unknownSkuCount()+" SKU待核验";
}
private static String window(Row r,int days) {
return r.windows().stream().filter(w->w.days()==days).findFirst().map(w->"COMPLETE".equals(w.dataStatus())?number(w.outboundQuantity(),2):"已采集 "+number(w.observedQuantity(),2)+",覆盖不足").orElse("—");
}
private static String volume(Volume v,boolean outbound) {
if(v==null)return "—;数量或尺寸待确认";
List<String> notes=new ArrayList<>();
if(v.value()==null)notes.add(v.knownSubtotal()==null?"暂无已知小计":"已知小计 "+number(v.knownSubtotal(),6)+" m³");
if(v.missingDimensionSkuCount()>0)notes.add("缺尺寸 "+v.missingDimensionSkuCount()+" SKU / "+number(v.missingDimensionQuantity(),2)+" 件");
if(v.unknownQuantitySkuCount()>0)notes.add("数量待确认 "+v.unknownQuantitySkuCount()+" SKU");
if(v.declaredDimensionQuantity()!=null&&v.declaredDimensionQuantity().signum()>0)notes.add("其中 "+number(v.declaredDimensionQuantity(),2)+" 件按商品尺寸估算");
if(outbound)notes.add("按当前商品尺寸估算");
else if(v.value()!=null&&v.value().signum()==0)notes.add("已知体积为0");
else if(v.value()!=null&&(v.declaredDimensionQuantity()==null||v.declaredDimensionQuantity().signum()==0))notes.add("按仓库实测尺寸计算");
return number(v.value(),6)+(v.value()==null?"":" m³")+";"+(notes.isEmpty()?"数量或尺寸待确认":String.join(";",notes));
}
private static String reasons(List<String> reasons){return reasons==null||reasons.isEmpty()?"—":String.join("、",reasons.stream().map(InventoryTurnoverExport::reason).toList());}
private static String reason(String reason){return TurnoverExportReasons.LABELS.getOrDefault(reason==null?"":reason,"数据待核验");}
private static String text(String value){return value==null||value.isBlank()?"—":value;}
private static BizException invalid(String message){return new BizException(message);}
}
@@ -77,11 +77,16 @@ public final class InventoryTurnoverPolicy {
if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK"); if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK");
if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK"); if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK");
if(count(r,"unassessable")>0)attention.add("UNASSESSABLE"); if(count(r,"unassessable")>0)attention.add("UNASSESSABLE");
var sampleReasons=new LinkedHashSet<String>();
if(!"VALID".equals(sampleStatus)) {
sampleReasons.add(sampleStatus);String historyReasons=text(r,"history_reasons");
if(historyReasons!=null&&!historyReasons.isBlank())sampleReasons.addAll(Arrays.asList(historyReasons.split(",")));
}
String last=text(r,"last_outbound"); String last=text(r,"last_outbound");
return new Row(text(r,"customer_code"),text(r,"customer_name"),owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available, return new Row(text(r,"customer_code"),text(r,"customer_name"),owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available,
total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO, total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO,
windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing), windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing),
"VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), List.copyOf(sampleReasons),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention),
risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"), risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"),
owner?null:decimal(r,"unit_volume"),owner?null:text(r,"dimension_source"),volume(r,"physical"),volumeWindows(r,context.endDate().plusDays(1),q.windows())); owner?null:decimal(r,"unit_volume"),owner?null:text(r,"dimension_source"),volume(r,"physical"),volumeWindows(r,context.endDate().plusDays(1),q.windows()));
} }
@@ -8,29 +8,22 @@ import org.apache.ibatis.annotations.*;
@Mapper @Mapper
public interface InventoryTurnoverReadMapper { public interface InventoryTurnoverReadMapper {
record SampleRun(LocalDate sampleDate,Long runId) {} record DayVersion(LocalDate sampleDate,Long dayVersionId) {}
@Select("SELECT "+AnalyticsReadMapper.RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} " @Select("SELECT "+AnalyticsReadMapper.RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} "
+"AND scope_hash=#{scopeHash} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS' " +"AND scope_hash=#{scopeHash} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS' "
+"AND published_at_utc<=#{asOf} ORDER BY published_at_utc DESC,id DESC LIMIT 1") +"AND published_at_utc<=#{asOf} ORDER BY published_at_utc DESC,id DESC LIMIT 1")
AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
// The immutable first-daily marker is set only by publication. Recheck its original schedule evidence. String HISTORY_WHERE=" source_account=#{account} AND warehouse_code=#{warehouse} AND scope_hash=#{scopeHash,jdbcType=VARCHAR} "
String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} " +"AND rule_version='FLOW_EOD_V1' AND status IN ('PUBLISHED','SUPERSEDED') AND invalidated_at IS NULL ";
+"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' " @Select("SELECT business_date sample_date,id day_version_id FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE
+"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' " +"AND head_slot=1 AND published_at<=#{asOf} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date")
+"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' " List<DayVersion> samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,
+"AND t.task_role='WAREHOUSE_SYNC' AND t.scope_hash=r.scope_hash "
+"AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code AND r.page_count>=1 AND r.source_total_count>=0 "
+"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc "
+"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_finished_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date ";
@Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r "
+"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE
+"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date")
List<SampleRun> samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,
@Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until); @Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until);
@Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE) @Select("<script>SELECT business_date sample_date,id day_version_id FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE
+"AND id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY business_date</script>")
List<DayVersion> fixedSamples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("ids") List<Long> ids);
@Select("SELECT MIN(business_date) FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE+" AND head_slot=1 AND published_at<=#{asOf}")
LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
@SelectProvider(type=InventoryTurnoverSql.class,method="page") @SelectProvider(type=InventoryTurnoverSql.class,method="page")
@@ -8,14 +8,15 @@ import org.springframework.format.annotation.DateTimeFormat;
/** Separate from DETAIL: its dates, windows and filters must never be silently mixed. */ /** Separate from DETAIL: its dates, windows and filters must never be silently mixed. */
public record InventoryTurnoverRequest(String sourceAccount, String warehouseCode, public record InventoryTurnoverRequest(String sourceAccount, String warehouseCode,
@DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays,
String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold, String groupBy, java.util.List<String> customerCodes, String sku, String focus, Integer coverageDaysThreshold,
String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview, String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview,
String snapshotContext) { String snapshotContext, Boolean hideZeroInventory) {
private static final Set<String> PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays", private static final Set<String> PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays",
"groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext"); "groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext","hideZeroInventory");
public static void validateParameters(Set<String> keys) { public static void validateParameters(Set<String> keys) {
if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数"); if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数");
} }
public boolean hideZero(){return !Boolean.FALSE.equals(hideZeroInventory);}
public int days(){return baseWindowDays==null?30:baseWindowDays;} public int days(){return baseWindowDays==null?30:baseWindowDays;}
public java.util.List<Integer> windows(){return java.util.stream.Stream.of(7,15,30,days()).distinct().sorted().toList();} public java.util.List<Integer> windows(){return java.util.stream.Stream.of(7,15,30,days()).distinct().sorted().toList();}
public String group(){return groupBy==null?"SKU":groupBy;} public String group(){return groupBy==null?"SKU":groupBy;}
@@ -28,7 +29,10 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod
public LocalDate end(LocalDate today){return endDate==null?today.minusDays(1):endDate;} public LocalDate end(LocalDate today){return endDate==null?today.minusDays(1):endDate;}
public void validate(LocalDate today) { public void validate(LocalDate today) {
AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库"); AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库");
if(customerCode!=null) AnalyticsQuery.text(customerCode,64,"货主"); if(customerCodes!=null) {
if(customerCodes.size()>100) throw new BizException("最多选择100个货主");
for(String code:customerCodes) AnalyticsQuery.text(code,64,"货主");
}
if(sku!=null) AnalyticsQuery.text(sku,128,"SKU"); if(sku!=null) AnalyticsQuery.text(sku,128,"SKU");
if(days()<1||days()>366) throw new BizException("统计区间须为1至366个完整自然日"); if(days()<1||days()>366) throw new BizException("统计区间须为1至366个完整自然日");
if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期"); if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期");
@@ -23,7 +23,7 @@ public final class InventoryTurnoverResponses {
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate, List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate,
List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount, List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount,
BigDecimal unitVolumeM3,String dimensionSource,Volume inventoryVolumeM3,List<VolumeWindow> outboundVolumesM3) {} BigDecimal unitVolumeM3,String dimensionSource,Volume inventoryVolumeM3,List<VolumeWindow> outboundVolumesM3) {}
public record Sample(LocalDate date,Long runId) {} public record Sample(LocalDate date,Long dayVersionId) {}
public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate, public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate,
int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext, int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext,
Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion, Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion,
@@ -6,7 +6,7 @@ import java.util.*;
/** Every value is bound. Only validated enums select SQL fragments; no source order/remote API reads. */ /** Every value is bound. Only validated enums select SQL fragments; no source order/remote API reads. */
public final class InventoryTurnoverSql { public final class InventoryTurnoverSql {
private InventoryTurnoverSql() {} private InventoryTurnoverSql() {}
public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {} public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long historyVersionId) {}
public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh, public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh,
boolean salesKnown,List<Day> days,LocalDateTime queryAsOf) { boolean salesKnown,List<Day> days,LocalDateTime queryAsOf) {
public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);} public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);}
@@ -31,52 +31,57 @@ public final class InventoryTurnoverSql {
var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales()); var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales());
boolean trusted=frozen.snapshot().sourceKnown()&&(publishedHistory||!frozen.response().analyticsContext().rebuildRequired()); boolean trusted=frozen.snapshot().sourceKnown()&&(publishedHistory||!frozen.response().analyticsContext().rebuildRequired());
var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8), var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8),
!trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList(); !trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.dayVersionId())).toList();
return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(), return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(),
"VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days,LocalDateTime.ofInstant(frozen.response().queryAsOf(),ZoneOffset.UTC)); "VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days,LocalDateTime.ofInstant(frozen.response().queryAsOf(),ZoneOffset.UTC));
} }
private static String windowStart(int n){return "#{p.from"+(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base")+"}";} private static String windowStart(int n){return "#{p.from"+(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base")+"}";}
private static String scope(String alias) { private static String scope(Input p,String alias) {
return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}" String base=alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}";
+" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})"; var owners=p.query.customerCodes();
if(owners==null||owners.isEmpty())return base;
var values=new ArrayList<String>();
for(int i=0;i<owners.size();i++)values.add("#{p.query.customerCodes["+i+"],jdbcType=VARCHAR}");
return base+" AND "+alias+"customer_code IN ("+String.join(",",values)+")";
} }
private static String valid(String field){return "product_quality IN (0,1) AND "+field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} private static String valid(String field){return "product_quality IN (0,1) AND "+field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";}
private static String calendar(Input p) { private static String calendar(Input p) {
var parts=new ArrayList<String>(); var parts=new ArrayList<String>();
for(int i=0;i<p.days().size();i++) parts.add("SELECT CAST(#{p.days["+i+"].date} AS DATE) business_date,CAST(#{p.days["+i+"].startUtc} AS DATETIME) start_utc," for(int i=0;i<p.days().size();i++) parts.add("SELECT CAST(#{p.days["+i+"].date} AS DATE) business_date,CAST(#{p.days["+i+"].startUtc} AS DATETIME) start_utc,"
+"CAST(#{p.days["+i+"].coverage} AS SIGNED) coverage,CAST(#{p.days["+i+"].sampleRun,jdbcType=BIGINT} AS SIGNED) sample_run"); +"CAST(#{p.days["+i+"].coverage} AS SIGNED) coverage,CAST(#{p.days["+i+"].historyVersionId,jdbcType=BIGINT} AS SIGNED) sample_run");
return String.join(" UNION ALL ",parts); return String.join(" UNION ALL ",parts);
} }
private static String sampleRuns(Input p) { private static String historyVersions(Input p) {
// The frozen context already resolved these versions. Bind them directly instead // The frozen context already resolved these versions. Bind them directly instead
// of repeatedly expanding the 30-day calendar as a membership subquery. // of repeatedly expanding the 30-day calendar as a membership subquery.
var runs=new ArrayList<String>(); var runs=new ArrayList<String>();
for(int i=0;i<p.days().size();i++) if(p.days().get(i).sampleRun()!=null) for(int i=0;i<p.days().size();i++) if(p.days().get(i).historyVersionId()!=null)
runs.add("#{p.days["+i+"].sampleRun,jdbcType=BIGINT}"); runs.add("#{p.days["+i+"].historyVersionId,jdbcType=BIGINT}");
return runs.isEmpty()?"NULL":String.join(",",runs); return runs.isEmpty()?"NULL":String.join(",",runs);
} }
private static String salesDays() { private static String salesDays(Input p) {
return "SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE " return "SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE "
+scope("")+" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date<CAST(#{p.until} AS DATE) GROUP BY customer_code,sku_code,business_date"; +scope(p,"")+" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date<CAST(#{p.until} AS DATE) GROUP BY customer_code,sku_code,business_date";
} }
private static String cte(Input p) { private static String cte(Input p) {
String stocks="SELECT snapshot_run_id,customer_code,sku_code,"+amount("physical_quantity")+" physical," String stocks="SELECT snapshot_run_id,customer_code,sku_code,"+amount("physical_quantity")+" physical,"
+amount("transit_quantity")+" transit,"+amount("frozen_quantity")+" frozen,"+AnalyticsInventorySql.availableQuantity(valid("available_quantity"))+" available," +amount("transit_quantity")+" transit,"+amount("frozen_quantity")+" frozen,"+AnalyticsInventorySql.availableQuantity(valid("available_quantity"))+" available,"
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) OR product_quality IS NULL THEN 0 WHEN product_quality=1 AND NOT ("+valid("physical_quantity")+") THEN 0 ELSE 1 END)=1 " +"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) OR product_quality IS NULL THEN 0 WHEN product_quality=1 AND NOT ("+valid("physical_quantity")+") THEN 0 ELSE 1 END)=1 "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective " +"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective "
+"FROM ll_selfop_inventory_snapshot WHERE "+scope("")+" AND (snapshot_run_id=#{p.inventoryRun} OR snapshot_run_id IN ("+sampleRuns(p)+")) " +"FROM ll_selfop_inventory_snapshot WHERE "+scope(p,"")+" AND snapshot_run_id=#{p.inventoryRun} "
+"GROUP BY snapshot_run_id,customer_code,sku_code"; +"GROUP BY snapshot_run_id,customer_code,sku_code";
String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope("")+" AND (profile_run_id IN (#{p.salesRun},#{p.inventoryRun}) OR profile_run_id IN ("+sampleRuns(p)+"))"; String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope(p,"")+" AND profile_run_id IN (#{p.salesRun},#{p.inventoryRun})";
StringBuilder sql=new StringBuilder("WITH calendar AS (").append(calendar(p)).append("), fixed_profiles AS (").append(profiles) String history="SELECT d.day_version_id,d.customer_code,d.sku_code,CASE WHEN MIN(CASE WHEN d.quality_status='VALID' AND d.product_quality IN (0,1) AND d.physical_quantity>=0 THEN 1 ELSE 0 END)=1 THEN SUM(d.physical_quantity) END physical,GROUP_CONCAT(DISTINCT d.quality_reason SEPARATOR ',') reason FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.source_account=#{p.query.sourceAccount} AND v.warehouse_code=#{p.query.warehouseCode} AND v.rule_version='FLOW_EOD_V1' AND v.invalidated_at IS NULL AND "+scope(p,"d.").replace("d.source_account=#{p.query.sourceAccount} AND d.warehouse_code=#{p.query.warehouseCode}","1=1")+" AND d.day_version_id IN ("+historyVersions(p)+") GROUP BY d.day_version_id,d.customer_code,d.sku_code";
.append("), stocks AS (").append(stocks).append("), sales_days AS (").append(salesDays()).append("), candidates AS (") StringBuilder sql=new StringBuilder("WITH calendar AS (").append(calendar(p)).append("), history AS (").append(history).append("), fixed_profiles AS (").append(profiles)
.append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days), profiles AS (") .append("), stocks AS (").append(stocks).append("), sales_days AS (").append(salesDays(p)).append("), candidates AS (")
.append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days UNION SELECT customer_code,sku_code FROM history), profiles AS (")
.append("SELECT c.customer_code,c.sku_code,COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN f.product_name END),MAX(f.product_name)) product_name,") .append("SELECT c.customer_code,c.sku_code,COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN f.product_name END),MAX(f.product_name)) product_name,")
.append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END)) unit_valid,") .append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.profile_run_id IS NULL THEN NULL WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END),CASE WHEN EXISTS (SELECT 1 FROM history h WHERE h.customer_code=c.customer_code AND h.sku_code=c.sku_code AND h.physical IS NOT NULL) THEN 1 ELSE 0 END) unit_valid,")
.append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ") .append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ")
.append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)") .append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)")
.append(InventoryVolumeSql.products()).append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ") .append(InventoryVolumeSql.products()).append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ")
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); .append(scope(p,"")).append(" AND sales_run_id=#{p.salesRun} AND business_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)");
sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid,pv.unit_volume,COALESCE(pv.dimension_source,'MISSING') dimension_source"); sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid,pv.unit_volume,COALESCE(pv.dimension_source,'MISSING') dimension_source");
for(int n:p.query().windows()) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix); for(int n:p.query().windows()) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix);
sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound"); sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound");
@@ -84,7 +89,7 @@ public final class InventoryTurnoverSql {
sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ") sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ")
.append("LEFT JOIN product_volumes pv ON pv.customer_code=c.customer_code AND pv.sku_code=c.sku_code LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,") .append("LEFT JOIN product_volumes pv ON pv.customer_code=c.customer_code AND pv.sku_code=c.sku_code LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,")
.append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,") .append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,")
.append("CASE WHEN sample_total>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,") .append("CASE WHEN sample_total>0 THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,")
.append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,") .append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,")
.append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (") .append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (")
.append("SELECT b.*").append(InventoryVolumeSql.sku(p)).append(",CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w") .append("SELECT b.*").append(InventoryVolumeSql.sku(p)).append(",CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w")
@@ -123,9 +128,8 @@ public final class InventoryTurnoverSql {
// Read only actual qualified samples at the frozen date/run. Missing candidates // Read only actual qualified samples at the frozen date/run. Missing candidates
// remain in sku_zero_counts; absence never creates a zero-valued sample. // remain in sku_zero_counts; absence never creates a zero-valued sample.
sql.append(" FROM actual_sku_days GROUP BY customer_code,sku_code), sku_sample_days AS (SELECT st.customer_code,st.sku_code,d.business_date,") sql.append(" FROM actual_sku_days GROUP BY customer_code,sku_code), sku_sample_days AS (SELECT st.customer_code,st.sku_code,d.business_date,")
.append("st.physical sample_physical FROM calendar d JOIN stocks st ON st.snapshot_run_id=d.sample_run ") .append("st.physical sample_physical FROM calendar d JOIN history st ON st.day_version_id=d.sample_run ")
.append("JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=st.customer_code AND ip.sku_code=st.sku_code ") .append("WHERE st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,")
.append("WHERE ip.quantity_unit='PCS' AND st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,")
.append("COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") .append("COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,")
// Keep the qualified sum exact; do not divide again by a rounded daily mean. // Keep the qualified sum exact; do not divide again by a rounded daily mean.
.append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,") .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,")
@@ -142,11 +146,11 @@ public final class InventoryTurnoverSql {
// Qualified date membership supports the full selected window without numeric bit overflow. // Qualified date membership supports the full selected window without numeric bit overflow.
return "GROUP_CONCAT(CASE WHEN sample_physical IS NOT NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',')"; return "GROUP_CONCAT(CASE WHEN sample_physical IS NOT NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',')";
} }
private static String pageMissingDates() { private static String pageMissingDates(Input p) {
// Only the selected page expands dates. Numeric qualification and filtering // Only the selected page expands dates. Numeric qualification and filtering
// already used every candidate and every frozen sample before pagination. // already used every candidate and every frozen sample before pagination.
return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d " return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d "
+"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates FROM page_result r)"; +"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates, (SELECT GROUP_CONCAT(DISTINCT h.reason SEPARATOR ',') FROM history h JOIN calendar d ON d.sample_run=h.day_version_id WHERE h.customer_code=r.customer_code "+("SKU".equals(p.query.group())?"AND h.sku_code=r.sku_code ":"")+"AND d.business_date>=CAST(#{p.baseFrom} AS DATE)) history_reasons FROM page_result r)";
} }
private static String windowAggregates(Input p) { private static String windowAggregates(Input p) {
StringBuilder sql=new StringBuilder(); StringBuilder sql=new StringBuilder();
@@ -209,6 +213,7 @@ public final class InventoryTurnoverSql {
case "UNASSESSABLE" -> "unassessable>0"; case "UNASSESSABLE" -> "unassessable>0";
default -> "1=1"; default -> "1=1";
}; };
if(p.query.hideZero()) predicate+=" AND (physical IS NULL OR physical<>0)";
if(p.query.turnoverStatus()!=null) predicate+=" AND sample_count<#{p.query.days}"; if(p.query.turnoverStatus()!=null) predicate+=" AND sample_count<#{p.query.days}";
if(!owner&&p.query.sku()!=null) predicate+=" AND sku_code=#{p.query.sku}"; if(!owner&&p.query.sku()!=null) predicate+=" AND sku_code=#{p.query.sku}";
return " WHERE "+predicate; return " WHERE "+predicate;
@@ -238,7 +243,7 @@ public final class InventoryTurnoverSql {
} }
public static String page(Map<String,Object> args) { public static String page(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group());
return cte(p)+(owner?ownerCte(p):"")+", page_result AS (SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"+pageMissingDates()+" SELECT * FROM page_missing"+order(p,owner); return cte(p)+(owner?ownerCte(p):"")+", page_result AS (SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"+pageMissingDates(p)+" SELECT * FROM page_missing"+order(p,owner);
} }
public static String count(Map<String,Object> args) { public static String count(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group());
@@ -253,7 +258,7 @@ public final class InventoryTurnoverSql {
+"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code"; +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code";
} }
public static String chartWindows(Map<String,Object> args) { public static String chartWindows(Map<String,Object> args) {
Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays()+"), warehouse_days AS (SELECT d.business_date," Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays(p)+"), warehouse_days AS (SELECT d.business_date,"
+"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete," +"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete,"
+"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed " +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed "
+"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates(p)+" FROM warehouse_days) SELECT w.*"+formalWindows(p)+" FROM warehouse_window_counts w"; +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates(p)+" FROM warehouse_days) SELECT w.*"+formalWindows(p)+" FROM warehouse_window_counts w";
@@ -265,7 +270,7 @@ public final class InventoryTurnoverSql {
public static String report(Map<String,Object> args) { public static String report(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview(); Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview();
String source=owner?"owner_result":"sku_result"; String source=owner?"owner_result":"sku_result";
List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable")); List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","history_reasons","last_outbound","turnover","unassessable"));
for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix); for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix);
fields.addAll(InventoryVolumeSql.fields(p)); fields.addAll(InventoryVolumeSql.fields(p));
if(owner) { if(owner) {
@@ -276,7 +281,7 @@ public final class InventoryTurnoverSql {
// Stock charts share owner totals, independently of list filters and pagination. // Stock charts share owner totals, independently of list filters and pagination.
String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals(p,"customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"") String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals(p,"customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"")
+", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"; +", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})";
sql+=pageMissingDates(); sql+=pageMissingDates(p);
if(overview)sql+=", summary_result AS ("+totals(p,null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)" if(overview)sql+=", summary_result AS ("+totals(p,null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)"
+", top_volume_owners AS (SELECT t.*,ROW_NUMBER() OVER (ORDER BY physical_volume DESC,customer_code) ordinal FROM owner_totals t WHERE physical_volume IS NOT NULL ORDER BY physical_volume DESC,customer_code LIMIT 10)"; +", top_volume_owners AS (SELECT t.*,ROW_NUMBER() OVER (ORDER BY physical_volume DESC,customer_code) ordinal FROM owner_totals t WHERE physical_volume IS NOT NULL ORDER BY physical_volume DESC,customer_code LIMIT 10)";
// Statement-local only: share intermediate relations across report branches; // Statement-local only: share intermediate relations across report branches;
@@ -0,0 +1,154 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.Map;
/** Business explanations retained in server-generated spreadsheets. */
final class TurnoverExportReasons {
private TurnoverExportReasons() {}
static final Map<String,String> LABELS = Map.ofEntries(
Map.entry("COMPLETE","完整"),
Map.entry("PARTIAL","未完整"),
Map.entry("UNKNOWN","未知"),
Map.entry("QUEUED","排队中"),
Map.entry("RUNNING","执行中"),
Map.entry("WAITING","等待中"),
Map.entry("WAITING_UPSTREAM","等待上游"),
Map.entry("WAITING_SOURCE","等待来源数据"),
Map.entry("RETRY_WAIT","待重试"),
Map.entry("YIELDED","预算等待"),
Map.entry("FAILED","失败"),
Map.entry("DEAD","待处理"),
Map.entry("SUCCEEDED","执行成功"),
Map.entry("ROUTED","账号共享采集完成"),
Map.entry("SAMPLE_DONE","测试样本结束"),
Map.entry("PUBLISHED","已发布"),
Map.entry("REUSED","沿用已有结果"),
Map.entry("NOT_PUBLISHED","未发布"),
Map.entry("PENDING","待处理"),
Map.entry("BLOCKED","修订阻断"),
Map.entry("OFF","关闭"),
Map.entry("VALIDATION","验证采样"),
Map.entry("BUSINESS","正式采集"),
Map.entry("FRESH","有效"),
Map.entry("STALE","过期"),
Map.entry("INVALID","异常"),
Map.entry("OBSERVING","观察中"),
Map.entry("HEALTHY","正常"),
Map.entry("STALE_HEARTBEAT","心跳过期,执行状态待确认"),
Map.entry("UNCONFIRMED","执行状态待确认"),
Map.entry("TERMINATED","尝试已结束"),
Map.entry("ABORTED","已停止"),
Map.entry("NOT_STARTED","尚未开始"),
Map.entry("FETCHING","采集中"),
Map.entry("FETCHED","采集完成"),
Map.entry("VALIDATING","校验中"),
Map.entry("BUILDING","计算中"),
Map.entry("READY","待发布"),
Map.entry("INITIALIZING","首采中"),
Map.entry("COVERED","覆盖完整"),
Map.entry("ACCOUNT_DISCOVERY","账号共享出库目录"),
Map.entry("WAREHOUSE_SYNC","本仓采集"),
Map.entry("VALID","有效"),
Map.entry("CURRENT","已核对"),
Map.entry("LAGGING","核对滞后"),
Map.entry("MISSING","尚无可用数据"),
Map.entry("UNCOVERED","覆盖不足"),
Map.entry("INCOMPLETE","未完整"),
Map.entry("REBUILD_REQUIRED","销量待重新统计发布"),
Map.entry("NO_AVAILABLE_INVENTORY","无可售库存"),
Map.entry("UNIT_UNKNOWN_OR_CONFLICT","数量单位未知或不一致"),
Map.entry("SALES_UNTRUSTED","销量待核对"),
Map.entry("COVERAGE_INSUFFICIENT","覆盖不足"),
Map.entry("OBSERVATION_INSUFFICIENT","观察期不足"),
Map.entry("SALES_UNKNOWN","销量未知"),
Map.entry("NO_OUTBOUND_IN_WINDOW","所选窗口无出库"),
Map.entry("NO_OUTBOUND_30_DAYS","近30天无出库"),
Map.entry("INVENTORY_INVALID","库存异常"),
Map.entry("INVENTORY_STALE","库存快照过期"),
Map.entry("INVENTORY_UNKNOWN","库存未知"),
Map.entry("UNASSESSABLE_SKUS","存在待评估SKU"),
Map.entry("INVENTORY_UNAVAILABLE","库存资料不可用"),
Map.entry("SALES_UNAVAILABLE","销量资料不可用"),
Map.entry("PHYSICAL_INVENTORY_UNKNOWN","实物库存待核验"),
Map.entry("AVAILABLE_INVENTORY_UNKNOWN","可售库存待核验"),
Map.entry("SALES_QUANTITY_OR_UNIT_INVALID","出库数量或单位异常"),
Map.entry("NO_ELIGIBLE_SKUS","暂无符合条件的SKU"),
Map.entry("NO_OUTBOUND_30D","近30天无出库"),
Map.entry("COVERAGE_GT_THRESHOLD","可售天数超过本次阈值"),
Map.entry("FROZEN_STOCK","存在冻结库存"),
Map.entry("DEFECTIVE_STOCK","存在次品实物库存"),
Map.entry("UNASSESSABLE","数据不足/待观察"),
Map.entry("THRESHOLD_NOT_SET","未设置查询阈值"),
Map.entry("NO_PHYSICAL_INVENTORY","无实物库存"),
Map.entry("METRIC_DEPENDENCY_UNKNOWN","该指标依赖资料未知"),
Map.entry("SALES_WINDOW_UNAVAILABLE","同期完整销量不足"),
Map.entry("SALES_WINDOW_INCOMPLETE","所选时段销量尚未采齐"),
Map.entry("RISK_DEPENDENCY_UNKNOWN","部分SKU关注判断未知"),
Map.entry("NO_OUTBOUND_IN_COLLECTED_HISTORY","已采集历史内未见出库"),
Map.entry("SALES_NOT_COVERED","销量未覆盖"),
Map.entry("SKU_HISTORY_UNKNOWN","SKU历史未知"),
Map.entry("QUANTITY_UNIT_UNKNOWN","数量单位未知"),
Map.entry("INVENTORY_NOT_PUBLISHED","库存尚未发布"),
Map.entry("SALES_NOT_PUBLISHED","销量尚未发布"),
Map.entry("OBSERVATION_INCOMPLETE","观察期不足"),
Map.entry("SCOPE_CHANGED","配置范围已变化"),
Map.entry("UPSTREAM_NOT_READY","账号共享上游未就绪"),
Map.entry("TASK_NOT_RETRYABLE","任务当前不可恢复"),
Map.entry("COLLECTION_MODE_NOT_BUSINESS","当前不是正式采集模式"),
Map.entry("VALIDATION_NOT_RETRYABLE","验证会话不可恢复"),
Map.entry("ACCOUNT_SHARED_TASK","账号共享任务由协调器恢复"),
Map.entry("SOURCE_TIMEZONE_UNKNOWN","来源时区未确认"),
Map.entry("SALES_DAY_INCOMPLETE","销量日期未完整"),
Map.entry("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN","销量未覆盖或SKU历史未知"),
Map.entry("SCOPE_LEASE_BUSY","本仓任务正在执行"),
Map.entry("COLLECTION_OFF","采集模式关闭"),
Map.entry("COLLECTION_MODE_INVALID","采集模式配置无效"),
Map.entry("BUSINESS_ENVIRONMENT_REJECTED","正式采集只允许生产环境"),
Map.entry("EXECUTION_DISABLED","采集执行未启用"),
Map.entry("WAREHOUSE_NOT_CONFIGURED","仓库未配置"),
Map.entry("TIMEZONE_OR_UNIT_NOT_CONFIRMED","时间或件数口径配置不符合确认值"),
Map.entry("ACCOUNT_PAUSED","账号采集已暂停"),
Map.entry("ACCOUNT_COOLDOWN","账号处于冷却期"),
Map.entry("EXCLUSIVE_GRANT_NOT_VALID","账号独占窗口授权无效"),
Map.entry("DAILY_BUDGET_NOT_CONFIGURED","账号日额度未配置"),
Map.entry("DAILY_BUDGET_EXHAUSTED","账号日额度已用完"),
Map.entry("SALES_MISSING","尚无销量发布版本"),
Map.entry("INVENTORY_MISSING","尚无库存发布版本"),
Map.entry("SALES_BLOCKED","销量修订阻断"),
Map.entry("SALES_STALE","销量数据过期"),
Map.entry("REVISION_REVIEW_UNKNOWN","修订核对状态未知"),
Map.entry("REVISION_REVIEW_LAGGING","修订核对滞后"),
Map.entry("SALES_REBUILD_REQUIRED","销量有变动,待重新统计发布"),
Map.entry("TODAY_INCOMPLETE","今日尚未结束,不能作为完整自然日"),
Map.entry("SALES_SOURCE_TIMEZONE_UNKNOWN","销量来源时区未确认"),
Map.entry("INDEPENDENT_ANCHOR_MISSING","缺少独立库存对账记录"),
Map.entry("INDEPENDENT_ANCHOR_QUALITY_MISSING","独立库存品质记录不完整"),
Map.entry("INDEPENDENT_ANCHOR_INVALID","独立库存记录无效"),
Map.entry("INDEPENDENT_ANCHOR_COVERAGE_MISSING","库存对账区间流水未齐"),
Map.entry("INDEPENDENT_ANCHOR_MISMATCH","流水与独立库存记录不符"),
Map.entry("INDEPENDENT_ANCHOR_FLOW_UNKNOWN","库存对账流水待核验"),
Map.entry("ANCHOR_TIME_AMBIGUOUS","采集库存时发生变动,待稳定库存记录核验"),
Map.entry("ANCHOR_MISSING","缺少库存锚点"),
Map.entry("FLOW_COVERAGE_GAP","库存流水覆盖不完整"),
Map.entry("FLOW_TYPE_UNVERIFIED","流水类型尚未核验"),
Map.entry("CHAIN_DISCONTINUITY","批次结存不连续"),
Map.entry("CHAIN_ORDER_AMBIGUOUS","同秒流水顺序待核验"),
Map.entry("FLOW_QUANTITY_OR_TIME_INVALID","流水数量或时间待核验"),
Map.entry("FLOW_QUANTITY_CONFLICT","流水变动数量不一致"),
Map.entry("NEGATIVE_CHAIN_STOCK","批次结存出现负数"),
Map.entry("NEGATIVE_RECONSTRUCTED_STOCK","还原库存出现负数"),
Map.entry("UNIT_UNKNOWN","库存数量单位待确认"),
Map.entry("ANCHOR_DUPLICATE","库存锚点重复"),
Map.entry("ANCHOR_QUANTITY_INVALID","库存锚点数量无效"),
Map.entry("ANCHOR_INTERVAL_INVALID","库存观测时间无效"),
Map.entry("ANCHOR_BEFORE_DAY_END","库存锚点早于日末"),
Map.entry("FLOW_ID_INVALID","流水标识无效"),
Map.entry("FLOW_ID_CONFLICT","流水标识存在冲突"),
Map.entry("UNKNOWN_QUALITY","库存品质待确认"),
Map.entry("CHAIN_IDENTITY_MISSING","流水库位或批次缺失"),
Map.entry("INSUFFICIENT","合格日末库存记录不足"),
Map.entry("ZERO_AVERAGE_STOCK","期间平均日末库存为0,无法计算周转"),
Map.entry("INVENTORY_UNKNOWN_OR_STALE","当前库存未知、异常或已过期"),
Map.entry("NOT_APPLICABLE","货主不计算整体可售天数"),
Map.entry("SALES_OR_STOCK_UNASSESSABLE","部分商品的销量或库存不足以判断"));
}
@@ -7,7 +7,7 @@ public final class SelfopEgressDtos {
boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint, boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint,
long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds, long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds,
boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){} boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){}
public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS} public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS,INVENTORY_FLOW}
public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){} public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){}
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){} public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){}
@@ -39,6 +39,22 @@ public class SelfopEgressService {
SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null; SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null;
if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}} if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}}
verify(task,run,root,account,now); verify(task,run,root,account,now);
boolean flowOperation=operation==Operation.INVENTORY_FLOW;
boolean history=com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task.getTaskType());
if(history){
if(!flowOperation||!config.isInventoryHistoryEnabled()||!SelfopCollectionPolicy.continuousBusinessRoot(task)
||!SelfopCollectionPolicy.continuousBusiness(config)||pageSize!=1000)throw reject("EGRESS_HISTORY_INVALID");
try {
var slice=SelfopSupport.read(task.getQueryScopeJson(),com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.class);
var checkpoint=SelfopSupport.read(run.getProgressSnapshotJson(),com.fasterxml.jackson.databind.JsonNode.class);
int page=checkpoint.path("nextPage").asInt();
if(!slice.warehouse().equals(warehouseCode)||!config.getWarehouses().containsKey(warehouseCode)
||!slice.requestHash(page).equals(requestHash))throw reject("EGRESS_HISTORY_INVALID");
}catch(EgressRejectedException e){throw e;}catch(RuntimeException invalid){throw reject("EGRESS_HISTORY_INVALID");}
}else if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType())
||flowOperation&&(!"test".equals(config.getEnvironment())||!"BUSINESS".equals(task.getExecutionPurpose())
||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode)
||business==null||business.http!=0||!Objects.equals(task.getRequestHash(),requestHash)))throw reject("EGRESS_FLOW_PROBE_INVALID");
boolean masterOperation=operation==Operation.CUSTOMERS||operation==Operation.PRODUCTS; boolean masterOperation=operation==Operation.CUSTOMERS||operation==Operation.PRODUCTS;
if(masterOperation!=("MASTER_DATA".equals(task.getTaskType())) if(masterOperation!=("MASTER_DATA".equals(task.getTaskType()))
||masterOperation&&(!"BUSINESS".equals(task.getExecutionPurpose())||pageSize<1||pageSize>(operation==Operation.CUSTOMERS?1000:100)))throw reject("EGRESS_OPERATION_TASK_MISMATCH"); ||masterOperation&&(!"BUSINESS".equals(task.getExecutionPurpose())||pageSize<1||pageSize>(operation==Operation.CUSTOMERS?1000:100)))throw reject("EGRESS_OPERATION_TASK_MISMATCH");
@@ -74,6 +90,7 @@ public class SelfopEgressService {
var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pendingForAuthorization(budget,c,request.operation()); var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pendingForAuthorization(budget,c,request.operation());
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);
if(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task==null?null:task.getTaskType())&&!config.isInventoryHistoryEnabled())throw reject("EGRESS_HISTORY_DISABLED");
if(hasBusinessSession(root)){ if(hasBusinessSession(root)){
var b=business(root);verifyBusinessTask(task,root); var b=business(root);verifyBusinessTask(task,root);
// Explicit root stops remain independent of request accounting and short permit expiry. // Explicit root stops remain independent of request accounting and short permit expiry.
@@ -19,6 +19,7 @@ public interface SalesRetentionMapper {
AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff} AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff}
AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL
AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous}) AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous})
AND NOT EXISTS (SELECT 1 FROM ll_selfop_inventory_day_version h WHERE h.sales_run_id=r.id OR h.anchor_run_id=r.id)
AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id) AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id)
OR EXISTS (SELECT 1 FROM ll_selfop_warehouse_daily_sales w WHERE w.sales_run_id=r.id) OR EXISTS (SELECT 1 FROM ll_selfop_warehouse_daily_sales w WHERE w.sales_run_id=r.id)
OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id)) OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id))
@@ -32,10 +33,11 @@ public interface SalesRetentionMapper {
List<SelfopSyncRun> eligible(@Param("scopeId") Long scopeId,@Param("current") Long current, List<SelfopSyncRun> eligible(@Param("scopeId") Long scopeId,@Param("current") Long current,
@Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff); @Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff);
@Select(""" @Select("""
SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id SELECT (SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id
WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse}
AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED')) AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED'))
AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id}) AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id}))
+ (SELECT COUNT(*) FROM ll_selfop_inventory_day_version h WHERE h.sales_run_id=#{id} OR h.anchor_run_id=#{id})
""") """)
long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id); long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id);
@Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}") @Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}")
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** TEST-only, one audited request. No schedule, retry, publication or historical inventory mutation. */
@Service
public class InventoryFlowProbeService {
private final SelfopAnalyticsProperties config;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopOutboundDiscoveryScopeMapper accounts;
private final SelfopLeaseService leases;
private final RestLingxingAnalyticsClient client;
private final TransactionTemplate tx;
public InventoryFlowProbeService(SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,
SelfopOutboundDiscoveryScopeMapper accounts,SelfopLeaseService leases,RestLingxingAnalyticsClient client,TransactionTemplate tx) {
this.config=config;this.tasks=tasks;this.runs=runs;this.accounts=accounts;this.leases=leases;this.client=client;this.tx=tx;
}
public record Request(String requestKey,String warehouseCode,String customerCode,String sku,LocalDateTime startTime,LocalDateTime endTime) {
public Map<String,Object> providerRequest(){return Map.of("warehouseCode",warehouseCode,"customerCode",customerCode,"sku",sku,
"startTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(startTime),
"endTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(endTime));}
}
public record Result(String taskNo,boolean complete,JsonNode page) {}
static void validate(Request q,LocalDateTime now) {
require(q!=null,"SELFOP_FLOW_REQUEST_REQUIRED");identity(q.requestKey(),80);identity(q.warehouseCode(),64);
identity(q.customerCode(),64);identity(q.sku(),128);
require(q.startTime()!=null&&q.endTime()!=null&&q.startTime().isBefore(q.endTime())
&&!q.endTime().isAfter(q.startTime().plusDays(31))&&!q.endTime().isAfter(now),"SELFOP_FLOW_WINDOW_INVALID");
}
public Result probe(Request q,String actor) {
validate(q,utcNow().plusHours(8));
require("test".equals(config.getEnvironment())&&"BUSINESS".equals(config.getCollectionMode())
&&config.isExecutionEnabled()&&config.getWarehouses().containsKey(q.warehouseCode()),"SELFOP_FLOW_PROBE_DISABLED");
String key="flow-probe:"+q.requestKey(),digest=hash(q.providerRequest());
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",config.getSourceAccount()));
require(account!=null,"SELFOP_SCOPE_MISSING");
var lease=leases.acquireDiscovery(account.getId(),"flow-probe-"+UUID.randomUUID());
try {
var existing=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount()).eq("request_key",key));
if(existing!=null) {
require(digest.equals(existing.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");
require("SAMPLE_DONE".equals(existing.getStatus())&&existing.getRoutingManifestJson()!=null,"SELFOP_FLOW_PREVIOUS_ATTEMPT_REQUIRES_REVIEW");
return read(existing.getRoutingManifestJson(),Result.class);
}
var now=utcNow();var task=new SelfopSyncTask();var run=new SelfopSyncRun();
task.setId(IdWorker.getId());task.setTaskNo("LXFLOW-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT));
task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS");task.setTaskRole("ACCOUNT_DISCOVERY");
task.setDiscoveryScopeId(account.getId());task.setTaskType("INVENTORY_FLOW_PROBE");task.setStatus("RUNNING");
task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);task.setRequestKey(key);task.setRequestHash(digest);
task.setScopeHash(digest);task.setQueryScopeJson(json(q.providerRequest()));task.setBusinessRootTaskId(task.getId());
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now));task.setRequestedBy(actor);task.setCreatedAt(now);task.setUpdatedAt(now);
run.setId(IdWorker.getId());run.setTaskId(task.getId());run.setSourceAccount(config.getSourceAccount());run.setExecutionPurpose("BUSINESS");
run.setRunRole("ACCOUNT_DISCOVERY");run.setAttemptNo(1);run.setScopeHash(digest);run.setScopeSnapshotJson(task.getQueryScopeJson());
run.setFenceToken(lease.fence());run.setStatus("FETCHING");run.setLastPhase("FETCHING");run.setFetchStartedAtUtc(now);
run.setCoverageStatus("UNKNOWN");run.setPageCount(0);run.setUniqueObjectCount(0L);run.setCreatedAt(now);run.setUpdatedAt(now);
tx.executeWithoutResult(s->{leases.verify(lease);tasks.insert(task);runs.insert(run);});
try {
JsonNode page=client.inventoryFlow(SelfopRequestContext.forTask(task,run,config),q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.endTime());
boolean complete=validatePage(page);
var result=new Result(task.getTaskNo(),complete,page);
tx.executeWithoutResult(s->{
leases.verify(lease);var done=utcNow();
task.setStatus("SAMPLE_DONE");task.setRoutingManifestJson(json(result));task.setUpdatedAt(done);
run.setStatus("SAMPLE_DONE");run.setLastPhase("INVENTORY_FLOW_PROBE");run.setPageCount(1);
run.setSourceTotalCount(page.path("total").asLong());run.setUniqueObjectCount((long)page.path("records").size());
run.setCoverageStatus(complete?"COMPLETE":"PARTIAL");run.setFetchFinishedAtUtc(done);run.setFinishedAtUtc(done);run.setUpdatedAt(done);
tasks.updateById(task);runs.updateById(run);
});
return result;
} catch(RuntimeException failure) {
String error=failure instanceof com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException c?c.code():
failure instanceof com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException e?e.code():"FLOW_PROBE_FAILED";
tx.executeWithoutResult(s->{leases.verify(lease);task.setStatus("DEAD");task.setUpdatedAt(utcNow());
run.setStatus("FAILED");run.setErrorCode(error);run.setErrorMessage("库存流水诊断失败;不自动重试");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());
tasks.updateById(task);runs.updateById(run);});
throw failure;
}
} finally {leases.release(lease);}
}
static boolean validatePage(JsonNode page) {
require(page!=null&&page.path("records").isArray()&&page.path("current").asInt()==1&&page.path("size").asInt()==1000
&&page.path("total").asLong(-1)>=0,"SELFOP_FLOW_PAGE_INVALID");
long total=page.path("total").asLong();
require(page.path("pages").asLong(-1)==(total==0?0:(total-1)/1000+1)
&&page.path("records").size()==Math.min(1000,total),"SELFOP_FLOW_PAGE_INVALID");
var ids=new HashSet<String>();
for(var row:page.path("records"))require(row.path("id").isTextual()&&!row.path("id").asText().isBlank()
&&ids.add(row.path("id").asText()),"SELFOP_FLOW_DUPLICATE_ID");
return total<=1000;
}
}
@@ -52,7 +52,6 @@ baiyue:
environment: ${DEPLOY_ENV:test} environment: ${DEPLOY_ENV:test}
daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0} daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0}
business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000} business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000}
execution-enabled: ${SELFOP_EXECUTION_ENABLED:false}
sales-publication-enabled: ${SELFOP_SALES_PUBLICATION_ENABLED:false} sales-publication-enabled: ${SELFOP_SALES_PUBLICATION_ENABLED:false}
integration-base-url: http://integration-service:9210 integration-base-url: http://integration-service:9210
internal-service-token: ${INTERNAL_SERVICE_TOKEN:} internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
@@ -3720,7 +3720,7 @@ CREATE TABLE ll_selfop_sync_task (
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
@@ -4004,3 +4004,86 @@ CREATE TABLE ll_selfop_product (
UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code), UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code),
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
-- 历史库存基线;执行前必须取得指定环境的建表授权。
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识',
product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知',
cell_no VARCHAR(128) NULL COMMENT '来源库位编码',
inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号',
bill_no VARCHAR(128) NULL COMMENT '来源业务单号',
source_no VARCHAR(128) NULL COMMENT '来源关联单号',
bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原',
book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空',
book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文',
delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量',
close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存',
source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文',
event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空',
business_date DATE NULL COMMENT '北京时间业务日期',
quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空',
content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要',
revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号',
stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据',
first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试',
last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试',
last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻',
validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知',
validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id),
KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc),
KEY idx_selfop_flow_day (source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
id BIGINT NOT NULL COMMENT '不可变历史日版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
business_date DATE NOT NULL COMMENT '北京时间日末所属日期',
scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要',
rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本',
build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试',
publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识',
sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本',
anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本',
input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要',
evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用',
status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败',
head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空',
valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数',
unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数',
created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻',
published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻',
superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻',
invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻',
invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因',
rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot),
UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date),
KEY idx_selfop_history_group (publication_group_id,status),
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
product_quality INT NOT NULL COMMENT '来源库存品质',
physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零',
quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知',
quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因',
anchor_reference BIGINT NULL COMMENT '独立库存锚点版本',
evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要',
source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原',
PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality),
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
@@ -0,0 +1,82 @@
-- 历史库存基线;执行前必须取得指定环境的建表授权。
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识',
product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知',
cell_no VARCHAR(128) NULL COMMENT '来源库位编码',
inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号',
bill_no VARCHAR(128) NULL COMMENT '来源业务单号',
source_no VARCHAR(128) NULL COMMENT '来源关联单号',
bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原',
book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空',
book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文',
delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量',
close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存',
source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文',
event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空',
business_date DATE NULL COMMENT '北京时间业务日期',
quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空',
content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要',
revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号',
stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据',
first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试',
last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试',
last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻',
validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知',
validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id),
KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc),
KEY idx_selfop_flow_day (source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
id BIGINT NOT NULL COMMENT '不可变历史日版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
business_date DATE NOT NULL COMMENT '北京时间日末所属日期',
scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要',
rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本',
build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试',
publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识',
sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本',
anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本',
input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要',
evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用',
status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败',
head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空',
valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数',
unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数',
created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻',
published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻',
superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻',
invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻',
invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因',
rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot),
UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date),
KEY idx_selfop_history_group (publication_group_id,status),
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
product_quality INT NOT NULL COMMENT '来源库存品质',
physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零',
quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知',
quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因',
anchor_reference BIGINT NULL COMMENT '独立库存锚点版本',
evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要',
source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原',
PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality),
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
@@ -10,6 +10,9 @@ info:
每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。 每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。
snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。 snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。
TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。 TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。
SELFOP_INVENTORY_TURNOVER 每批最多100行;parameters 必须提供库存周转列表的 snapshotContext、expectedTotal 和有序 columns(仅列键)。
它沿用 inventory-turnover 的筛选、排序、固定版本及30分钟有效期,拒绝 current/size/includeOverview;版本失效或条数变化整任务失败,不切换到最新数据。
columns 由服务端白名单解释并追加统计期间、期间出库、平均日末库存、周转率和历史证据;列头与说明由服务端生成。
原 Lastleg 业务 /export CSV 公网接口已移除。文件领取、过期、失败重试由 File 下载中心管理。 原 Lastleg 业务 /export CSV 公网接口已移除。文件领取、过期、失败重试由 File 下载中心管理。
paths: paths:
/internal/exports/v1/{exportType}/batches: /internal/exports/v1/{exportType}/batches:
@@ -25,7 +28,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
enum: [LAST_MILE_ORDER_LIST, OVERSEAS_INVENTORY_BALANCE, OVERSEAS_INVENTORY_LEDGER, OVERSEAS_QUOTE_LIST, MONTHLY_EXCHANGE_RATE_LIST, VEHICLE_LIST, FLEET_QUOTE_LIST, TRIP_OPERATIONS_LIST] enum: [LAST_MILE_ORDER_LIST, OVERSEAS_INVENTORY_BALANCE, OVERSEAS_INVENTORY_LEDGER, OVERSEAS_QUOTE_LIST, MONTHLY_EXCHANGE_RATE_LIST, VEHICLE_LIST, FLEET_QUOTE_LIST, TRIP_OPERATIONS_LIST, SELFOP_INVENTORY_TURNOVER]
requestBody: requestBody:
required: true required: true
content: content:
@@ -21,6 +21,34 @@ info:
重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。
security: [{bearerAuth: []}] security: [{bearerAuth: []}]
paths: paths:
/api/platform/v1/last-leg/self-operated-analytics/inventory-flow-probe:
post:
summary: TEST单SKU库存流水有界诊断
description: >-
仅TEST、BUSINESS、执行开启及配置仓库;PLATFORM、tenantId=0、非代登录、独立platform:self-operated-analytics:sync权限。
每个requestKey最多一次供应商请求、第一页最多1000条、时间范围最多31天且不得未来。
复用现有账号lease/fence、1200ms间隔、permit和消费记录。占用时拒绝,由操作者另行发起,不自动重试。
同键成功重放读取保存结果;同键异参拒绝;失败或结果未知不得重发。任务类型INVENTORY_FLOW_PROBE,成功SAMPLE_DONE,失败DEAD。
complete仅证明本次分页记录已全部返回,不证明历史流水无缺失或库存可还原。只保存诊断结果,不发布销量、库存或日样本。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [requestKey, warehouseCode, customerCode, sku, startTime, endTime]
properties:
requestKey: {type: string, minLength: 1, maxLength: 80}
warehouseCode: {type: string, minLength: 1, maxLength: 64}
customerCode: {type: string, minLength: 1, maxLength: 64}
sku: {type: string, minLength: 1, maxLength: 128}
startTime: {type: string, description: 北京时间本地日期时间}
endTime: {type: string, description: 北京时间本地日期时间}
responses:
'200':
description: ApiResponse;成功data包含taskNo、complete与page(records、total、current、size、pages、observedAt、egress)。流水字段定义见Integration库存流水合同。业务失败保留错误码,不返回伪成功。
'403':
description: 非平台或缺少采集权限
/api/platform/v1/last-leg/self-operated-analytics/owner-directory: /api/platform/v1/last-leg/self-operated-analytics/owner-directory:
get: get:
summary: 完整资料批次中的客户名称与编码 summary: 完整资料批次中的客户名称与编码
@@ -6,12 +6,12 @@ info:
仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。 仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。
仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。 仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。
所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。 所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。
每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据, 每次请求使用REPEATABLE_READ,一致性读固定销量、当前库存及历史日版本映射。
不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、 snapshotContext保存签名压缩的准确版本清单与缺失日期,不作为授权凭据;后续页按准确ID校验
重建状态及全部max(30,baseWindowDays)日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。 账号、仓库、范围及失效标记,不用墙钟时刻重新推导日头。服务重启、校正失效、规则变更要求整体刷新。
历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。 历史分母只使用FLOW_EOD_V1日末全品质实物库存,以完整流水及独立库存锚点还原;不与旧计划样本混算。
上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。 上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。
历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。 历史销量完整性不依赖当前库存新鲜度;缺日保持未知、不缩短周转分母。sampleReasonCodes包含流水或独立库存对账失败原因。dailySamples.dayVersionId表示历史日版本标识,非原始库存run。
security: [{bearerAuth: []}] security: [{bearerAuth: []}]
paths: paths:
/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover: /api/platform/v1/last-leg/self-operated-analytics/inventory-turnover:
@@ -19,7 +19,7 @@ paths:
operationId: inventoryTurnover operationId: inventoryTurnover
summary: 单仓SKU或货主三窗口库存周转 summary: 单仓SKU或货主三窗口库存周转
description: >- description: >-
精确customerCode限定列表与概览;sku、focus及turnoverStatus只筛列表。 customerCodes按所选货主集合限定列表、概览及图表,省略或空集合表示全部;sku、focus、turnoverStatus及hideZeroInventory只筛列表。
按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。 按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。
OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。 OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。
SKU出库及无出库判断同样不受首次观察时间限制:可信完整覆盖的缺销量行可计零;实际异常单位或未覆盖日期保持未知。 SKU出库及无出库判断同样不受首次观察时间限制:可信完整覆盖的缺销量行可计零;实际异常单位或未覆盖日期保持未知。
@@ -32,8 +32,9 @@ paths:
- {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}} - {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}}
- {name: baseWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, default: 30}} - {name: baseWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, default: 30}}
- {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}} - {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}}
- {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} - {name: customerCodes, in: query, style: form, explode: true, description: '货主编码多选,重复键传输;同一编码不重复计数,最多100个', schema: {type: array, maxItems: 100, items: {type: string, minLength: 1, maxLength: 64}}}
- {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}}
- {name: hideZeroInventory, in: query, description: 默认隐藏当前全品质实物件数明确为0的SKU或货主汇总行,保留未知;全量筛选后分页,不改变概览及图表, schema: {type: boolean, default: true}}
- name: focus - name: focus
in: query in: query
schema: {type: string, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL} schema: {type: string, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL}
@@ -142,17 +143,17 @@ components:
analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'} analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'}
inventoryObservedFrom: {type: string, format: date-time, nullable: true} inventoryObservedFrom: {type: string, format: date-time, nullable: true}
inventoryObservedUntil: {type: string, format: date-time, nullable: true} inventoryObservedUntil: {type: string, format: date-time, nullable: true}
sampleRuleVersion: {type: string, enum: [DAILY_SCHEDULED_FIRST_V1]} sampleRuleVersion: {type: string, enum: [FLOW_EOD_V1]}
dailySamples: dailySamples:
type: array type: array
minItems: 30 minItems: 30
maxItems: 366 maxItems: 366
items: items:
type: object type: object
required: [date, runId] required: [date, dayVersionId]
properties: properties:
date: {type: string, format: date} date: {type: string, format: date}
runId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号} dayVersionId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号}
missingSampleDates: {type: array, items: {type: string, format: date}} missingSampleDates: {type: array, items: {type: string, format: date}}
accumulationStartedOn: {type: string, format: date, nullable: true} accumulationStartedOn: {type: string, format: date, nullable: true}
Row: Row:
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.selfoperated.analytics.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.selfoperated.analytics.sync.InventoryFlowProbeService;
import org.junit.jupiter.api.Test;
import java.util.Set;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.security.access.prepost.PreAuthorize;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PlatformInventoryFlowProbeControllerTest {
@Test void probeRequiresRealPlatformSyncPermission() throws Exception {
var service=mock(InventoryFlowProbeService.class);var c=new PlatformInventoryFlowProbeController(service);
var reader=new AuthenticatedUser(1L,0L,0L,1L,"reader","PLATFORM","WEB",Set.of("platform:self-operated-analytics:list"));
var customer=new AuthenticatedUser(1L,1L,1L,1L,"customer","CLIENT","WEB",Set.of("platform:self-operated-analytics:sync"));
assertThrows(ResponseStatusException.class,()->c.probe(null,null));assertThrows(ResponseStatusException.class,()->c.probe(reader,null));
assertThrows(ResponseStatusException.class,()->c.probe(customer,null));verifyNoInteractions(service);
var writer=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
c.probe(writer,null);verify(service).probe(null,"operator");
assertEquals("hasAuthority('platform:self-operated-analytics:sync')",PlatformInventoryFlowProbeController.class
.getMethod("probe",AuthenticatedUser.class,InventoryFlowProbeService.Request.class).getAnnotation(PreAuthorize.class).value());
}
}
@@ -43,10 +43,12 @@ class PlatformInventoryTurnoverControllerTest {
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover") mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover")
.param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12") .param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12")
.param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT") .param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT")
.param("customerCodes","A","B")
.param("coverageDaysThreshold","60").param("current","2").param("size","20") .param("coverageDaysThreshold","60").param("current","2").param("size","20")
.param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk()); .param("hideZeroInventory","false").param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk());
var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture()); var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture());
var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days()); var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days());
assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertEquals("fixed-token",q.snapshotContext()); assertEquals(List.of("A","B"),q.customerCodes());
assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertFalse(q.hideZero());assertEquals("fixed-token",q.snapshotContext());
} }
} }
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class InventoryFlowSliceTest {
private final InventoryFlowSlice slice=new InventoryFlowSlice("W",LocalDateTime.parse("2026-10-14T00:00:00"),LocalDateTime.parse("2026-10-15T00:00:00"),1000);
JsonNode row(String id,String at){return SelfopSupport.read(SelfopSupport.json(Map.of("id",id,"customerCode","C","whCode","W","productSku","SKU","createTime",at)),JsonNode.class);}
JsonNode page(int current,int total,List<JsonNode> rows){return SelfopSupport.read(SelfopSupport.json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);}
@Test void emptyRangeAndInclusiveMidnightArePreserved(){
assertEquals(0,slice.validate(page(1,0,List.of()),1,null,null).total());
assertEquals(1,slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:00"))),1,null,null).rows().size());
assertEquals(Instant.parse("2026-10-14T16:00:01Z"),slice.until());
}
@Test void totalDriftDuplicatesAndOrderingNeverProveCoverage(){
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,2L,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"),row("1","2026-10-14 12:00:00"))),1,null,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,null,LocalDateTime.parse("2026-10-14T13:00:00")));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"))),1,null,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:01"))),1,null,null));
}
@Test void fixedPageHashCannotChangeWarehouseWindowOrPage(){
assertEquals(slice.requestHash(1),SelfopSupport.hash(slice.request(1)));
assertNotEquals(slice.requestHash(1),slice.requestHash(2));
assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end().plusDays(1),1000));
assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end(),100));
}
@Test void acceptsInternalLongStringButRejectsFractionalOrOverflowTotals(){
var response=(com.fasterxml.jackson.databind.node.ObjectNode)page(1,0,List.of());
response.put("total","0");
assertEquals(0,slice.validate(response,1,null,null).total());
for(String invalid:List.of("0.0","-1","9223372036854775808","1e3","")) {
response.put("total",invalid);
assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null));
}
response.put("total",0.5);
assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null));
}
}
@@ -0,0 +1,175 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
class InventoryHistoryCalculatorTest {
private static final Sku SKU=new Sku("OWNER","SKU");
private static final Key GOOD=new Key(SKU,0), BAD=new Key(SKU,1);
private static final LocalDate DAY=LocalDate.of(2026,10,14);
private static Instant at(String local) { return LocalDateTime.parse(local).toInstant(ZoneOffset.ofHours(8)); }
private static BigDecimal n(String n) { return new BigDecimal(n); }
private static Anchor anchor(Key k,String qty) { return new Anchor(k,n(qty),"PCS",at("2026-10-15T08:00:00"),at("2026-10-15T08:00:01"),100); }
private static Flow flow(String id,Key key,String type,String delta,String close,String time) {
return new Flow(id,key,"CELL","BATCH",type,n(delta),n(delta),n(close),at(time));
}
private static Input input(List<Flow> rows,List<Anchor> anchors) {
return new Input(DAY,DAY.plusDays(1),Set.of(SKU),rows,anchors,
List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T08:00:02"))));
}
@Test void reversesTailAndAssignsMidnightToNewDay() {
var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","144","2026-10-15T00:00:00")),List.of(anchor(GOOD,"144"))));
assertThat(r.get(0).quantity()).isEqualByComparingTo("145");
}
@Test void missingTailIsUnknownEvenWhenYesterdayIsComplete() {
var i=input(List.of(),List.of(anchor(GOOD,"144")));
var r=reconstruct(new Input(i.from(),i.until(),i.candidates(),i.flows(),i.anchors(),List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T00:00:00")))));
assertThat(r.get(0).reason()).isEqualTo("FLOW_COVERAGE_GAP");assertThat(r.get(0).quantity()).isNull();
}
@Test void keepsInactiveBatchInSkuAnchor() {
var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","10","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
assertThat(r.get(0).quantity()).isEqualByComparingTo("101");
}
@Test void sameIdAtOverlappingBoundaryIsCountedOnce() {
var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00");
assertThat(reconstruct(input(List.of(f,f),List.of(anchor(GOOD,"9")))).get(0).quantity()).isEqualByComparingTo("10");
}
@Test void sameIdDifferentIdentityRevokesBoth() {
var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00");
var g=flow("f",BAD,"IMO","1","1","2026-10-15T01:00:00");
assertThat(reconstruct(input(List.of(f,g),List.of(anchor(GOOD,"9"),anchor(BAD,"1"))))).allMatch(d->"FLOW_ID_CONFLICT".equals(d.reason()));
}
@Test void qualityConversionPreservesTotalWithoutDroppingEitherSide() {
var r=reconstruct(input(List.of(flow("a",GOOD,"IMO","-3","7","2026-10-15T01:00:00"),flow("b",BAD,"IMO","3","3","2026-10-15T01:00:00")),List.of(anchor(GOOD,"7"),anchor(BAD,"3"))));
assertThat(r.stream().map(Daily::quantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("10");
assertThat(r.get(0).quantity()).isEqualByComparingTo("10");assertThat(r.get(1).quantity()).isZero();
}
@Test void absentAnchorNeverBecomesZero() {
assertThat(reconstruct(input(List.of(),List.of())).get(0).reason()).isEqualTo("ANCHOR_MISSING");
}
@Test void explicitZeroWithCompleteCoverageIsValid() {
var d=reconstruct(input(List.of(),List.of(anchor(GOOD,"0")))).get(0);
assertThat(d.status()).isEqualTo("VALID");assertThat(d.quantity()).isZero();
}
@Test void unverifiedTypeIsNotIgnored() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"BO","1","1","2026-10-15T01:00:00")),List.of(anchor(GOOD,"1")))).get(0).reason()).isEqualTo("FLOW_TYPE_UNVERIFIED");
}
@Test void eventInAnchorObservationIsAmbiguous() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"DO","-1","9","2026-10-15T08:00:00")),List.of(anchor(GOOD,"9")))).get(0).reason()).isEqualTo("ANCHOR_TIME_AMBIGUOUS");
}
@Test void sameSecondOrderUsesBalancesNotIdOrResponseOrder() {
var rows=List.of(flow("1",GOOD,"DO","-1","37","2026-10-15T01:00:00"),flow("9",GOOD,"DO","-1","38","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"37")))).get(0).quantity()).isEqualByComparingTo("39");
}
@Test void brokenChainDoesNotPublish() {
var rows=List.of(flow("1",GOOD,"DO","-1","38","2026-10-15T01:00:00"),flow("2",GOOD,"DO","-1","35","2026-10-15T02:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"35")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
}
@Test void restoresHundredSameSecondDecrementsAcrossSixtyDaysRegardlessOfSourceOrder() {
var group=new ArrayList<Flow>();
for(int i=0;i<100;i++)group.add(flow("F"+(100-i),GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00"));
for(int seed=0;seed<10;seed++) {
Collections.shuffle(group,new Random(seed));
for(boolean hasPrior:List.of(false,true)) {
var rows=new ArrayList<>(group);
if(hasPrior)rows.add(flow("prior",GOOD,"DO","-1","727","2026-10-14T23:59:59"));
var i=input(rows,List.of(anchor(GOOD,"627")));
var result=reconstruct(new Input(DAY.minusDays(59),i.until(),i.candidates(),i.flows(),i.anchors(),
List.of(new Interval(DAY.minusDays(59).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),at("2026-10-15T08:00:02")))));
assertThat(result).hasSize(60).allMatch(d->"VALID".equals(d.status()));
assertThat(result.get(59).quantity()).isEqualByComparingTo("727");
assertThat(result.get(0).quantity()).isEqualByComparingTo(hasPrior?"728":"727");
}
}
}
@Test void forcedCycleBeforeExitIsUniqueDespiteMultipleOutgoingEdgesAndDecimalScales() {
// Taking the exit first is a dead end, not a second complete order.
var rows=List.of(flow("exit",GOOD,"DO","-1.00","0.0","2026-10-15T01:00:00"),
flow("up",GOOD,"PA","1","2.000","2026-10-15T01:00:00"),
flow("down",GOOD,"DO","-1","1.000000","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).status()).isEqualTo("VALID");
}
@Test void twoCyclesWithSameFinalBalanceRemainAmbiguous() {
var rows=List.of(flow("prior",GOOD,"PA","0","2","2026-10-15T00:59:59"),
flow("a",GOOD,"PA","1","3","2026-10-15T01:00:00"),flow("b",GOOD,"DO","-1","2","2026-10-15T01:00:00"),
flow("c",GOOD,"PA","2","4","2026-10-15T01:00:00"),flow("d",GOOD,"DO","-2","2","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS");
}
@Test void distinctIdsWithDuplicateEdgesAreNotDeduplicatedIntoAUniqueOrder() {
var rows=List.of(flow("a",GOOD,"PA","1","2","2026-10-15T01:00:00"),
flow("b",GOOD,"PA","1","2","2026-10-15T01:00:00"),flow("return",GOOD,"DO","-1","1","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS");
}
@Test void disconnectedCycleCannotBeIgnoredEvenWhenDegreesMatch() {
var rows=List.of(flow("path",GOOD,"DO","-1","1","2026-10-15T01:00:00"),
flow("loop",GOOD,"PA","0","3","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
}
@Test void longChainWithMissingEdgeOrWrongPriorRemainsUnknown() {
var rows=new ArrayList<Flow>();
for(int i=0;i<100;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00"));
rows.add(flow("prior",GOOD,"PA","0","728","2026-10-15T00:59:59"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
rows.remove(rows.size()-1);rows.remove(50);
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
}
@Test void largeUniqueChainUsesIterativeTraversal() {
var rows=new ArrayList<Flow>();
for(int i=0;i<20_000;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(20_000-i),"2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"1")))).get(0).quantity()).isEqualByComparingTo("20001");
}
@Test void graphSolverMatchesExhaustiveOrdersForAllSmallMultigraphs() {
// Independent permutation oracle: all multisets of up to five directed edges
// on three balances, including loops/parallel edges, with every possible prior.
for(int size=1;size<=5;size++)checkGraphs(new ArrayList<>(),0,size);
}
private void checkGraphs(List<Integer> edges,int minimum,int remaining) {
if(remaining>0) {
for(int edge=minimum;edge<9;edge++) {
edges.add(edge);checkGraphs(edges,edge,remaining-1);edges.remove(edges.size()-1);
}
return;
}
var group=new ArrayList<Flow>();
for(int i=0;i<edges.size();i++) {
int edge=edges.get(i),opening=edge/3,closing=edge%3;
group.add(flow("F"+i,GOOD,"PA",String.valueOf(closing-opening),String.valueOf(closing),"2026-10-15T01:00:00"));
}
for(int prior=-1;prior<3;prior++) {
var rows=new ArrayList<>(group);
if(prior>=0)rows.add(flow("prior",GOOD,"PA","0",String.valueOf(prior),"2026-10-15T00:59:59"));
int paths=countOrders(group,new boolean[group.size()],prior<0?null:BigDecimal.valueOf(prior),0);
String reason=paths==0?"CHAIN_DISCONTINUITY":paths==1?null:"CHAIN_ORDER_AMBIGUOUS";
var actual=reconstruct(input(rows,List.of(anchor(GOOD,"100")))).get(0);
assertThat(actual.reason()).as("edges=%s prior=%s",edges,prior).isEqualTo(reason);
}
}
private int countOrders(List<Flow> group,boolean[] used,BigDecimal balance,int depth) {
if(depth==group.size())return 1;
int count=0;
for(int i=0;i<group.size();i++)if(!used[i]) {
var f=group.get(i);
if(balance!=null&&balance.add(f.delta()).compareTo(f.closing())!=0)continue;
used[i]=true;count+=countOrders(group,used,f.closing(),depth+1);used[i]=false;
if(count>1)return 2;
}
return count;
}
@Test void laterAnchorRevisionChangesEarlierDay() {
var a=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
var b=reconstruct(input(List.of(flow("f",GOOD,"DO","-2","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
assertThat(a.get(0).quantity()).isEqualByComparingTo("101");assertThat(b.get(0).quantity()).isEqualByComparingTo("102");
}
@Test void negativeSkuBalanceCannotHideBehindPositiveBatchClosing() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"PA","5","5","2026-10-15T01:00:00")),List.of(anchor(GOOD,"3")))).get(0).reason()).isEqualTo("NEGATIVE_RECONSTRUCTED_STOCK");
}
@Test void mergingCoverageDoesNotBridgeGaps() {
var t=Instant.EPOCH;
assertThat(covered(List.of(new Interval(t,t.plusSeconds(2)),new Interval(t.plusSeconds(1),t.plusSeconds(4))),t,t.plusSeconds(4))).isTrue();
assertThat(covered(List.of(new Interval(t,t.plusSeconds(1)),new Interval(t.plusSeconds(2),t.plusSeconds(4))),t,t.plusSeconds(4))).isFalse();
}
}
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.sync.SelfopLeaseService;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.junit.jupiter.api.*;
import java.nio.file.*;
import java.time.*;
import java.math.BigDecimal;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Actual mapper + SQL + transaction pipeline, isolated H2; provider is a bounded fixture. */
public class InventoryHistoryPipelineTest {
JdbcTemplate jdbc;TransactionTemplate tx;SelfopAnalyticsProperties config;SqlSessionTemplate session;
SelfopSyncTaskMapper tasks;SelfopSyncRunMapper runs;SelfopSyncStageMapper stages;SelfopCoordinationMapper locks;
SelfopLeaseService leases;RestLingxingAnalyticsClient client;InventoryHistoryVersions versions;InventoryFlowFacts facts;
InventoryHistoryBuilder builder;InventoryFlowCollector collector;
static final LocalDate DAY=LocalDate.of(2026,10,14);
@BeforeEach void setup()throws Exception {
var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:history_pipeline_"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String ddl=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_selfop_outbound_discovery_scope","ll_selfop_sync_scope","ll_selfop_sync_task","ll_selfop_sync_run","ll_selfop_sync_stage","ll_selfop_inventory_snapshot","ll_selfop_sku_daily_sales","ll_selfop_sku_profile","ll_selfop_inventory_flow","ll_selfop_inventory_day_version","ll_selfop_inventory_daily")) {
var m=java.util.regex.Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?"+table+" \\([\\s\\S]*?\\) ENGINE[^;]+;").matcher(ddl);assertTrue(m.find(),table);
jdbc.execute(m.group().replaceAll("\\) ENGINE[^;]+;",");").replace(" JSON "," VARCHAR(1000000) "));
}
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '"+getClass().getName()+".extract'");jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '"+getClass().getName()+".unquote'");
var c=new MybatisConfiguration();c.setMapUnderscoreToCamelCase(true);
for(var mapper:List.of(SelfopSyncTaskMapper.class,SelfopSyncRunMapper.class,SelfopSyncStageMapper.class,SelfopCoordinationMapper.class,SelfopSyncScopeMapper.class,SelfopOutboundDiscoveryScopeMapper.class))c.addMapper(mapper);
var factory=new MybatisSqlSessionFactoryBean();factory.setConfiguration(c);factory.setDataSource(ds);session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
tasks=session.getMapper(SelfopSyncTaskMapper.class);runs=session.getMapper(SelfopSyncRunMapper.class);stages=session.getMapper(SelfopSyncStageMapper.class);
var real=session.getMapper(SelfopCoordinationMapper.class);locks=mock(SelfopCoordinationMapper.class,org.mockito.AdditionalAnswers.delegatesTo(real));
doReturn(List.of()).when(locks).dueContinuous(any(),anyInt(),anyString());
config=new SelfopAnalyticsProperties();config.setSourceAccount("demo");config.setCollectionMode("BUSINESS");config.setContinuousBusinessCollectionEnabled(true);config.setInventoryHistoryEnabled(true);config.setSourceQueryTimezone("Asia/Shanghai");config.setQuantityUnit("PCS");config.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse());
leases=new SelfopLeaseService(locks,session.getMapper(SelfopSyncScopeMapper.class),session.getMapper(SelfopOutboundDiscoveryScopeMapper.class),config);
client=mock(RestLingxingAnalyticsClient.class);versions=new InventoryHistoryVersions(jdbc,tx);facts=new InventoryFlowFacts(jdbc,tx,versions);
collector=newCollector();builder=new InventoryHistoryBuilder(jdbc,tx,versions,tasks,runs,stages);
jdbc.update("INSERT INTO ll_selfop_outbound_discovery_scope (id,created_at,updated_at,source_account,fence_token,request_budget,time_budget_seconds,min_request_interval_ms) VALUES(1,?,?,?,0,100,120,1200)",utcNow(),utcNow(),"demo");
jdbc.update("INSERT INTO ll_selfop_sync_scope (id,created_at,updated_at,source_account,warehouse_code,data_family,scope_json,scope_hash,business_timezone,source_timezone,schedule_cron,stale_after_seconds,fence_token,fact_revision,published_fact_revision,rebuild_required,review_request_budget,review_time_budget_seconds) VALUES(2,?,?,'demo','W','INVENTORY','{}',?,'Asia/Shanghai','Asia/Shanghai','0 0 8 * * *',93600,0,0,0,0,100,120)",utcNow(),utcNow(),"b".repeat(64));
}
@AfterEach void close(){jdbc.execute("SHUTDOWN");}
public static String extract(String value,String path){return json(read(value,JsonNode.class).path(path.substring(2)));}
public static String unquote(String value){return read(value,JsonNode.class).asText();}
InventoryFlowCollector newCollector(){return new InventoryFlowCollector(config,tasks,runs,stages,locks,leases,client,jdbc,tx);}
InventoryFlowSlice slice(){return new InventoryFlowSlice("W",DAY.atStartOfDay(),DAY.plusDays(1).atStartOfDay(),1000);}
JsonNode flow(String id,String delta,String close){return read("{\"id\":\""+id+"\",\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"productQuality\":0,\"cellNo\":\"CELL\",\"innerBatchNo\":\"BATCH\",\"billType\":\"DO\",\"bookQty\":"+delta+",\"bookChangQty\":\""+delta+"\",\"closeQty\":"+close+",\"createTime\":\"2026-10-14 12:00:00\"}",JsonNode.class);}
JsonNode page(int current,long total,List<JsonNode> rows){return read(json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);}
void respond(JsonNode page){when(client.inventoryFlowPage(any(),eq("W"),any(),any(),anyInt(),eq(1000))).thenReturn(page);}
void collect(InventoryFlowSlice slice,LocalDate round,JsonNode page){collector.plan(1,slice,round);respond(page);collector.page();while(facts.mergeNext("demo")){} }
@Test void collectionReplaysDoNotDoubleCountAndRevisionInvalidatesAtomically() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals("FACTS_APPLIED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run",String.class));
collect(slice(),DAY.plusDays(1),page(1,1,List.of(flow("F","-1","9"))));
assertEquals(1,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class));
var build=new InventoryHistoryVersions.Build("demo","W","b".repeat(64),50,51,52,null,versions.factHash("demo","W"),"{}",Map.of());
var candidate=versions.create(build,DAY);versions.ready(candidate,0);versions.publish(build,List.of(candidate));
collect(slice(),DAY.plusDays(2),page(1,1,List.of(flow("F","-2","8"))));
assertEquals(2,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class));
assertNotNull(jdbc.queryForObject("SELECT invalidated_at FROM ll_selfop_inventory_day_version",Object.class));
assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class));
}
@Test void changedIdentityIsQuarantinedAndNeverMoved() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
var changed=(com.fasterxml.jackson.databind.node.ObjectNode)flow("F","-1","9");changed.put("customerCode","OTHER");
collect(slice(),DAY.plusDays(1),page(1,1,List.of(changed)));
assertEquals("C",jdbc.queryForObject("SELECT customer_code FROM ll_selfop_inventory_flow",String.class));
assertEquals("FLOW_IDENTITY_CONFLICT",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class));
}
@Test void missingPreviouslySeenFlowRemainsUnknownInsteadOfBeingDeleted() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
collect(slice(),DAY.plusDays(1),page(1,0,List.of()));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals("SOURCE_FLOW_MISSING",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class));
}
@Test void restartedCollectorStartsNewAttemptAndDoesNotMixPages() {
collector.plan(1,slice(),DAY);var rows=new ArrayList<JsonNode>();for(int i=0;i<1000;i++)rows.add(flow("F"+i,"-1","9"));
respond(page(1,1001,rows));collector.page();assertEquals(1000,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class));
collector=newCollector();respond(page(1,1,List.of(flow("NEW","-1","9"))));collector.page();
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run",Integer.class));
assertEquals("ABORTED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run WHERE attempt_no=1",String.class));
while(facts.mergeNext("demo")){}assertEquals("NEW",jdbc.queryForObject("SELECT source_flow_id FROM ll_selfop_inventory_flow",String.class));
}
@Test void malformedPageSplitsFixedWindowWithoutGrantingCoverage() {
collector.plan(1,slice(),DAY);respond(page(1,2,List.of(flow("F","-1","9"))));collector.page();
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='DEAD'",Integer.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='READY'",Integer.class));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run WHERE coverage_status='COMPLETE'",Integer.class));
assertFalse(facts.mergeNext("demo"));
}
@Test void independentAnchorMismatchPublishesUnknownAndNeverInventsQuantity() {
stock(100,DAY,20);stock(101,DAY.plusDays(1),9);
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of()));
builder.build("demo","W",30);
assertEquals("INDEPENDENT_ANCHOR_MISMATCH",jdbc.queryForObject("SELECT quality_reason FROM ll_selfop_inventory_daily",String.class));
assertNull(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class));
}
@Test void localReconstructionPublishesDayAndKeepsRawSnapshotsUnchanged() {
stock(100,DAY,10);stock(101,DAY.plusDays(1),9);
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
var tail=new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000);
collect(tail,DAY,page(1,0,List.of()));builder.build("demo","W",30);
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class));
assertEquals(0,new BigDecimal("9").compareTo(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class)));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class));
builder.build("demo","W",30);assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class));
}
@Test void calculationRevisionRebuildsLegacyUnknownFromSavedFlowsExactlyOnce() {
stock(100,DAY,727);stock(101,DAY.plusDays(1),627);
var rows=new ArrayList<JsonNode>();
for(int i=0;i<100;i++)rows.add(flow("F"+i,"-1",String.valueOf(726-i)));
Collections.shuffle(rows,new Random(42));
collect(slice(),DAY,page(1,100,rows));
collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of()));
builder.build("demo","W",30);
// Seed the pre-fix published state, including its exact former dedup signature.
var coverage=List.of(new InventoryHistoryCalculator.Interval(
DAY.atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DAY.plusDays(1).atTime(8,0,2).toInstant(ZoneOffset.ofHours(8))));
String legacyHash=hash(Arrays.asList(versions.factHash("demo","W"),builder.anchor("demo","W",0),
builder.anchor("demo","W",1),DAY,DAY.plusDays(1),null,coverage));
var legacyTask=jdbc.queryForMap("SELECT id,request_hash FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD'");
assertNotEquals(legacyHash,legacyTask.get("request_hash"));
jdbc.update("UPDATE ll_selfop_sync_task SET request_hash=? WHERE id=?",legacyHash,legacyTask.get("id"));
jdbc.update("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='CHAIN_ORDER_AMBIGUOUS'");
jdbc.update("UPDATE ll_selfop_inventory_day_version SET evidence_json=?",json(Map.of("rule","FLOW_EOD_V1")));
var flowHash=versions.factHash("demo","W");
clearInvocations(client);
builder.build("demo","W",30);
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD' AND status='SUCCEEDED'",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class));
var current=jdbc.queryForMap("SELECT d.physical_quantity,d.quality_status,v.rule_version,v.evidence_json FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.head_slot=1");
assertEquals(0,new BigDecimal("627").compareTo((BigDecimal)current.get("physical_quantity")));
assertEquals("VALID",current.get("quality_status"));
assertEquals("FLOW_EOD_V1",current.get("rule_version"));
var evidence=read(current.get("evidence_json").toString(),JsonNode.class);
assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,evidence.path("calculationRevision").asInt());
var runEvidence=read(jdbc.queryForObject("SELECT scope_snapshot_json FROM ll_selfop_sync_run WHERE status='FACTS_APPLIED' AND last_phase='HISTORY_PUBLISHED' ORDER BY id DESC LIMIT 1",String.class),JsonNode.class);
assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,runEvidence.path("calculationRevision").asInt());
builder.build("demo","W",30);
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class));
assertEquals(flowHash,versions.factHash("demo","W"));
assertEquals(100,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class));
verifyNoInteractions(client);
}
void stock(long id,LocalDate day,int quantity) {
var now=day.atTime(0,0);var r=new SelfopSyncRun();r.setId(id);r.setTaskId(id);r.setAttemptNo(1);r.setSourceAccount("demo");r.setWarehouseCode("W");r.setScopeId(2L);r.setRunRole("WAREHOUSE_SYNC");r.setExecutionPurpose("BUSINESS");r.setScopeHash("b".repeat(64));r.setScopeSnapshotJson("{}");r.setFenceToken(0L);r.setStatus("PUBLISHED");r.setLastPhase("PUBLISHED");r.setCoverageStatus("COMPLETE");r.setSourceTotalCount(1L);r.setUniqueObjectCount(1L);r.setPageCount(1);r.setCreatedAt(now);r.setUpdatedAt(now);r.setFetchStartedAtUtc(now);r.setFetchFinishedAtUtc(now.plusSeconds(1));r.setPublishedAtUtc(now.plusSeconds(2));runs.insert(r);
jdbc.update("INSERT INTO ll_selfop_inventory_snapshot(id,created_at,updated_at,snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality,physical_quantity,observed_at_utc,quality_status) VALUES(?,?,?,?,'demo','C','W','SKU',0,?,?,'VALID')",id,now,now,id,quantity,now.plusSeconds(1));
jdbc.update("INSERT INTO ll_selfop_sku_profile(id,created_at,updated_at,profile_run_id,source_account,customer_code,warehouse_code,sku_code,quantity_unit,evidence_type,first_seen_at_utc,last_seen_run_id) VALUES(?,?,?,?,'demo','C','W','SKU','PCS','INVENTORY_OBSERVATION',?,?)",id,now,now,id,now,id);
}
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryVersions.*;
class InventoryHistoryVersionsTest {
JdbcTemplate jdbc;TransactionTemplate tx;InventoryHistoryVersions versions;
final LocalDate day=LocalDate.of(2026,10,1);
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));versions=new InventoryHistoryVersions(jdbc,tx);
jdbc.execute("CREATE TABLE ll_selfop_sync_scope(id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),data_family VARCHAR(32),scope_hash VARCHAR(64))");
jdbc.update("INSERT INTO ll_selfop_sync_scope VALUES(1,'a','w','INVENTORY','scope')");
String ddl=new ClassPathResource("db/selfop-inventory-history.sql").getContentAsString(StandardCharsets.UTF_8)
.replaceAll("(?m)^--.*$","").replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin","");
for(String statement:ddl.split(";")) if(!statement.isBlank())jdbc.execute(statement);
}
Build build(long id,Map<LocalDate,Long> heads) {return new Build("a","w","scope",id,id,10,20L,versions.factHash("a","w"),"{}",heads);}
Candidate candidate(Build b,LocalDate date,String quantity) {
Candidate c=versions.create(b,date);
versions.append(c,List.of(new Daily(date,new Key(new Sku("o","s"),0),new BigDecimal(quantity),"VALID",null,10)),"evidence");
versions.ready(c,1);return c;
}
@Test void unpublishedRowsAreInvisibleAndGroupSwitchesAllDates() {
var b=build(1,Map.of());var first=candidate(b,day,"100");var second=candidate(b,day.plusDays(1),"100");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEmpty();
versions.publish(b,List.of(first,second));
var old=versions.heads("a","w",day,day.plusDays(2));
var next=build(2,old);var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old);
versions.publish(next,List.of(n1,n2));
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(Map.of(day,n1.id(),day.plusDays(1),n2.id()));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_daily",Integer.class)).isEqualTo(4);
}
@Test void failureAfterFirstHeadChangeRollsBackWholeGroup() {
var b=build(1,Map.of());var c1=candidate(b,day,"100");var c2=candidate(b,day.plusDays(1),"100");versions.publish(b,List.of(c1,c2));
var old=versions.heads("a","w",day,day.plusDays(2));var next=build(2,old);
var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110");
jdbc.update("UPDATE ll_selfop_inventory_day_version SET invalidated_at=CURRENT_TIMESTAMP WHERE id=?",n2.id());
assertThatThrownBy(()->versions.publish(next,List.of(n1,n2))).hasMessage("HISTORY_CANDIDATE_CHANGED");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old);
assertThat(jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=?",String.class,n1.id())).isEqualTo("READY");
}
@Test void partialGroupAndIncompleteSkuSetsCannotPublish() {
var b=build(1,Map.of());var c1=candidate(b,day,"100");candidate(b,day.plusDays(1),"100");
assertThatThrownBy(()->versions.publish(b,List.of(c1))).hasMessage("HISTORY_GROUP_INCOMPLETE");
var c=versions.create(build(2,Map.of()),day.plusDays(2));
assertThatThrownBy(()->versions.ready(c,1)).hasMessage("HISTORY_CANDIDATE_INCOMPLETE");
}
@Test void unknownRowsAreRetainedAndPublishedVersionsCannotBeAppended() {
var b=build(1,Map.of());var c=versions.create(b,day);
versions.append(c,List.of(new Daily(day,new Key(new Sku("o","s"),0),null,"UNKNOWN","ANCHOR_MISSING",0)),"hash");versions.ready(c,1);versions.publish(b,List.of(c));
assertThat(jdbc.queryForObject("SELECT unknown_sku_count FROM ll_selfop_inventory_day_version WHERE id=?",Long.class,c.id())).isEqualTo(1);
assertThatThrownBy(()->versions.append(c,List.of(),"hash")).hasMessage("HISTORY_CANDIDATE_IMMUTABLE");
}
@Test void publicationIsIdempotentAfterCommit() {
var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c));versions.publish(b,List.of(c));
assertThat(versions.heads("a","w",day,day.plusDays(1))).hasSize(1);
}
@Test void aNewHeadRejectsStaleBuild() {
var stale=build(1,Map.of());var c=candidate(stale,day,"100");
var winner=build(2,Map.of());var c2=candidate(winner,day,"110");versions.publish(winner,List.of(c2));
assertThatThrownBy(()->versions.publish(stale,List.of(c))).hasMessage("HISTORY_HEAD_CHANGED");
}
@Test void changedWarehouseScopeRejectsFrozenCandidate() {
var b=build(1,Map.of());var c=candidate(b,day,"100");
jdbc.update("UPDATE ll_selfop_sync_scope SET scope_hash='new-scope'");
assertThatThrownBy(()->versions.publish(b,List.of(c))).hasMessage("HISTORY_SCOPE_CHANGED");
assertThat(versions.heads("a","w",day,day.plusDays(1))).isEmpty();
}
@Test void knownInvaliditySurvivesFailedRebuild() {
var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c));
tx.executeWithoutResult(s->{versions.lockWarehouse("a","w");versions.invalidate("a","w",2,"FLOW_REVISION");});
assertThat(jdbc.queryForObject("SELECT invalidation_reason FROM ll_selfop_inventory_day_version WHERE id=?",String.class,c.id())).isEqualTo("FLOW_REVISION");
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class SelfopAnalyticsPropertiesBindingTest {
StandardEnvironment environment() throws Exception {
var env=new StandardEnvironment();
for(var source:new YamlPropertySourceLoader().load("application",new ClassPathResource("application.yml")))env.getPropertySources().addLast(source);
env.getPropertySources().addFirst(new MapPropertySource("runtime",Map.of(
"baiyue.self-operated-analytics.collection-mode","BUSINESS",
"baiyue.self-operated-analytics.environment","test",
"baiyue.self-operated-analytics.inventory-history-enabled",true)));
return env;
}
@Test void refreshingRunningBusinessBeanBindsHistoryFlagWithoutDerivedExecutionInput() throws Exception {
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");
var env=environment();
assertNull(env.getProperty("baiyue.self-operated-analytics.execution-enabled"));
Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config));
assertTrue(config.isInventoryHistoryEnabled());assertTrue(config.isExecutionEnabled());
Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config));
assertTrue(config.isInventoryHistoryEnabled());assertTrue(config.isExecutionEnabled());
}
@Test void obsoleteFalseInputReproducesTheRefreshFailure() throws Exception {
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");
var env=environment();env.getPropertySources().addFirst(new MapPropertySource("obsolete",Map.of("baiyue.self-operated-analytics.execution-enabled",false)));
assertThrows(BindException.class,()->Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config)));
}
}
@@ -26,7 +26,8 @@ class InventoryTurnoverContextTest {
when(analytics.publishedRun(run.id())).thenReturn(run); when(analytics.publishedRun(run.id())).thenReturn(run);
when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run); when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run);
} }
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.SampleRun(END,100L))); when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L)));
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L)));
when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END); when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END);
} }
@Test void sameContextFreezesAllThirtyDatesAndMissingSamples() { @Test void sameContextFreezesAllThirtyDatesAndMissingSamples() {
@@ -35,34 +36,34 @@ class InventoryTurnoverContextTest {
assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate()); assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate());
assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive()); assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive());
assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response()); assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response());
var page2=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext()); var page2=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false);
var second=at(NOW.plusSeconds(90)).resolve(page2); var second=at(NOW.plusSeconds(90)).resolve(page2);
assertEquals(first,second.response()); assertEquals(first,second.response());
assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(), assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(),
InventoryTurnoverSql.input(page2,second).days()); InventoryTurnoverSql.input(page2,second).days());
verify(mapper,times(4)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); verify(mapper,times(1)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1));
// A subsequently published sample cannot appear in the old asOf query. If reconstruction // Even a late commit bearing an older wall-clock timestamp cannot change the exact mapping.
// ever differs, reject the context instead of mixing old charts and new rows. when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,101L)));
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); assertEquals(first,contexts.resolve(request(first.snapshotContext())).response());
// Explicit invalidation revokes the old evidence rather than silently switching its version.
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext())));
} }
@Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() { @Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() {
var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null); var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null,false);
var first=contexts.resolve(q).response(); var first=contexts.resolve(q).response();
assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size()); assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size());
assertTrue(first.snapshotContext().length()<2048); assertTrue(first.snapshotContext().length()<2048);
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext()); var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false);
assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response()); assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response());
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(next)); assertThrows(BizException.class,()->contexts.resolve(next));
} }
@Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception { @Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception {
String token=contexts.resolve(request(null)).response().snapshotContext(); String token=contexts.resolve(request(null)).response().snapshotContext();
var evidence=json.readTree(Base64.getUrlDecoder().decode(token)); String tampered=(token.charAt(0)=='A'?"B":"A")+token.substring(1);
((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L);
String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence));
assertThrows(BizException.class,()->contexts.resolve(request(tampered))); assertThrows(BizException.class,()->contexts.resolve(request(tampered)));
var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token); var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);
assertThrows(BizException.class,()->contexts.resolve(another)); assertThrows(BizException.class,()->contexts.resolve(another));
assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token))); assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token)));
assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token))); assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token)));
@@ -81,7 +82,7 @@ class InventoryTurnoverContextTest {
} }
@Test void rejectsDuplicateRepresentativesAndScopeDrift() { @Test void rejectsDuplicateRepresentativesAndScopeDrift() {
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of( when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(
new InventoryTurnoverReadMapper.SampleRun(END,100L),new InventoryTurnoverReadMapper.SampleRun(END,101L))); new InventoryTurnoverReadMapper.DayVersion(END,100L),new InventoryTurnoverReadMapper.DayVersion(END,101L)));
assertThrows(BizException.class,()->contexts.resolve(request(null))); assertThrows(BizException.class,()->contexts.resolve(request(null)));
when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH)); when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH));
assertThrows(BizException.class,()->contexts.resolve(request(null))); assertThrows(BizException.class,()->contexts.resolve(request(null)));
@@ -138,11 +139,22 @@ class InventoryTurnoverContextTest {
verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any()); verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any());
clearInvocations(mapper); clearInvocations(mapper);
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"))); when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}")));
var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext()); var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext(),false);
var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any()); var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any());
} }
@Test void fullYearContextKeepsExactVersionsAndBoundedSignedToken() {
var versions=java.util.stream.IntStream.range(0,366).mapToObj(i->new InventoryTurnoverReadMapper.DayVersion(END.minusDays(365-i),2110000000000000000L+i*100000003L)).toList();
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(versions);
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(versions);
var first=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var frozen=contexts.resolve(first).response();
assertEquals(366,frozen.dailySamples().size());
assertTrue(frozen.snapshotContext().length()<7500);
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,2,20,false,frozen.snapshotContext(),false);
assertEquals(frozen,contexts.resolve(next).response());
}
private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC),mock(SelfopOwnerDirectory.class));} private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC),mock(SelfopOwnerDirectory.class));}
static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);} static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);} private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);}
private static Run run(long id,long scope,String hash){ private static Run run(long id,long scope,String hash){
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.export.ExportContracts.*;
import com.baiyue.lastleg.export.LastlegExportProviderService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.math.BigDecimal;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class InventoryTurnoverExportTest {
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
final InventoryTurnoverQueryService queries=mock(InventoryTurnoverQueryService.class);
final InventoryTurnoverExport export=new InventoryTurnoverExport(queries,json);
Map<String,Object> parameters() {
Map<String,Object> p=new LinkedHashMap<>();
p.put("sourceAccount","demo");p.put("warehouseCode","W");p.put("endDate","2026-10-14");p.put("baseWindowDays",30);
p.put("groupBy","SKU");p.put("customerCodes",List.of("A","B"));p.put("sortBy","TURNOVER_DESC");p.put("hideZeroInventory",true);
p.put("snapshotContext","fixed");p.put("expectedTotal",205);p.put("columns",List.of("quantityTurnoverRate","sku","inventoryVolumeM3"));return p;
}
ExportBatchRequest request(Map<String,Object> p,String cursor,String token) {return new ExportBatchRequest("task","0","PLATFORM","user",1,p,cursor,token,100);}
Row row(int i) {
Map<String,Object> r=new LinkedHashMap<>();r.put("sku",i==0?"=SUM(1,2)":"S"+i);r.put("customerCode","A");r.put("customerName","货主甲");
r.put("quantityTurnoverCount",i==1?null:i==2?0:new BigDecimal("1.25"));r.put("averagePhysicalQuantity",new BigDecimal("80"));
r.put("windows",List.of(Map.of("days",30,"outboundQuantity",100,"dataStatus","COMPLETE")));
r.put("qualifiedSampleDays",30);r.put("requiredSampleDays",30);r.put("sampleReasonCodes",List.of());
r.put("inventoryVolumeM3",Map.of("knownSubtotal",1,"missingDimensionSkuCount",2,"missingDimensionQuantity",3,"declaredDimensionQuantity",4));
return json.convertValue(r,Row.class);
}
Response response(InventoryTurnoverRequest q,long total,String token) {
Context c=mock(Context.class);when(c.snapshotContext()).thenReturn(token);
List<Row> rows=java.util.stream.IntStream.range((int)q.offset(),(int)Math.min(total,q.offset()+q.pageSize())).mapToObj(this::row).toList();
return new Response(c,null,null,rows,total);
}
LastlegExportProviderService provider() {
var p=new LastlegExportProviderService();ReflectionTestUtils.setField(p,"json",json);ReflectionTestUtils.setField(p,"turnover",export);
ReflectionTestUtils.setField(p,"signingKey","test-export-signing-key-with-32-bytes");return p;
}
@Test void batchesAllRowsWithVisibleColumnOrderPercentageNotesAndFrozenFilters() {
when(queries.query(any())).thenAnswer(c->response(c.getArgument(0),205,"fixed"));
var p=provider();var first=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),null,null));
assertEquals(List.of("quantityTurnoverRate","sku","inventoryVolumeM3"),first.columns().subList(0,3).stream().map(ExportColumn::key).toList());
assertEquals("125%",first.rows().get(0).get("quantityTurnoverRate"));assertEquals("—",first.rows().get(1).get("quantityTurnoverRate"));assertEquals("0%",first.rows().get(2).get("quantityTurnoverRate"));
assertTrue(first.rows().get(0).get("inventoryVolumeM3").toString().contains("缺尺寸 2 SKU / 3 件"));
assertEquals("库存流水还原(日末实物库存)",first.rows().get(0).get("historySource"));
var second=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),first.nextCursor(),first.snapshotToken()));
var third=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),second.nextCursor(),second.snapshotToken()));
assertEquals(205,first.rows().size()+second.rows().size()+third.rows().size());assertFalse(third.hasMore());assertTrue(second.columns().isEmpty());assertEquals(first.schemaHash(),third.schemaHash());
verify(queries).query(argThat(q->q.page()==2&&q.pageSize()==100&&!q.overview()&&q.hideZero()&&q.customerCodes().equals(List.of("A","B"))&&q.sort().equals("TURNOVER_DESC")&&q.snapshotContext().equals("fixed")));
var changed=parameters();changed.put("columns",List.of("sku"));
assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,request(changed,"100",first.snapshotToken())));
assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,new ExportBatchRequest("task","0","PLATFORM","other",1,parameters(),"100",first.snapshotToken(),100)));
assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,new ExportBatchRequest("task","2","CLIENT","user",1,parameters(),null,null,100)));
}
@Test void rejectsStaleOrChangedDataWithoutReturningPartialFileAndSupportsEmptyResults() {
doAnswer(c->response(c.getArgument(0),204,"fixed")).when(queries).query(any());
assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed"));
doAnswer(c->response(c.getArgument(0),205,"other")).when(queries).query(any());
assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed"));
var empty=parameters();empty.put("expectedTotal",0);doAnswer(c->response(c.getArgument(0),0,"fixed")).when(queries).query(any());
var result=export.batch(request(empty,null,null),0,"signed");assertFalse(result.hasMore());assertEquals(0L,result.totalRows());assertFalse(result.columns().isEmpty());
doThrow(new com.baiyue.common.core.exception.BizException("库存周转上下文无效或已过期,请整体刷新")).when(queries).query(any());
assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed"));
}
@Test void validatesColumnsPaginationAndSnapshotBeforeQuerying() {
for(var columns:List.of(List.of("internalId"),List.of("sku","sku"))) {
var p=parameters();p.put("columns",columns);assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(p,null,null),0,"s"));
}
var owner=parameters();owner.put("groupBy","OWNER");assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(owner,null,null),0,"s"));
var noContext=parameters();noContext.remove("snapshotContext");assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(noContext,null,null),0,"s"));
var paged=parameters();paged.put("current",1);assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(paged,null,null),0,"s"));
verifyNoInteractions(queries);
assertEquals("<0.01",InventoryTurnoverExport.number(new BigDecimal("0.00001"),2));
}
@Test void ownerExportUsesTheServerAggregateWithoutAveragingSkuPercentages() {
var p=parameters();p.put("groupBy","OWNER");p.put("columns",List.of("customerCode","quantityTurnoverRate"));p.put("expectedTotal",1);
when(queries.query(any())).thenAnswer(c->response(c.getArgument(0),1,"fixed"));
var result=export.batch(request(p,null,null),0,"signed");assertEquals("货主甲",result.rows().get(0).get("customerCode"));assertEquals("125%",result.rows().get(0).get("quantityTurnoverRate"));
}
}
@@ -16,7 +16,16 @@ class InventoryTurnoverRequestTest {
} }
@Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() { @Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() {
var q=request(Map.of("includeOverview",false));q.validate(TODAY); var q=request(Map.of("includeOverview",false));q.validate(TODAY);
assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview()); assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview());assertTrue(q.hideZero());
assertFalse(request(Map.of("hideZeroInventory",false)).hideZero());
assertTrue(request(Map.of("hideZeroInventory",true)).hideZero());
}
@Test void validatesMultipleOwnersAndRejectsTheRemovedSingleOwnerParameter() {
request(Map.of("customerCodes",List.of("A","B"))).validate(TODAY);
request(Map.of("customerCodes",List.of())).validate(TODAY);
assertThrows(BizException.class,()->request(Map.of("customerCodes",List.of(""))).validate(TODAY));
assertThrows(BizException.class,()->request(Map.of("customerCodes",Collections.nCopies(101,"A"))).validate(TODAY));
assertThrows(BizException.class,()->InventoryTurnoverRequest.validateParameters(Set.of("customerCode")));
} }
@Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() {
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367), for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367),
@@ -24,6 +24,8 @@ class InventoryTurnoverSqlTest {
execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)"); execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)"); execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_day_version(id BIGINT PRIMARY KEY,source_account VARCHAR,warehouse_code VARCHAR,rule_version VARCHAR,invalidated_at TIMESTAMP)");
execute("CREATE TABLE ll_selfop_inventory_daily(day_version_id BIGINT,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
} }
@AfterEach void close() throws Exception {db.close();} @AfterEach void close() throws Exception {db.close();}
private void master() throws Exception { private void master() throws Exception {
@@ -33,6 +35,49 @@ class InventoryTurnoverSqlTest {
private void product(String owner,String sku,String declared,String measured) throws Exception { private void product(String owner,String sku,String declared,String measured) throws Exception {
execute("INSERT INTO ll_selfop_product VALUES(901,'demo','"+owner+"','"+sku+"',"+declared+","+measured+")"); execute("INSERT INTO ll_selfop_product VALUES(901,'demo','"+owner+"','"+sku+"',"+declared+","+measured+")");
} }
@Test void hidesOnlyKnownZeroPhysicalRowsBeforeCountAndPagingWithoutChangingOverview() throws Exception {
seed("A","POSITIVE",0);seed("A","ZERO",0);seed("B","ZERO",0);seed("C","UNKNOWN",0);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id=22 AND sku_code='ZERO'");
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0 WHERE snapshot_run_id=22 AND customer_code='A'");
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL,quality_status='UNKNOWN' WHERE snapshot_run_id=22 AND customer_code='C'");
for(String group:List.of("SKU","OWNER")) {
var all=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",1,20,true,"fixed",false);
var hidden=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",1,20,true,"fixed",null);
var visible=reportRows(input(hidden,30,0,true));
assertEquals(List.of("A","C"),visible.stream().map(r->r.get("customer_code")).toList());
assertEquals(group.equals("SKU")?4:3,reportRows(input(all,30,0,true)).size());
var report=query("report",input(hidden,30,0,true));
assertEquals(2,payload(report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow()).path("total").asInt());
assertEquals(query("overview",input(all,30,0,true)),query("overview",input(hidden,30,0,true)));
for(int page=1;page<=2;page++) {
var paged=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",page,1,false,"fixed",true);
assertEquals(visible.get(page-1).get("customer_code"),reportRows(input(paged,30,0,true)).get(0).get("customer_code"));
}
if(group.equals("SKU"))assertEquals(4,query("page",input(hidden,30,0,false)).size(),"Stale stock stays unknown, not zero");
}
var exact=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,"ZERO","ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",true);
assertTrue(reportRows(input(exact,30,0,true)).isEmpty());
}
@Test void filtersMultipleOwnersBeforeAggregationAndPaginationWithoutMergingSameSku() throws Exception {
seed("A","SAME",30);seed("B","SAME",30);seed("C","SAME",30);
for(String group:List.of("SKU","OWNER")) {
var selected=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("B","A","A"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false);
var p=input(selected,30,30,true);
var rows=reportRows(p);
assertEquals(List.of("A","B"),rows.stream().map(r->r.get("customer_code")).toList());
var all=input(new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false),30,30,true);
assertEquals(3,reportRows(all).size());
for(int page=1;page<=2;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("A","B"),null,"ALL",null,null,"PHYSICAL_DESC",page,1,false,"fixed",false);
assertEquals(rows.get(page-1).get("customer_code"),reportRows(input(q,30,30,true)).get(0).get("customer_code"));
}
var overview=query("overview",p);
assertFalse(overview.isEmpty());
assertNotEquals(query("overview",all),overview);
var unknown=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("' OR 1=1 --"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false);
assertTrue(reportRows(input(unknown,30,30,true)).isEmpty());
}
}
@Test void ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception { @Test void ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception {
master(); master();
for(int i=0;i<12;i++) { for(int i=0;i<12;i++) {
@@ -43,7 +88,7 @@ class InventoryTurnoverSqlTest {
product("UNKNOWN","SAME","100,1,1,'m'","NULL,NULL,NULL,NULL"); product("UNKNOWN","SAME","100,1,1,'m'","NULL,NULL,NULL,NULL");
execute("UPDATE ll_selfop_inventory_snapshot SET quality_status='INVALID' WHERE customer_code='UNKNOWN'"); execute("UPDATE ll_selfop_inventory_snapshot SET quality_status='INVALID' WHERE customer_code='UNKNOWN'");
for(int days:List.of(7,30)) { for(int days:List.of(7,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,"not-present","FROZEN",null,null,"PHYSICAL_DESC",2,1,true,"fixed"); var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,"not-present","FROZEN",null,null,"PHYSICAL_DESC",2,1,true,"fixed",false);
var report=query("report",input(q,30,0,true)); var report=query("report",input(q,30,0,true));
var ranking=new ArrayList<com.fasterxml.jackson.databind.JsonNode>(); var ranking=new ArrayList<com.fasterxml.jackson.databind.JsonNode>();
for(var part:report)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part)); for(var part:report)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part));
@@ -78,7 +123,7 @@ class InventoryTurnoverSqlTest {
: direction.equals("ASC")?List.of("A","D","B","C"):List.of("B","A","D","C"); : direction.equals("ASC")?List.of("A","D","B","C"):List.of("B","A","D","C");
var actual=new ArrayList<Object>(); var actual=new ArrayList<Object>();
for(int page=1;page<=2;page++) { for(int page=1;page<=2;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,days,group,null,null,"ALL",null,null,metric+"_"+direction,page,2,true,"fixed"); var q=new InventoryTurnoverRequest("demo","W",END,days,group,null,null,"ALL",null,null,metric+"_"+direction,page,2,true,"fixed",false);
q.validate(END.plusDays(1)); q.validate(END.plusDays(1));
actual.addAll(reportRows(input(q,30,0,true)).stream().map(r->r.get("customer_code")).toList()); actual.addAll(reportRows(input(q,30,0,true)).stream().map(r->r.get("customer_code")).toList());
} }
@@ -142,7 +187,7 @@ class InventoryTurnoverSqlTest {
decimal("1",reportRows(later).get(0).get("unit_volume")); decimal("1",reportRows(later).get(0).get("unit_volume"));
} }
static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) { static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) {
return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null); return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null,false);
} }
static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) { static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) {
List<InventoryTurnoverSql.Day> days=new ArrayList<>(); List<InventoryTurnoverSql.Day> days=new ArrayList<>();
@@ -153,7 +198,7 @@ class InventoryTurnoverSqlTest {
seed("A","SKU",1);master();product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL"); seed("A","SKU",1);master();product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL");
execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2025-01-01 00:00:00'"); execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2025-01-01 00:00:00'");
for(int n:List.of(3,45,366)) { for(int n:List.of(3,45,366)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null); var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var dates=new ArrayList<InventoryTurnoverSql.Day>(); var dates=new ArrayList<InventoryTurnoverSql.Day>();
int horizon=Math.max(30,n); int horizon=Math.max(30,n);
for(int i=0;i<horizon;i++) { for(int i=0;i<horizon;i++) {
@@ -242,7 +287,7 @@ class InventoryTurnoverSqlTest {
} }
@Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception { @Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception {
seed("A","SKU",30); seed("A","SKU",30);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100");
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
var row=query("page",p).get(0); var row=query("page",p).get(0);
decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days")); decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days"));
@@ -258,7 +303,7 @@ class InventoryTurnoverSqlTest {
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
var row=query("page",p).get(0); var row=query("page",p).get(0);
decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days")); decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days"));
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover")); row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover"));
} }
@Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception { @Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception {
@@ -311,7 +356,7 @@ class InventoryTurnoverSqlTest {
@Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception { @Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception {
seed("A","SKU",30); seed("A","SKU",30);
for(int days:List.of(7,15)) { for(int days:List.of(7,15)) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,null,1,20,true,null); var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var row=query("page",input(q,30,30,true)).get(0); var row=query("page",input(q,30,30,true)).get(0);
decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound")); decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound"));
decimal("60",row.get("coverage_days"));decimal(days==7?"0.14":"0.3",row.get("turnover"));decimal(String.valueOf(days),row.get("sample_count")); decimal("60",row.get("coverage_days"));decimal(days==7?"0.14":"0.3",row.get("turnover"));decimal(String.valueOf(days),row.get("sample_count"));
@@ -353,7 +398,7 @@ class InventoryTurnoverSqlTest {
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,present?100L:null)); dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,present?100L:null));
if(i>=30-n&&!present)missing.add(date.toString()); if(i>=30-n&&!present)missing.add(date.toString());
} }
var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null); var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null,false);
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates),LocalDateTime.of(2026,10,13,0,0)); var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates),LocalDateTime.of(2026,10,13,0,0));
var row=query("page",p).get(0); var row=query("page",p).get(0);
assertNotNull(row.get("sample_mask")); assertNotNull(row.get("sample_mask"));
@@ -366,9 +411,9 @@ class InventoryTurnoverSqlTest {
@Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception { @Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception {
seed("A","ONE",30);seed("A","TWO",30); seed("A","ONE",30);seed("A","TWO",30);
// Both edge bits and a middle bit, with different missing members per day. // Both edge bits and a middle bit, with different missing members per day.
execute("DELETE FROM ll_selfop_inventory_snapshot WHERE warehouse_code='W' AND ((sku_code='ONE' AND snapshot_run_id IN (100,114)) OR (sku_code='TWO' AND snapshot_run_id=129))"); execute("DELETE FROM ll_selfop_inventory_daily WHERE ((sku_code='ONE' AND day_version_id IN (100,114)) OR (sku_code='TWO' AND day_version_id=129))");
for(int n:List.of(7,15,30)) { for(int n:List.of(7,15,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null); var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null,false);
var p=input(q,30,30,true); var p=input(q,30,30,true);
var row=reportRows(p).get(0); var row=reportRows(p).get(0);
var expected=new ArrayList<LocalDate>(); var expected=new ArrayList<LocalDate>();
@@ -378,7 +423,7 @@ class InventoryTurnoverSqlTest {
assertNull(row.get("average_physical"));assertNull(row.get("turnover")); assertNull(row.get("average_physical"));assertNull(row.get("turnover"));
assertEquals(row.get("missing_dates"),query("page",p).get(0).get("missing_dates")); assertEquals(row.get("missing_dates"),query("page",p).get(0).get("missing_dates"));
} }
var pageTwo=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,1,false,null); var pageTwo=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,1,false,null,false);
var second=reportRows(input(pageTwo,30,30,true)).get(0); var second=reportRows(input(pageTwo,30,30,true)).get(0);
assertEquals("TWO",second.get("sku_code"));decimal("29",second.get("sample_count"));assertEquals(END.toString(),second.get("missing_dates")); assertEquals("TWO",second.get("sku_code"));decimal("29",second.get("sample_count"));assertEquals(END.toString(),second.get("missing_dates"));
// A stock/profile-only candidate without samples invalidates all owner days. // A stock/profile-only candidate without samples invalidates all owner days.
@@ -391,12 +436,13 @@ class InventoryTurnoverSqlTest {
long first=9_007_199_254_740_993L; long first=9_007_199_254_740_993L;
for(int i=0;i<30;i++) { for(int i=0;i<30;i++) {
execute("UPDATE ll_selfop_sku_profile SET profile_run_id="+(first+i)+" WHERE profile_run_id="+(100+i)); execute("UPDATE ll_selfop_sku_profile SET profile_run_id="+(first+i)+" WHERE profile_run_id="+(100+i));
execute("UPDATE ll_selfop_inventory_snapshot SET snapshot_run_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE snapshot_run_id="+(100+i)); execute("UPDATE ll_selfop_inventory_day_version SET id="+(first+i)+" WHERE id="+(100+i));
execute("UPDATE ll_selfop_inventory_daily SET day_version_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE day_version_id="+(100+i));
} }
List<InventoryTurnoverSql.Day> days=new ArrayList<>(); List<InventoryTurnoverSql.Day> days=new ArrayList<>();
for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));} for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));}
for(int n:List.of(7,15,30)) { for(int n:List.of(7,15,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null); var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0)); var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0));
var row=reportRows(p).get(0); var row=reportRows(p).get(0);
decimal(String.valueOf(n),row.get("sample_count")); decimal(String.valueOf(n),row.get("sample_count"));
@@ -412,12 +458,12 @@ class InventoryTurnoverSqlTest {
@Test void reportPaginationKeepsTotalAndCanOmitOverview() throws Exception { @Test void reportPaginationKeepsTotalAndCanOmitOverview() throws Exception {
seed("A","SKU",30);seed("B","SKU",30); seed("A","SKU",30);seed("B","SKU",30);
var first=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true)); var first=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true));
var secondQuery=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,1,false,"frozen"); var secondQuery=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,1,false,"frozen",false);
var second=query("report",input(secondQuery,30,30,true)); var second=query("report",input(secondQuery,30,30,true));
var row=second.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow(); var row=second.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow();
assertNotEquals(first.get(0).get("customer_code"),payload(row).path("customer_code").asText()); assertNotEquals(first.get(0).get("customer_code"),payload(row).path("customer_code").asText());
assertEquals(2,second.size()); assertEquals(2,second.size());
var beyond=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",3,1,false,"frozen"); var beyond=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",3,1,false,"frozen",false);
var report=query("report",input(beyond,30,30,true));assertEquals(1,report.size()); var report=query("report",input(beyond,30,30,true));assertEquals(1,report.size());
assertEquals("TOTAL",report.get(0).get("section"));assertEquals(2,payload(report.get(0)).path("total").asInt()); assertEquals("TOTAL",report.get(0).get("section"));assertEquals(2,payload(report.get(0)).path("total").asInt());
} }
@@ -432,7 +478,7 @@ class InventoryTurnoverSqlTest {
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true); var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true);
var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed")); var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed"));
var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.sampleRun())).toList(); var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.historyVersionId())).toList();
var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0); var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0);
assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial")); assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial"));
var complete=reportRows(input(p.query(),30,30,true)).get(0); var complete=reportRows(input(p.query(),30,30,true)).get(0);
@@ -498,16 +544,16 @@ class InventoryTurnoverSqlTest {
} }
@Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception { @Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception {
seed("A","ONE",30);seed("A","TWO",30); seed("A","ONE",30);seed("A","TWO",30);
String key=" WHERE snapshot_run_id=100 AND sku_code='TWO' AND product_quality=0"; String key=" WHERE day_version_id=100 AND sku_code='TWO' AND product_quality=0";
for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'","profile")) { for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'")) {
if("profile".equals(mutation))execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100 AND sku_code='TWO'"); if("profile".equals(mutation))execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100 AND sku_code='TWO'");
else execute("UPDATE ll_selfop_inventory_snapshot SET "+mutation+key); else execute("UPDATE ll_selfop_inventory_daily SET "+mutation+key);
var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
decimal("29",owner.get("sample_count"));assertNull(owner.get("turnover"));assertEquals(END.minusDays(29).toString(),owner.get("missing_dates")); decimal("29",owner.get("sample_count"));assertNull(owner.get("turnover"));assertEquals(END.minusDays(29).toString(),owner.get("missing_dates"));
execute("DELETE FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0); execute("DELETE FROM ll_selfop_inventory_daily WHERE day_version_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='PCS' WHERE profile_run_id=100 AND sku_code='TWO'"); execute("UPDATE ll_selfop_sku_profile SET quantity_unit='PCS' WHERE profile_run_id=100 AND sku_code='TWO'");
} }
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
decimal("30",owner.get("sample_count"));decimal("0",owner.get("average_physical"));assertNull(owner.get("turnover")); decimal("30",owner.get("sample_count"));decimal("0",owner.get("average_physical"));assertNull(owner.get("turnover"));
} }
@@ -525,6 +571,7 @@ class InventoryTurnoverSqlTest {
String amount="99999999999999.123456";var quantity=new BigDecimal(amount); String amount="99999999999999.123456";var quantity=new BigDecimal(amount);
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity="+amount+" WHERE warehouse_code='W'"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity="+amount+" WHERE warehouse_code='W'");
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END"); execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END");
var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true));
var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow()); var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow());
decimal(quantity.multiply(BigDecimal.valueOf(120)).toPlainString(),row.path("w30_outbound").decimalValue()); decimal(quantity.multiply(BigDecimal.valueOf(120)).toPlainString(),row.path("w30_outbound").decimalValue());
@@ -579,7 +626,7 @@ class InventoryTurnoverSqlTest {
} }
@Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception { @Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception {
seed("A","ONE",30);seed("A","TWO",30); seed("A","ONE",30);seed("A","TWO",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN snapshot_run_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN day_version_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE day_version_id>=100");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'");
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true); var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true);
@@ -593,7 +640,7 @@ class InventoryTurnoverSqlTest {
} }
for(var row:query("page",p))decimal("30000000",row.get("turnover")); for(var row:query("page",p))decimal("30000000",row.get("turnover"));
} }
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20); var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);
for(var row:reportRows(input(q,30,30,true))) { for(var row:reportRows(input(q,30,30,true))) {
@@ -625,7 +672,7 @@ class InventoryTurnoverSqlTest {
} }
@Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception { @Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception {
seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30); seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE day_version_id>=100");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'");
for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) { for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1); var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1);
@@ -644,6 +691,7 @@ class InventoryTurnoverSqlTest {
void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception { void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception {
seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30); seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,transit_quantity=CASE customer_code WHEN 'L' THEN 2 WHEN 'H' THEN 8 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END"); execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,transit_quantity=CASE customer_code WHEN 'L' THEN 2 WHEN 'H' THEN 8 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END");
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue; if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue;
@@ -665,7 +713,7 @@ class InventoryTurnoverSqlTest {
execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN',outbound_quantity=9999 WHERE sku_code='UNKNOWN'"); execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN',outbound_quantity=9999 WHERE sku_code='UNKNOWN'");
for(int days:List.of(7,15,30)) { for(int days:List.of(7,15,30)) {
for(int page=1;page<=3;page++) { for(int page=1;page<=3;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context"); var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context",false);
var expected=days==7?List.of("RECENT","OLDER","UNKNOWN"):List.of("OLDER","RECENT","UNKNOWN"); var expected=days==7?List.of("RECENT","OLDER","UNKNOWN"):List.of("OLDER","RECENT","UNKNOWN");
assertEquals(expected.get(page-1),reportRows(input(q,30,0,true)).get(0).get("sku_code")); assertEquals(expected.get(page-1),reportRows(input(q,30,0,true)).get(0).get("sku_code"));
} }
@@ -699,6 +747,10 @@ class InventoryTurnoverSqlTest {
execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')"); execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')");
} }
private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception { private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception {
if(run>=100) {
execute("MERGE INTO ll_selfop_inventory_day_version KEY(id) VALUES("+run+",'demo','W','FLOW_EOD_V1',NULL)");
execute("INSERT INTO ll_selfop_inventory_daily VALUES("+run+",'"+owner+"','"+sku+"',"+quality+","+physical+",'VALID',NULL)");return;
}
execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,0,'VALID',NULL)"); execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,0,'VALID',NULL)");
} }
private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}} private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}}
@@ -29,6 +29,25 @@ class SelfopEgressServiceTest {
private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);} private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);}
private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);} private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);}
private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);} private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);}
@Test void historyPagesRequireExactFrozenSliceCheckpointAndEnabledWorker(){
continuousBusinessRoot();cfg.setInventoryHistoryEnabled(true);
var slice=new com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice("A",now().minusDays(1).withNano(0),now().withNano(0),1000);
task.setTaskType(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE);
task.setScopeHash(SelfopSupport.hash(slice));task.setRequestHash(task.getScopeHash());task.setQueryScopeJson(SelfopSupport.json(slice));
task.setPlannedAtUtc(now().toLocalDate().atStartOfDay());task.setQueryFromUtc(LocalDateTime.ofInstant(slice.from(),ZoneOffset.UTC));task.setQueryUntilUtc(LocalDateTime.ofInstant(slice.until(),ZoneOffset.UTC));
task.setRequestKey("continuous:history:"+task.getPlannedAtUtc().toLocalDate()+":"+task.getScopeHash());
run.setProgressSnapshotJson("{\"nextPage\":1}");
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue();
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"B",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID");
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2))).hasMessage("EGRESS_HISTORY_INVALID");
cfg.setInventoryHistoryEnabled(false);
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID");
cfg.setInventoryHistoryEnabled(true);
var first=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1));success(first,Operation.INVENTORY_FLOW);
run.setProgressSnapshotJson("{\"nextPage\":2}");
var second=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2));success(second,Operation.INVENTORY_FLOW);
assertThat(businessBudget().http).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);
}
@Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){ @Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){
continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null); continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null);
task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8)); task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8));
@@ -44,6 +63,21 @@ class SelfopEgressServiceTest {
success(c,Operation.CUSTOMERS);success(reserve(Operation.PRODUCTS,100,null,null),Operation.PRODUCTS); success(c,Operation.CUSTOMERS);success(reserve(Operation.PRODUCTS,100,null,null),Operation.PRODUCTS);
assertThat(budget().confirmedHttp).isEqualTo(2); assertThat(budget().confirmedHttp).isEqualTo(2);
} }
@Test void flowProbeIsTestOnlyBoundToFrozenHashAndExactlyOneAttempt(){
testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL);
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,100,null,null)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"OTHER",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,PROVIDER)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);success(c,Operation.INVENTORY_FLOW);
assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"A",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
assertThat(businessBudget().http).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);
}
@Test void flowPermissionFailureDoesNotPauseExistingSalesAccount(){
testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL);
var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);service.authorize(new AuthorizationRequest(c,Operation.INVENTORY_FLOW,PROVIDER));
service.complete(new CompletionRequest(c,Operation.INVENTORY_FLOW,PROVIDER,1,"LINGXING_WMS_FLOW_PERMISSION_DENIED",200,11008,false,null));
assertThat(account.getApiPauseReason()).isNull();assertThat(budget().confirmedHttp).isEqualTo(1);
}
@Test void ordinaryTasksCannotUseMasterDataOperations(){ @Test void ordinaryTasksCannotUseMasterDataOperations(){
business();assertThatThrownBy(()->reserve(Operation.CUSTOMERS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH"); business();assertThatThrownBy(()->reserve(Operation.CUSTOMERS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
assertThatThrownBy(()->reserve(Operation.PRODUCTS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH"); assertThatThrownBy(()->reserve(Operation.PRODUCTS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient;
import org.springframework.transaction.support.TransactionTemplate;
class InventoryFlowProbeServiceTest {
@Test void successfulProbeIsAuditedReplayedAndNeverPublished() throws Exception {
var cfg=new SelfopAnalyticsProperties();cfg.setEnvironment("test");cfg.setCollectionMode("BUSINESS");cfg.setSourceAccount("demo");
cfg.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse());
var tasks=mock(SelfopSyncTaskMapper.class);var runs=mock(SelfopSyncRunMapper.class);var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);
var leases=mock(SelfopLeaseService.class);var client=mock(RestLingxingAnalyticsClient.class);var tx=mock(TransactionTemplate.class);
doAnswer(i->{((java.util.function.Consumer<org.springframework.transaction.TransactionStatus>)i.getArgument(0)).accept(null);return null;}).when(tx).executeWithoutResult(any());
var a=new SelfopOutboundDiscoveryScope();a.setId(1L);when(accounts.selectOne(any())).thenReturn(a);
var lease=new SelfopLeaseService.Lease(1L,"probe",2L,true);when(leases.acquireDiscovery(eq(1L),anyString())).thenReturn(lease);
var saved=new java.util.concurrent.atomic.AtomicReference<SelfopSyncTask>();
when(tasks.selectOne(any())).thenAnswer(i->saved.get());when(tasks.insert(any(SelfopSyncTask.class))).thenAnswer(i->{saved.set(i.getArgument(0));return 1;});
var page=new ObjectMapper().readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}");
when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenReturn(page);
var s=new InventoryFlowProbeService(cfg,tasks,runs,accounts,leases,client,tx);
var end=LocalDateTime.of(2026,1,1,0,0);var q=new InventoryFlowProbeService.Request("one","W","C","S",end.minusDays(30),end);
var first=s.probe(q,"operator");assertTrue(first.complete());assertEquals(first,s.probe(q,"operator"));
assertEquals("SAMPLE_DONE",saved.get().getStatus());assertNull(saved.get().getResultPublishedRunId());
var capture=org.mockito.ArgumentCaptor.forClass(SelfopSyncRun.class);verify(runs).insert(capture.capture());
assertEquals("SAMPLE_DONE",capture.getValue().getStatus());assertNull(capture.getValue().getPublishedAtUtc());assertNull(capture.getValue().getInventoryDailySampleDate());
verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());verify(leases,times(2)).release(lease);
assertThrows(IllegalStateException.class,()->s.probe(new InventoryFlowProbeService.Request("one","W","C","OTHER",q.startTime(),q.endTime()),"operator"));
verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());
saved.set(null);when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenThrow(new IllegalStateException("PROVIDER_FAILURE"));
assertThrows(IllegalStateException.class,()->s.probe(q,"operator"));assertEquals("DEAD",saved.get().getStatus());
assertThrows(IllegalStateException.class,()->s.probe(q,"operator"));
verify(client,times(2)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());
}
@Test void refusesFutureAndOversizedWindow() {
var now=LocalDateTime.of(2026,10,15,12,0);
InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(30),now),now);
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(32),now),now));
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now,now.plusSeconds(1)),now));
}
@Test void refusesPartialPageAndDuplicateEvidence() throws Exception {
var mapper=new ObjectMapper();
assertTrue(InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}")));
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"}]}")));
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"},{\"id\":\"1\"}]}")));
var root=mapper.createObjectNode().put("current",1).put("size",1000).put("pages",2).put("total",1001);
var rows=root.putArray("records");for(int i=0;i<1000;i++)rows.addObject().put("id",String.valueOf(i));
assertFalse(InventoryFlowProbeService.validatePage(root));
}
}
@@ -129,6 +129,11 @@
"target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard",
"reason": "内部出口许可端点复用现行服务令牌校验,不复制认证实现;只允许该控制器依赖该基础校验器,不开放客户业务或共享技术切片的其他引用。" "reason": "内部出口许可端点复用现行服务令牌校验,不复制认证实现;只允许该控制器依赖该基础校验器,不开放客户业务或共享技术切片的其他引用。"
}, },
{
"source": "com.baiyue.lastleg.export.LastlegExportProviderService",
"target": "com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverExport",
"reason": "统一导出装配器只调用自营周转的公开导出投影,复用固定版本查询;不开放自营Mapper或客户WMS库存访问,不产生反向依赖。"
},
{ {
"source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter",
"target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway", "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway",