Merge latest test into dev preserving initialization fixes [skip ci]

This commit is contained in:
2026-10-14 15:55:04 +08:00
405 changed files with 30826 additions and 800 deletions
+2
View File
@@ -78,6 +78,8 @@
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 |
| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` |
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
+12
View File
@@ -218,6 +218,12 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9210
LINGXING_WMS_ENABLED: ${LINGXING_WMS_ENABLED:-false}
LINGXING_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
LINGXING_ENVIRONMENT_LABEL: ${DEPLOY_ENV:-test}
LINGXING_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
LINGXING_OMS_APP_KEY: ${LINGXING_OMS_APP_KEY:-}
LINGXING_OMS_APP_SECRET: ${LINGXING_OMS_APP_SECRET:-}
BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false}
BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-}
BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-}
@@ -282,6 +288,12 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9303
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
SELFOP_DAILY_REQUEST_BUDGET: ${SELFOP_DAILY_REQUEST_BUDGET:-0}
SELFOP_MIN_REQUEST_INTERVAL_MS: ${SELFOP_MIN_REQUEST_INTERVAL_MS:-5000}
SELFOP_SALES_PUBLICATION_ENABLED: ${SELFOP_SALES_PUBLICATION_ENABLED:-false}
EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000}
EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000}
EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60}
+11
View File
@@ -21,6 +21,17 @@ IMAGE_TAG=
FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads
CORS_ALLOWED_ORIGINS=
INTERNAL_SERVICE_TOKEN=
# 领星唯一账号;凭据仅注入 integration-service。
LINGXING_OMS_APP_KEY=
LINGXING_OMS_APP_SECRET=
LINGXING_WMS_ENABLED=false
SELFOP_COLLECTION_MODE=OFF
SELFOP_SOURCE_ACCOUNT=lingxing-primary
# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。
SELFOP_DAILY_REQUEST_BUDGET=0
SELFOP_MIN_REQUEST_INTERVAL_MS=5000
SELFOP_EXECUTION_ENABLED=false
SELFOP_SALES_PUBLICATION_ENABLED=false
REDIS_PASSWORD=
AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
AUTH_DB_USERNAME=
+11 -3
View File
@@ -1,5 +1,9 @@
# 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
`migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。
`migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。
@@ -22,6 +26,8 @@
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
## 唯一入口与区域边界
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
@@ -29,7 +35,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 184 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 197 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -51,7 +57,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/184`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/197`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -67,7 +73,7 @@
| 所属库 | 保留内容 | 不包含 |
|---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、442 个权限、630 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、449 个权限、637 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -139,3 +145,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。
码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。
`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS ll_selfop_warehouse_daily_sales (
sales_run_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 '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
SET @selfop_warehouse_daily_ready_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_selfop_sync_run' AND column_name='warehouse_daily_ready'),
'SELECT 1',
'ALTER TABLE ll_selfop_sync_run ADD COLUMN warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT ''仓库日汇总已完整构建并校验,含完整空版本''');
PREPARE selfop_warehouse_daily_ready_stmt FROM @selfop_warehouse_daily_ready_ddl;
EXECUTE selfop_warehouse_daily_ready_stmt;
DEALLOCATE PREPARE selfop_warehouse_daily_ready_stmt;
@@ -0,0 +1,44 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
ALTER TABLE ll_return_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID';
ALTER TABLE ll_return_order_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_receipt
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号';
ALTER TABLE ll_return_receipt_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码';
ALTER TABLE ll_return_inventory_carton
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码';
ALTER TABLE ll_return_inventory_carton_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_inventory_outbound_movement
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID',
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号';
ALTER TABLE ll_return_sku_conversion
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU',
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号';
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -2,7 +2,7 @@
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。utf8mb4_0900_ai_ci
SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci;
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
@@ -1422,7 +1422,6 @@ CREATE TABLE fl_consumed_event (
CREATE TABLE fl_container_booking (
id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订',
cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
@@ -1477,7 +1476,6 @@ CREATE TABLE fl_container_booking (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
@@ -5337,7 +5335,7 @@ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container
SELECT MAX(plan_status) INTO current_plan_status
FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -8315,4 +8313,24 @@ SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND r.status='ACTIVE' AND r.deleted=0;
-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables;
+389 -19
View File
@@ -2373,13 +2373,13 @@ CREATE TABLE ll_relabel_status_history (
CREATE TABLE ll_return_inventory_outbound_movement (
id BIGINT NOT NULL COMMENT '退货出库SKU流水ID',
outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID',
carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码',
outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号',
sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号',
sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正',
total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量',
total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量',
@@ -2395,7 +2395,7 @@ CREATE TABLE ll_return_inventory_outbound_movement (
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
AND total_before-total_after=quantity AND available_before-available_after=quantity
AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='不可变退货出库SKU库存流水';
CREATE TABLE ll_return_inventory_balance (
id bigint NOT NULL COMMENT '退货库存余额ID',
@@ -2414,15 +2414,17 @@ CREATE TABLE ll_return_inventory_balance (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额';
CREATE TABLE ll_return_inventory_carton (
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签',
new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛',
id bigint NOT NULL COMMENT '退货库存箱ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
customer_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
overseas_warehouse_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID',
source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔',
source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码',
carton_mark varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码',
fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛',
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码',
destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称',
@@ -2459,9 +2461,10 @@ CREATE TABLE ll_return_inventory_carton_item (
id bigint NOT NULL COMMENT '退货库存箱SKU明细ID',
carton_id bigint NOT NULL COMMENT '退货库存箱ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
quantity bigint NOT NULL COMMENT '该SKU箱内数量',
relabelled_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '当前SKU已经换标的实物件数,不可二次换标',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
@@ -2486,9 +2489,9 @@ CREATE TABLE ll_return_inventory_operation (
CREATE TABLE ll_return_order (
id bigint NOT NULL COMMENT '退货订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
customer_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
return_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货订单号',
overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
overseas_warehouse_id varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
warehouse_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系人快照',
warehouse_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系电话快照',
customer_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户参考号',
@@ -2521,8 +2524,8 @@ CREATE TABLE ll_return_order_item (
id bigint NOT NULL COMMENT '退货预报明细ID',
return_order_id bigint NOT NULL COMMENT '退货订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
expected_quantity bigint NOT NULL COMMENT 'SKU预报数量',
received_quantity bigint NOT NULL DEFAULT '0' COMMENT 'SKU累计实收数量',
created_at datetime(3) NOT NULL COMMENT '创建时间',
@@ -2537,7 +2540,7 @@ CREATE TABLE ll_return_receipt (
id bigint NOT NULL COMMENT 'PDA退货签收单ID',
return_order_id bigint NOT NULL COMMENT '退货订单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号',
receipt_no varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号',
receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号',
@@ -2564,7 +2567,9 @@ CREATE TABLE ll_return_receipt (
length DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
width DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
height DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
weight DECIMAL(12,3) NULL COMMENT '实测重量kg'
weight DECIMAL(12,3) NULL COMMENT '实测重量kg',
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
measurement_source VARCHAR(32) NULL COMMENT '尺寸重量来源'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实';
CREATE TABLE ll_return_receipt_batch (
@@ -2588,7 +2593,7 @@ CREATE TABLE ll_return_receipt_item (
receipt_id bigint NOT NULL COMMENT 'PDA退货签收单ID',
return_order_item_id bigint NOT NULL COMMENT '退货预报明细ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
sku_code varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
received_quantity bigint NOT NULL COMMENT '本次SKU实收数量',
PRIMARY KEY (id),
KEY idx_ll_return_receipt_item_receipt (receipt_id),
@@ -3907,8 +3912,328 @@ CREATE TABLE ll_procurement_closure_item (
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
USE baiyue_lastleg;
-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。
CREATE TABLE ll_selfop_outbound_discovery_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖',
catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖',
next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧',
next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次',
active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务',
lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者',
lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务',
request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正',
time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正',
min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置',
api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据',
api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除',
api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_discovery_account (source_account),
CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域';
CREATE TABLE ll_selfop_sync_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥',
scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单',
scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合',
business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准',
source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区',
schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行',
stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整',
warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表',
initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月',
initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间',
discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界',
discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点',
fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加',
published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号',
rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1',
invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻',
published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次',
lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁',
lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验',
known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标',
known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识',
known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间',
known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间',
history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界',
history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界',
history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置',
history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻',
review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正',
review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正',
review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family),
KEY idx_selfop_scope_lease (lease_until_utc),
CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针';
CREATE TABLE ll_selfop_sync_task (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识',
execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承',
validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空',
validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因',
business_root_task_id BIGINT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空',
business_budget_json JSON NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器',
upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游',
discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定',
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号',
routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷',
scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要',
status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功',
apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE',
affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补',
apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间',
apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数',
next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻',
requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计',
result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空',
progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合',
progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_task_no (task_no),
UNIQUE KEY uk_selfop_task_request (source_account,request_key),
KEY idx_selfop_task_poll (status,next_retry_at_utc,id),
KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code),
KEY idx_selfop_task_apply (apply_state,id),
KEY idx_selfop_task_scope_list (scope_id,created_at,id),
KEY idx_selfop_task_account_list (source_account,task_role,created_at,id),
KEY idx_selfop_task_business_root (business_root_task_id,status,id),
CONSTRAINT chk_selfop_business_budget CHECK ((business_root_task_id IS NULL AND business_budget_json IS NULL) OR (execution_purpose='BUSINESS' AND business_root_task_id IS NOT NULL AND ((business_root_task_id=id AND business_budget_json IS NOT NULL) OR (business_root_task_id<>id AND business_budget_json IS NULL)))),
CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求';
CREATE TABLE ll_selfop_sync_run (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_id BIGINT NOT NULL COMMENT '本次执行请求',
execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本',
scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合',
attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增',
scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要',
scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版',
observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围',
fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本',
base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本',
reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实',
inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空',
inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存',
status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头',
last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实',
fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间',
fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空',
build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔',
warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT '仓库日汇总已完整构建并校验,含完整空版本',
finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间',
published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间',
superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置',
page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数',
source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空',
unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数',
progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写',
heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进',
coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断',
covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界',
covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点',
complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含',
complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含',
applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空',
calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号',
error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息',
error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no),
UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date),
KEY idx_selfop_run_scope (scope_id,id),
KEY idx_selfop_run_status (status,updated_at),
CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')),
CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头';
CREATE TABLE ll_selfop_sync_stage (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试',
stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页',
last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源',
observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓',
object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK',
object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对',
first_page_no INT NOT NULL COMMENT '首次发现页码',
observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻',
content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要',
payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段',
before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空',
validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常',
validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash),
KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id),
KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence),
KEY idx_selfop_stage_fact_observation (observation_sequence,warehouse_code,object_type,validation_status,run_id,observed_at_utc),
KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status),
CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据';
CREATE TABLE ll_selfop_outbound_order (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份',
source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示',
source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码',
source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代',
source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代',
source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区',
outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空',
business_date DATE NULL COMMENT '已核准业务时区下的出库日',
business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日',
sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1',
revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数',
content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要',
last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试',
last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no),
KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id),
KEY idx_selfop_order_run (last_run_id),
CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实';
CREATE TABLE ll_selfop_outbound_item (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
order_id BIGINT NOT NULL COMMENT '对应外部出库事实',
sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku',
product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示',
actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值',
quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order_sku (order_id,sku_code),
KEY idx_selfop_item_sku (sku_code,order_id),
CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细';
CREATE TABLE ll_selfop_inventory_snapshot (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存',
product_name VARCHAR(255) NULL COMMENT '来源名称',
physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空',
available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结',
frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空',
transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存',
source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据',
observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间',
quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生',
quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality),
KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细';
CREATE TABLE ll_selfop_sku_daily_sales (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
business_date DATE NOT NULL COMMENT '已核准业务日',
outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负',
outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负',
quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date),
KEY idx_selfop_daily_window_cover (sales_run_id,source_account,warehouse_code,business_date,customer_code,sku_code,outbound_quantity,outbound_order_count,quantity_unit),
KEY idx_selfop_daily_sku_cover (sales_run_id,source_account,warehouse_code,customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit),
CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实';
CREATE TABLE ll_selfop_warehouse_daily_sales (
sales_run_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 '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
CREATE TABLE ll_selfop_sku_profile (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称',
quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标',
first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据',
earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移',
evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据',
evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空',
last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code),
KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据';
CREATE TABLE ll_appointment_impact (
container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示',
@@ -3949,4 +4274,49 @@ CREATE TABLE ll_appointment_impact_history (
CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 184 AS initialized_tables;
-- SKU 换标实物转换:先执行本增量,再部署服务。不会自动改写存量业务事实。
CREATE TABLE ll_return_sku_conversion (
id BIGINT NOT NULL COMMENT 'SKU库存转换记录ID',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键',
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
application_id BIGINT NOT NULL COMMENT '换标申请ID',
relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号',
item_id BIGINT NOT NULL COMMENT '换标箱级明细ID',
round_key VARCHAR(64) NOT NULL COMMENT '作业轮次,退回重做使用新轮次',
carton_id BIGINT NOT NULL COMMENT '实物来源包裹或箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '完工时仓库识别码',
old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU',
new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_sku_conversion_round(item_id,round_key,old_sku_code,new_sku_code),
KEY idx_return_sku_conversion_app(customer_id,application_id,status),
KEY idx_return_sku_conversion_carton(customer_id,carton_id),
CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录';
-- PDA cargo discard photo evidence
CREATE TABLE ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 197 AS initialized_tables;
+2
View File
@@ -56,3 +56,5 @@
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
平台自营销量、产品库存与采集:正式入口为 ../docs/自营业务/README.md;适配从 integration-service/AGENTS.md 进入,持久化、指标和平台运行查询从 lastleg-service/AGENTS.md 进入 selfoperated/analytics/,权限源为 iam/src/main/resources/db/selfoperated-analytics-permissions.sql。Admin 从 ../apps/admin-web/src/modules/self-operated/AGENTS.md 进入;与客户物流订单和仓储作业库存分开。
@@ -31,6 +31,7 @@ enum BusinessFileCategory {
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_CARGO_DISCARD_PHOTO(true, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -0,0 +1,12 @@
openapi: 3.0.3
info:
title: Cargo discard photo storage ownership contract
version: 1.0.0
description: >-
Uses the existing internal business-file upload, metadata and content endpoints, protected
by X-Internal-Service-Token. businessType is OVERSEAS_CARGO_DISCARD_PHOTO; tenantId is a
positive customer ID and businessId is the positive cargo discard application ID.
File enforces composite ownership and non-empty JPG/PNG bytes with image signature and
10 MiB limit. Lastleg owns the pallet binding, submitted visibility and immutable confirmation.
No anonymous or public file endpoint is introduced.
paths: {}
@@ -0,0 +1,25 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class CargoDiscardPhotoStorageTest {
@TempDir Path root;
@Test void discardEvidenceIsCustomerOwnedAndRequiresRealImageSignature() throws Exception {
var category = BusinessFileCategory.require("OVERSEAS_CARGO_DISCARD_PHOTO");
category.validateOwner(7);
assertThatThrownBy(() -> category.validateOwner(0)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot();
byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10};
var result = storage.store(7, "11", new MockMultipartFile("file", "discard.png", "image/png", bytes), category);
assertThat(storage.read(result.objectKey())).isEqualTo(bytes);
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "fake.png", "image/png", "fake".getBytes()), category)).hasMessageContaining("不是有效图片");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "large.png", "image/png", new byte[10*1024*1024+1]), category)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
}
@@ -13,7 +13,7 @@ public final class ContainerOverviewDtos {
public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda,
List<Document> documents, ExceptionState exception, List<ExceptionHistory> exceptionHistory,
List<TimelineEntry> timeline) {}
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
public record ContainerBookingEntityView(Long id, String cabinetNo, String containerNo,
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName,
String departurePortCode, String departurePortName, String destinationPortCode,
@@ -48,7 +48,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
if (size == 0) size = 20;
}
}
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
public record WorkbenchContainer(Long bookingId, String cabinetNo, String containerNo,
Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType,
BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long planId, String planNo, String planStatus, Integer version) {}
@@ -19,7 +19,6 @@ import java.time.LocalDateTime;
@TableName("fl_container_booking")
public class ContainerBookingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String bookingNo;
private String containerNo;
private String cabinetNo;
private String sourceType;
@@ -335,7 +335,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
@Select("""
<script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
SELECT b.id bookingId,b.cabinet_no cabinetNo,b.container_no containerNo,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_group_code channelGroupCode,b.channel_code channelCode,
b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm,
b.max_weight_kg maxWeightKg,p.id planId,p.plan_no planNo,
@@ -2,6 +2,6 @@ package com.baiyue.firstleg.portschedule.dto;
import java.time.LocalDateTime;
public record ArrivalCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
public record ArrivalCalendarEntry(Long containerBookingId, String cabinetNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) {}
@@ -12,7 +12,7 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo,
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
@@ -42,7 +42,7 @@ public class ContainerOverviewService {
.toList();
PdaSummary pda = task == null ? null : pda(task);
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
return new Overview(new ContainerBookingEntityView(booking.getId(),
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(),
booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(),
@@ -63,7 +63,7 @@ public class ContainerService {
.map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.nextCabinetNo());
value.setCabinetNo(documentNumbers.nextCabinetNo());
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
@@ -19,7 +19,7 @@ import java.util.Set;
public class FirstlegDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "LP", "DP");
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
@@ -3,6 +3,6 @@ package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** Original pickup appointment wall time; the source does not establish a port time zone. */
public record PickupCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
public record PickupCalendarEntry(Long containerBookingId, String cabinetNo, String containerNo,
String destinationPortName, String destinationWarehouseCode, String fleetSupplierName,
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt) { }
@@ -15,7 +15,7 @@ import java.util.List;
@Mapper
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo,
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_terminal_drayage t JOIN fl_container_booking c ON c.id=t.container_booking_id
@@ -47,7 +47,7 @@ public class TerminalDrayageCalendarService {
for(var fact:shipping.containerActualFacts(ids.subList(start,Math.min(start+200,ids.size()))))
if(fact.pickedUpAt()!=null) actual.put(fact.containerBookingId(),fact.pickedUpAt());
}
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.bookingNo(),row.containerNo(),
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.cabinetNo(),row.containerNo(),
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))).toList();
return result(enriched,rows.size()>1000,unknown);
}
@@ -94,7 +94,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -35,7 +35,6 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
@@ -90,7 +89,6 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
@@ -895,7 +893,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_code <=> NEW.channel_code)
OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -1542,9 +1540,9 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
AND column_name IN ('cabinet_no', 'warehouse_id', 'bill_of_lading_no',
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 11 THEN
'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -28,7 +28,7 @@ paths:
type: object
properties:
containerBookingId: {type: string}
bookingNo: {type: string}
cabinetNo: {type: string}
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
@@ -2323,13 +2323,12 @@ components:
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
required: [id, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
@@ -2606,11 +2605,10 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
SmartLoadingWorkbenchContainer:
type: object
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode,
required: [bookingId, cabinetNo, warehouseId, warehouseName, channelGroupCode,
containerType, capacityCbm, maxWeightKg, planStatus, version]
properties:
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
bookingNo: {type: string, description: 货柜登记业务号}
cabinetNo: {type: string, description: 稳定柜次业务号}
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -74,7 +74,7 @@ components:
exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
Container: {type: object, properties: {id: {type: integer, format: int64}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
@@ -33,7 +33,7 @@ paths:
type: object
properties:
containerBookingId: {type: string, description: 仅用于来源详情定位}
bookingNo: {type: string}
cabinetNo: {type: string}
containerNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
@@ -128,7 +128,7 @@ class ContainerControllerContractTest {
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
.contains("containerNo: {type: string, nullable: true")
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("required: [id, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
.contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true");
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArrivalCalendarMapperTest {
@Test
void readsTheExistingCabinetNumberWithoutASecondContainerNumberColumn() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:arrival-cabinet-number;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT," +
"standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("INSERT INTO fl_container_booking VALUES(1,'D0007','TEST-CONT','Vessel','Voyage','Port'," +
"'US-W',NULL,'2026-11-25 08:00:00','ACTIVE')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(PortScheduleMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows = session.getMapper(PortScheduleMapper.class).arrivalCalendar(
LocalDateTime.parse("2026-11-01T00:00:00"), LocalDateTime.parse("2026-12-01T00:00:00"));
assertEquals(1, rows.size());
assertEquals("D0007", rows.get(0).cabinetNo());
assertEquals(1L, rows.get(0).containerBookingId());
}
}
}
@@ -13,6 +13,7 @@ import org.mockito.MockitoAnnotations;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -25,6 +26,7 @@ import static org.mockito.Mockito.when;
class SailingCalendarServiceTest {
@Mock private SailingCalendarRepository repository;
@InjectMocks private SailingCalendarService service;
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
@BeforeEach
void setUp() {
@@ -33,8 +35,8 @@ class SailingCalendarServiceTest {
@Test
void replaysAnExistingCopyInsteadOfReturningAnInternalError() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0));
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", copiedDeparture);
SailingHistoryEntity history = new SailingHistoryEntity();
history.setAction("COPY"); history.setReason("复制自SL-SOURCE"); history.setSailingId(2L);
when(repository.sailing(1L)).thenReturn(source);
@@ -45,7 +47,7 @@ class SailingCalendarServiceTest {
when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null);
var result = service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0)));
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3)));
assertThat(result.sailing().id()).isEqualTo(2L);
verify(repository, never()).insert(any(SailingEntity.class));
@@ -53,13 +55,13 @@ class SailingCalendarServiceTest {
@Test
void reportsBusinessConflictForAnUnrelatedExistingSailing() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0));
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", copiedDeparture);
when(repository.sailing(1L)).thenReturn(source);
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
assertThatThrownBy(() -> service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0))))
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3))))
.isInstanceOf(BizException.class)
.hasMessageContaining("已存在航次 QA-20261005");
verify(repository, never()).insert(any(SailingEntity.class));
@@ -67,11 +69,11 @@ class SailingCalendarServiceTest {
@Test
void rejectsCutoffAtOrAfterCopiedDeparture() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
when(repository.sailing(1L)).thenReturn(source);
assertThatThrownBy(() -> service.copy(1L, 7L, "admin",
new CopyRequest(LocalDateTime.of(2026, 10, 12, 10, 0), LocalDateTime.of(2026, 10, 12, 10, 0))))
new CopyRequest(copiedDeparture, copiedDeparture)))
.isInstanceOf(BizException.class)
.hasMessageContaining("收货截止必须早于开船");
verify(repository, never()).insert(any(SailingEntity.class));
@@ -52,7 +52,6 @@ class ContainerServiceTest {
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.nextCabinetNo()).thenReturn("D0001");
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
@@ -76,8 +75,9 @@ class ContainerServiceTest {
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
assertThat(saved.getCabinetNo()).isEqualTo("D0001");
verify(documentNumbers).nextCabinetNo();
verify(documentNumbers, never()).next("CB");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX");
@@ -41,7 +41,7 @@ class FirstlegDocumentNumberGeneratorTest {
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class);
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
assertThat(generator.nextCabinetNo()).isEqualTo("D0001");
@@ -627,7 +627,7 @@ class SmartLoadingServiceTest {
@Test
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
WorkbenchContainer container = new WorkbenchContainer(99L, "D0001", null,
100L, "义乌仓", "MATSON_CLX", null, null, "40HQ",
new BigDecimal("68"), new BigDecimal("19500"),
1L, "LP-1", "EDITING", 2);
@@ -71,10 +71,10 @@ class TaillegRealProducerAcceptanceTest {
}
private void seedCompletedLoadingPlan() {
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,container_no,cabinet_no,warehouse_id," +
"warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
"VALUES(?,?,?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ");
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
@@ -14,11 +14,11 @@ class TerminalDrayageCalendarMapperTest {
@Test void respectsExclusiveRangeActiveDepartureWithoutDuplicatingContainersOrOwningShippingFacts() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE fl_container_booking(id BIGINT,booking_no VARCHAR,container_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR,record_status VARCHAR)");
s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR,record_status VARCHAR)");
s.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,fleet_supplier_name VARCHAR,pickup_appointment_at TIMESTAMP)");
s.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
for(int id=1;id<=5;id++) {
s.execute("INSERT INTO fl_container_booking VALUES("+id+",'CB','CONT','Port','US-W','"+(id==4?"CANCELLED":"ACTIVE")+"')");
s.execute("INSERT INTO fl_container_booking VALUES("+id+",'D000"+id+"','CONT','Port','US-W','"+(id==4?"CANCELLED":"ACTIVE")+"')");
s.execute("INSERT INTO fl_terminal_drayage VALUES("+id+",'Fleet','"+(id==3?"2026-11-01 00:00:00":"2026-10-01 09:00:00")+"')");
s.execute("INSERT INTO fl_outbound_dispatch VALUES("+id+",'2026-09-01',"+(id==5?"'2026-09-02'":"NULL")+",'WAITING_FOR_LOADING')");
}
@@ -28,6 +28,7 @@ class TerminalDrayageCalendarMapperTest {
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(TerminalDrayageMapper.class).calendar(LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-11-01T00:00:00"),1001);
assertEquals(List.of(1L,2L),rows.stream().map(r->r.containerBookingId()).toList());assertNull(rows.get(0).actualPickupAt());
assertEquals("D0001", rows.get(0).cabinetNo());
}
}
}
@@ -18,7 +18,7 @@ class TerminalDrayageCalendarServiceTest {
final com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService shipping=mock(com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService.class);
final TerminalDrayageCalendarService service=new TerminalDrayageCalendarService(mapper,warehouses,shipping,"test");
final LocalDate from=LocalDate.of(2026,10,1),to=from.plusDays(31);
PickupCalendarEntry row(long id,String code) {return new PickupCalendarEntry(id,"CB-"+id,"TEST-CONT","Port",code,"Fleet",from.atTime(9,0),null);}
PickupCalendarEntry row(long id,String code) {return new PickupCalendarEntry(id,"D000"+id,"TEST-CONT","Port",code,"Fleet",from.atTime(9,0),null);}
@Test void batchesTheCatalogAndKeepsUnknownScopeVisibleWithoutGuessingPortTimeZone() {
when(mapper.calendar(any(),any(),eq(1001))).thenReturn(List.of(row(1,"US-W"),row(2,"CA-W"),row(3,"UNKNOWN")));
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(
+1
View File
@@ -17,6 +17,7 @@
| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 |
| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` |
| 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` |
| 平台自营领星统计权限 | `src/main/resources/db/selfoperated-analytics-permissions.sql`;自营业务下经营总览、销量分析、库存分析、数据采集菜单资源同步国内统一空库基线;四个页面和所有只读接口统一使用 `platform:self-operated-analytics:list`,采集写入独立使用 `platform:self-operated-analytics:sync`。菜单资源不替代服务端读写授权。 |
| 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` |
| 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 |
| 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` |
@@ -0,0 +1,19 @@
-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3));
+3
View File
@@ -0,0 +1,3 @@
# Copy to .env.local for local WMS connectivity verification.
LINGXING_OMS_APP_KEY=
LINGXING_OMS_APP_SECRET=
+1
View File
@@ -9,6 +9,7 @@
- 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java`
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
- 领星 WMS 自研系统出库只读查询:`controller/LingxingInternalController.java`、`provider/lingxing/`、`dto/lingxing/`、`config/LingxingWmsProperties.java`;内部契约 `openapi/lingxing-wms-v1.yaml` 位于 `src/main/resources/`。内部提供出库单页摘要、独立按单定位、出库详情、仓库目录及单仓产品库存单页查询;统一账号节流、有限退避和严格分页校验,保留未知数量与源时间原值。不编排定时采集、多页库存同步或业务持久化;本地隔离启动与环境变量入口见 `README.md`,真实调用默认关闭。
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
- 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md`
+102
View File
@@ -0,0 +1,102 @@
# Integration 本地领星 WMS 查询
本入口用于在现有 Java 服务内验证 WMS 只读调用。仅返回单页出库摘要,不启动月度采集、库存全量同步、定时任务或数据库写入。业务方案见[领星一件代发数据统计一期方案](../../docs/自营业务/领星一件代发数据统计一期方案.md)。
后续开发交接见[接口联调手册](../../docs/自营业务/领星WMS接口联调手册.md):已另行用只读 HTTP 验证仓库发现、一件代发详情和按仓产品库存全分页。此处现有 Java 接口仍只有出库摘要;库存内部接口、分仓任务和持久化尚待开发,不能以外部直连成功推定 Java 功能已经具备。
## 凭据与开关
从 WMS 管理后台的“个人中心 → API 信息 → 自研系统”获取 `appKey` 和 `appSecret`,填写本目录 Git 忽略的 `.env.local`,格式参考 [.env.example](.env.example)。这两个值分别加载为 `LINGXING_OMS_APP_KEY` 和 `LINGXING_OMS_APP_SECRET` 进程环境变量,不提交真实值。Spring 本身不会自动读取 `.env.local`,应在启动 Java 的同一终端加载。该凭据属于 OMP 服务商内部系统对接,不使用 OMS 货主或 WMS 智能设备协议。
正式应用配置默认 `LINGXING_WMS_ENABLED=false`。下面的本地命令显式启用适配器,为该本地实例生成内部调用令牌。不要复用共享环境令牌。
测试部署通过 `deploy/docker-compose.yml` 将 `LINGXING_OMS_APP_KEY`、`LINGXING_OMS_APP_SECRET` 传入 Integration 容器。现有 Jenkins 下游部署任务读取 `firstleg-test-env` Secret File;可将这两项加入该文件,或在实际执行部署的下游服务任务中以凭据绑定注入同名环境变量。只在上游总控任务设置变量不会自动传给下游任务。变量命名采用用户指定的 `OMS`,接口协议仍为当前 WMS 自研系统(OMP);真实凭据是否有该接口权限仍待联调核验。不要将密钥作为普通构建参数或打印完整 Compose 配置。
2026-10-12 用户确认唯一账号、所有源时间按北京时间、数量按 SKU 件数。Lastleg 应用配置使用 `source-account=lingxing-primary`(内部稳定标识,不是 appKey)、`source-query-timezone=Asia/Shanghai`、`quantity-unit=PCS`,分仓配置的源时区默认 `Asia/Shanghai`;数量仍保留十进制精度。仓库编码须在获得真实仓库目录后配置到 `baiyue.self-operated-analytics.warehouses`,不预填虚构仓库。`SELFOP_EXECUTION_ENABLED` 和 `SELFOP_SALES_PUBLICATION_ENABLED` 均默认关闭。
## 本地启动
先在后端仓库根目录执行构建,包含相关模块的编译与测试:
```powershell
mvn -pl integration-service -am package
```
然后在同一目录的 PowerShell 中执行。只加载明确允许的两个名称,不执行环境文件内的代码:
```powershell
$credentialNames = @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET')
foreach ($line in [IO.File]::ReadAllLines((Join-Path (Get-Location) 'integration-service/.env.local'))) {
if ($line -match '^\s*(LINGXING_OMS_APP_KEY|LINGXING_OMS_APP_SECRET)\s*=(.*)$') {
$credentialName = $matches[1]
$credentialValue = $matches[2].Trim()
if ($credentialValue.Length -ge 2 -and
(($credentialValue.StartsWith('"') -and $credentialValue.EndsWith('"')) -or
($credentialValue.StartsWith("'") -and $credentialValue.EndsWith("'")))) {
$credentialValue = $credentialValue.Substring(1, $credentialValue.Length - 2)
}
[Environment]::SetEnvironmentVariable($credentialName, $credentialValue, 'Process')
}
}
foreach ($credentialName in $credentialNames) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($credentialName, 'Process'))) {
throw "Missing credential: $credentialName"
}
}
$env:INTERNAL_SERVICE_TOKEN = [guid]::NewGuid().ToString()
$localInternalToken = $env:INTERNAL_SERVICE_TOKEN
$env:INTEGRATION_LOCAL_PORT = '19210'
$buildJar = (Resolve-Path 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar').Path
$localJava = Start-Process -FilePath (Get-Command java).Source -WindowStyle Hidden -PassThru `
-ArgumentList @('-jar', ('"' + $buildJar + '"'),
'--spring.config.location=classpath:/application-lingxing-local.yml',
'--baiyue.integration.lingxing-wms.enabled=true') `
-RedirectStandardOutput 'integration-service/target/lingxing-local.stdout.log' `
-RedirectStandardError 'integration-service/target/lingxing-local.stderr.log'
```
该配置仅监听 `127.0.0.1`,关闭 Nacos Config、Discovery、服务注册及 Spring 定时任务,不导入共享环境配置。若端口占用,选择其他本地端口并同步替换后续 URL,不停止未知进程。确认 `http://127.0.0.1:19210/actuator/health` 返回 `UP` 后再查询。
## 读取一条出库单
在保留上述变量的同一 PowerShell 中执行一次:
```powershell
$businessZone = [TimeZoneInfo]::FindSystemTimeZoneById('China Standard Time')
$queryEnd = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $businessZone)
$query = @{
startTime = $queryEnd.AddMonths(-1).ToString('yyyy-MM-ddTHH:mm:ss')
endTime = $queryEnd.ToString('yyyy-MM-ddTHH:mm:ss')
current = 1
size = 1
} | ConvertTo-Json -Compress
$result = Invoke-RestMethod -Method Post `
-Uri 'http://127.0.0.1:19210/internal/integrations/lingxing/wms/outbound-orders/query' `
-Headers @{'X-Internal-Service-Token' = $localInternalToken} `
-ContentType 'application/json' -Body $query
$result | ConvertTo-Json -Depth 6
```
返回摘要只包含出库单号、来源单号、货主编码、仓库编码、状态、创建时间和出库时间,不返回收件人或地址。业务 `code="200"` 且 `records` 包含一条才表示已读到样本;空页表示调用成功但未取得样本;`B0001`/`S0503` 表示明确失败,保留 `errorCode`、来源数值错误码及请求标识排查,不能当作零销量。
当前调用 OMP `POST /openapi/v2/order/delivery/page`,固定 `timeType=outboundTime`、`status=100` 已出库,窗口最多向前一个自然月。该本地保护不代表供应商历史上限;单页样本也不代表整月采集完成。订单详情、全量库存、增量任务和统计落库仍按业务方案后续实现。
测试完毕,在同一终端停止本次启动的进程,并清理当前终端注入的变量:
```powershell
if (-not $localJava.HasExited) { Stop-Process -Id $localJava.Id }
foreach ($credentialName in @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET', 'INTERNAL_SERVICE_TOKEN')) {
[Environment]::SetEnvironmentVariable($credentialName, $null, 'Process')
}
$localInternalToken = $null
$credentialValue = $null
```
## 接口与验证
- 2026-10-12 已通过本地 Java 实例调用 OMP WMS 出库分页,按最近 1 个月、`current=1`、`size=1` 成功返回 1 条已出库摘要;无内部令牌请求被拒绝。验证后本地进程已停止。该结果仅证明当前只读链路可用,不代表全量采集或库存同步完成。
- 实测成功响应为数值 `code=200`,但缺少官方示例及 Schema 中的 `success`。客户端以成功码和有效分页结构共同判定;`success` 出现时仍须为布尔 `true`。此差异已加入回归测试,失败响应不转换为空页。
- 内部前缀为 `POST /internal/integrations/lingxing/wms`,提供 `/outbound-orders/query`、`/outbound-orders/locate`、`/outbound-orders/detail`、`/warehouses/query`、`/product-inventory/query`;仅接受 `X-Internal-Service-Token`,不经公网 Gateway。各请求只返回一页或一单,多页与业务版本归 Lastleg 编排。定位不传时间/状态;产品库存必须指定仓库、保留全部品质和零库存。源时间原值与 BigDecimal 数量未知值保留,不推断时区或单位。
- [OpenAPI](src/main/resources/openapi/lingxing-wms-v1.yaml)定义输入、分页摘要和失败结构。
- 从后端根执行 `mvn -pl integration-service -am test` 与 `node --test scripts/gateway-route-contract.test.mjs`。
- 本地模拟 HTTP 单测验证签名、请求体、敏感信息隔离和错误处理;不能代替真实领星样本验证。官方入口为[服务商内部系统对接](https://apidoc-omp.xlwms.com/)与[一件代发出库单 WMS 分页查询](https://apidoc-omp.xlwms.com/reference/post_openapi-v2-order-delivery-page-1)。
@@ -0,0 +1,37 @@
package com.baiyue.integration.config;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
/** Exclusive grant identity; externalExclusiveUntil bounds VALIDATION only, not BUSINESS. */
@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress")
public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode,
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
@DefaultValue("5m") Duration cooldownFallback,
@DefaultValue("false") boolean continuousBusinessCollectionEnabled) {
public LingxingEgressProperties {
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
environmentLabel = environmentLabel == null ? "test" : environmentLabel;
minStartInterval = minStartInterval == null ? Duration.ofSeconds(5) : minStartInterval;
cooldownFallback = cooldownFallback == null ? Duration.ofMinutes(5) : cooldownFallback;
Duration minimumInterval = collectionMode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1);
if (minStartInterval.compareTo(minimumInterval) < 0 || minStartInterval.compareTo(Duration.ofMinutes(5)) > 0
|| cooldownFallback.compareTo(Duration.ofSeconds(5)) < 0 || cooldownFallback.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("领星 VALIDATION 出口间隔至少5秒,其他模式至少1毫秒;间隔和冷却配置须为有界正值");
}
if (budgetAuthorityBaseUri != null && (budgetAuthorityBaseUri.getHost() == null
|| !("http".equals(budgetAuthorityBaseUri.getScheme()) || "https".equals(budgetAuthorityBaseUri.getScheme()))
|| budgetAuthorityBaseUri.getUserInfo() != null || budgetAuthorityBaseUri.getQuery() != null
|| budgetAuthorityBaseUri.getFragment() != null || !budgetAuthorityBaseUri.getPath().isEmpty())) {
throw new IllegalArgumentException("领星预算授权地址必须为受控内部服务源地址");
}
}
@Override public String toString() {
return "LingxingEgressProperties[collectionMode=" + collectionMode + ", environmentLabel=" + environmentLabel + "]";
}
}
@@ -0,0 +1,6 @@
package com.baiyue.integration.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/** Feature gate only. Explicit, durable grants and deadlines are owned by Lastleg. */
@ConfigurationProperties("baiyue.integration.lingxing-wms.quantity-capture")
public record LingxingQuantityCaptureProperties(@DefaultValue("false") boolean enabled,String sourceAccount) {}
@@ -0,0 +1,46 @@
package com.baiyue.integration.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.net.URI;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms")
public record LingxingWmsProperties(
@DefaultValue("false") boolean enabled,
@DefaultValue("https://api.xlwms.com") URI baseUri,
String appKey,
String appSecret,
@DefaultValue("5s") Duration connectTimeout,
@DefaultValue("15s") Duration requestTimeout
) {
public LingxingWmsProperties {
baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri;
connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout;
requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout;
if (connectTimeout.isZero() || connectTimeout.isNegative()
|| requestTimeout.isZero() || requestTimeout.isNegative()) {
throw new IllegalArgumentException("领星 WMS 超时必须为正数");
}
String host = baseUri.getHost();
boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host)
|| "[::1]".equals(host);
boolean secureScheme = "https".equalsIgnoreCase(baseUri.getScheme())
|| ("http".equalsIgnoreCase(baseUri.getScheme()) && loopback);
if (host == null || !secureScheme || baseUri.getUserInfo() != null
|| baseUri.getQuery() != null || baseUri.getFragment() != null
|| (!baseUri.getPath().isEmpty() && !"/".equals(baseUri.getPath()))) {
throw new IllegalArgumentException("领星 WMS base-uri 必须是 HTTPS 源地址;仅本机测试允许 HTTP");
}
}
public boolean hasCredentials() {
return appKey != null && !appKey.isBlank() && appSecret != null && !appSecret.isBlank();
}
@Override
public String toString() {
return "LingxingWmsProperties[enabled=" + enabled + ", credentialsConfigured=" + hasCredentials() + "]";
}
}
@@ -0,0 +1,32 @@
package com.baiyue.integration.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
import com.baiyue.integration.provider.lingxing.LingxingEgressGate;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
/** Internal token is checked before the active gate is inspected. Never calls the provider. */
@RestController
public class LingxingEgressRuntimeController {
private final InternalServiceTokenGuard guard;
private final LingxingEgressGate gate;
private final LingxingWmsProperties provider;
public LingxingEgressRuntimeController(InternalServiceTokenGuard guard, LingxingEgressGate gate,
LingxingWmsProperties provider) {
this.guard = guard; this.gate = gate; this.provider = provider;
}
@Operation(summary = "读取实际领星出口绑定配置的脱敏快照,无供应商或许可副作用")
@GetMapping("/internal/integrations/lingxing/wms/egress/runtime")
public ApiResponse<LingxingEgressRuntimeSnapshot> runtime(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token) {
guard.verify(token);
return ApiResponse.success(gate.runtimeSnapshot(provider.enabled()));
}
}
@@ -0,0 +1,118 @@
package com.baiyue.integration.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.core.JsonParser;
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "领星 WMS 内部查询", description = "仅内部服务令牌认证,不提供公网路由")
@RestController
@RequestMapping("/internal/integrations/lingxing/wms")
public class LingxingInternalController {
private final InternalServiceTokenGuard tokenGuard;
private final LingxingWmsClient client;
private final ObjectMapper mapper;
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client, ObjectMapper mapper) {
this.tokenGuard = tokenGuard;
this.client = client;
this.mapper = mapper;
}
@Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要")
@PostMapping("/outbound-orders/query")
public ApiResponse<PageResult> query(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody QueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryPage(request, context(context)));
}
@Operation(summary = "读取正常仓库目录单页")
@PostMapping("/warehouses/query")
public ApiResponse<QueryPage<Warehouse>> warehouses(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody WarehouseQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryWarehouses(request, context(context)));
}
@Operation(summary = "按单号定位单页,不附加时间或状态筛选")
@PostMapping("/outbound-orders/locate")
public ApiResponse<PageResult> locate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody LocateRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.locate(request, context(context)));
}
@Operation(summary = "按仓库、货主、发货单号读取完整 SKU 详情")
@PostMapping("/outbound-orders/detail")
public ApiResponse<OrderDetail> detail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody DetailRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryDetail(request, context(context)));
}
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
@PostMapping("/product-inventory/query")
public ApiResponse<QueryPage<ProductStock>> inventory(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody InventoryQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryInventory(request, context(context)));
}
private EgressContext context(String header) {
if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126))
throw invalidContext();
try {
EgressContext context = mapper.readerFor(EgressContext.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
DeserializationFeature.FAIL_ON_TRAILING_TOKENS).with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION).readValue(header);
if (context == null || !context.wellFormed()) throw invalidContext();
return context;
} catch (Exception ignored) { throw invalidContext(); }
}
private static LingxingWmsException invalidContext() {
return new LingxingWmsException("LINGXING_WMS_EGRESS_CONTEXT_INVALID", "领星安全出口控制上下文缺失或无效", false, null, null);
}
@ExceptionHandler(LingxingWmsException.class)
public ApiResponse<ProviderFailure> handleProviderFailure(LingxingWmsException exception,
HttpServletRequest request) {
ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR;
return ApiResponse.of(code, exception.getMessage(),
new ProviderFailure(exception.code(), exception.retryable(),
exception.upstreamStatus(), exception.sourceCode(), exception.egress() == null
? new AttemptMetadata(null, 0, false, null) : exception.egress(), exception.denial()),
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
}
public record ProviderFailure(String errorCode, boolean retryable,
Integer upstreamStatus, Integer sourceCode, AttemptMetadata egress, AuthorizationDenial denial) { }
}
@@ -0,0 +1,31 @@
package com.baiyue.integration.dto.lingxing;
import java.time.Instant;
/** Control identities are supplied by the collector and revalidated by its durable authority. */
public final class LingxingEgressDtos {
private LingxingEgressDtos() { }
public enum CollectionMode { OFF, VALIDATION, BUSINESS }
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY }
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
String sessionId, String attemptId, String exclusiveGrantId) {
public boolean wellFormed() {
return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS)
&& identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128)
&& identifier(attemptId, 128) && identifier(exclusiveGrantId, 128);
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
}
public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { }
public record AttemptPermit(EgressContext context, Operation operation, String requestHash,
int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed,
boolean budgetConsumed) { }
public record CompletionRequest(EgressContext context, Operation operation, String requestHash,
int actualHttpAttempts, String errorCode, Integer upstreamStatus,
Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { }
public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused,
Instant cooldownUntil) { }
public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { }
}
@@ -0,0 +1,9 @@
package com.baiyue.integration.dto.lingxing;
import java.time.Instant;
/** Bound runtime configuration only. No credentials, addresses or execution attestation. */
public record LingxingEgressRuntimeSnapshot(int schemaVersion, String service, Instant observedAt,
String mode, String environment, boolean continuousEnabled, String sourceAccountFingerprint,
boolean grantPresent, String grantFingerprint, long intervalMs, int permitMaxSeconds,
boolean providerEnabled, String budgetAuthorityFingerprint) { }
@@ -0,0 +1,56 @@
package com.baiyue.integration.dto.lingxing;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
public final class LingxingWmsDtos {
private LingxingWmsDtos() { }
public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime,
@Min(1) int current, @Min(1) @Max(100) int size) { }
public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo,
@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotBlank @Size(max=64) String customerCode,
@NotBlank @Size(max=128) String deliveryNo) { }
public record InventoryQueryRequest(@NotBlank @Size(max=64) String warehouseCode,
@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record Warehouse(String whCode, String whNameCn, Integer warehouseType,
Integer status, String timeZone) { }
public record SkuLine(String productSku, String productName, BigDecimal actualDeliveryQty,
BigDecimal deliveryQty) { }
public record OrderDetail(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String orderCreateTime, String outboundTime,
List<SkuLine> skuVOList, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public OrderDetail { skuVOList = List.copyOf(skuVOList); }
}
public record ProductStock(String customerCode, String whCode, String productSku, String productName,
Integer productQuality, BigDecimal stkQty, BigDecimal freezeQty,
BigDecimal useStkQty, BigDecimal transitQty, String innerBatchNo,
String outBatchNo, String uniqueKey4tenant) { }
public record QueryPage<T>(List<T> records, Long total, int current, int size, int pages,
int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public QueryPage { records = List.copyOf(records); }
}
// Preserve source strings; the collector interprets confirmed Asia/Shanghai source time.
public record ListSku(String productSku, String productName, BigDecimal qty) { }
public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String createTime, String outboundTime, List<ListSku> productList) { }
public record PageResult(List<OrderSummary> records, Long total, int current, int size,
int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public PageResult { records = List.copyOf(records); }
}
}
@@ -0,0 +1,9 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */
public interface LingxingBudgetAuthority {
AttemptPermit authorize(AuthorizationRequest request);
void complete(CompletionRequest request);
}
@@ -0,0 +1,11 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */
public final class LingxingBudgetDeniedException extends RuntimeException {
private final AuthorizationDenial denial;
public LingxingBudgetDeniedException(AuthorizationDenial denial) {
super("领星持久预算授权拒绝"); this.denial = denial;
}
public AuthorizationDenial denial() { return denial; }
}
@@ -0,0 +1,170 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Clock;
import java.time.Instant;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/** Local serialization supplements the exclusive grant and durable account protection. */
@Component
public final class LingxingEgressGate {
private static final int PERMIT_MAX_SECONDS = 30;
private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class);
private final LingxingEgressProperties properties;
private final LingxingBudgetAuthority authority;
private final Clock clock;
private final Waiter waiter;
private final Semaphore inFlight = new Semaphore(1, true);
private volatile boolean paused;
private volatile boolean recoverableRatePause;
private Instant lastStart;
private Instant cooldownUntil;
@Autowired public LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority) {
this(properties, authority, Clock.systemUTC(), Thread::sleep);
}
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
}
/** The very properties and clock used by execute; no semaphore, authority, budget or HTTP work. */
public LingxingEgressRuntimeSnapshot runtimeSnapshot(boolean providerEnabled) {
String grant = properties.externalExclusiveGrantId();
return new LingxingEgressRuntimeSnapshot(1, "integration", clock.instant(),
properties.collectionMode().name(), properties.environmentLabel(),
properties.continuousBusinessCollectionEnabled(), fingerprint("source-account", properties.sourceAccount()),
grant != null && !grant.isBlank(), fingerprint("grant", grant), properties.minStartInterval().toMillis(),
PERMIT_MAX_SECONDS, providerEnabled, fingerprint("budget-authority", properties.budgetAuthorityBaseUri() == null ? null
: properties.budgetAuthorityBaseUri().toString()));
}
private static String fingerprint(String domain, String value) {
if (value == null || value.isBlank()) return null;
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA256_UNAVAILABLE", impossible); }
}
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
try { return guarded(context, operation, hash, call); }
catch (LingxingWmsException error) {
if (error.egress() == null) error.withEgress(new AttemptMetadata(
context != null && identifier(context.attemptId(), 128) ? context.attemptId() : null, 0, paused, cooldownUntil));
throw error;
}
}
private <T> T guarded(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
check(context);
if (operation == null || hash == null || !hash.matches("[a-f0-9]{64}")) throw failure("EGRESS_CONTEXT_INVALID");
boolean acquired = false;
try {
while (!(acquired = inFlight.tryAcquire(100, TimeUnit.MILLISECONDS))) check(context);
check(context);
if (lastStart != null) {
Instant next = lastStart.plus(properties.minStartInterval());
long wait = java.time.Duration.between(clock.instant(), next).toMillis();
if (wait > 0) waiter.sleep(wait + 1);
}
check(context);
AuthorizationRequest request = new AuthorizationRequest(context, operation, hash);
AttemptPermit permit;
try { permit = authority.authorize(request); }
catch (LingxingBudgetDeniedException knownDecision) {
throw failure("BUDGET_AUTHORIZATION_DENIED").withDenial(knownDecision.denial());
}
catch (RuntimeException error) { recoverableRatePause = false; paused = true; throw failure("BUDGET_AUTHORITY_UNAVAILABLE"); }
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(PERMIT_MAX_SECONDS))
|| (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) {
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
}
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state.
// Time alone never clears the pause. VALIDATION and uncertain callbacks cannot reach this branch.
if (paused && recoverableRatePause && properties.collectionMode() == CollectionMode.BUSINESS) {
paused = false; recoverableRatePause = false; cooldownUntil = null;
}
AttemptHandle handle = new AttemptHandle(context, permit.expiresAt());
T result = null;
LingxingWmsException failure = null;
try { result = call.run(handle); }
catch (LingxingWmsException error) { failure = error; }
catch (RuntimeException error) { failure = failure("LOCAL_PROCESSING_FAILED"); }
Instant callFinishedAt = clock.instant();
CompletionRequest completion = new CompletionRequest(context, operation, hash, handle.actualHttpAttempts,
failure == null ? null : failure.code(), failure == null ? Integer.valueOf(200) : failure.upstreamStatus(),
failure == null ? Integer.valueOf(200) : failure.sourceCode(), paused, cooldownUntil);
boolean completionConfirmed = false;
try { authority.complete(completion); completionConfirmed = true; }
catch (RuntimeException error) {
recoverableRatePause = false; paused = true;
// Unconfirmed completion must remain distinguishable even when the provider already failed.
failure = failure("BUDGET_COMPLETION_UNCONFIRMED");
}
log.debug("lingxingAttempt operation={} start={} end={} upstreamStatus={} sourceCode={} errorCode={} completionConfirmed={}",
operation, handle.startedAt, callFinishedAt, completion.upstreamStatus(), completion.sourceCode(),
completion.errorCode(), completionConfirmed);
if (failure != null) throw failure.withEgress(handle.metadata());
return result;
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); throw failure("INTERRUPTED");
} finally { if (acquired) inFlight.release(); }
}
private void check(EgressContext context) {
if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF");
if (properties.collectionMode() == CollectionMode.BUSINESS
&& !("production".equals(properties.environmentLabel()) || "test".equals(properties.environmentLabel())))
throw failure("BUSINESS_ENVIRONMENT_REJECTED");
if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS
|| cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED");
if (context == null || context.executionPurpose() != properties.collectionMode()
|| !context.wellFormed()
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank()
|| !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| (!business() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())))
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
private boolean business() {
// The account authority validates the original task identity, including manual roots.
// BUSINESS has no operator session cutoff; each individual permit remains finite.
return properties.collectionMode() == CollectionMode.BUSINESS;
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
private static LingxingWmsException failure(String code) {
return new LingxingWmsException("LINGXING_WMS_" + code, "领星安全出口拒绝请求(" + code + ")", false, null, null);
}
public final class AttemptHandle {
private final EgressContext context;
private final Instant expiresAt;
private int actualHttpAttempts;
private Instant startedAt;
private AttemptHandle(EgressContext context, Instant expiresAt) { this.context = context; this.expiresAt = expiresAt; }
/** Immediately before send, after signing. One permit can never send twice. */
public void startHttp() {
check(context);
if (actualHttpAttempts != 0 || !clock.instant().isBefore(expiresAt)) throw failure("ATTEMPT_NOT_SENDABLE");
actualHttpAttempts = 1; lastStart = clock.instant(); startedAt = lastStart;
}
public void rateLimited(Instant retryAfter) {
Instant fallback = clock.instant().plus(properties.cooldownFallback());
cooldownUntil = retryAfter != null && retryAfter.isAfter(fallback) ? retryAfter : fallback;
recoverableRatePause = properties.collectionMode() == CollectionMode.BUSINESS;
paused = true;
}
public AttemptMetadata metadata() { return new AttemptMetadata(context.attemptId(), actualHttpAttempts, paused, cooldownUntil); }
}
@FunctionalInterface interface Waiter { void sleep(long millis) throws InterruptedException; }
@FunctionalInterface public interface AuthorizedCall<T> { T run(AttemptHandle handle); }
}
@@ -0,0 +1,73 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */
@Component
public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority {
private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/";
private final LingxingEgressProperties properties;
private final ObjectMapper mapper;
private final String internalToken;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER).build();
public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.properties = properties; this.mapper = mapper; this.internalToken = internalToken;
}
@Override public AttemptPermit authorize(AuthorizationRequest request) {
JsonNode data = post("authorize", request);
if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber()
|| data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean()
|| !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean()
|| !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject()
|| !data.path("expiresAt").isTextual()) throw unavailable();
try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); }
catch (Exception ignored) { throw unavailable(); }
}
@Override public void complete(CompletionRequest request) {
JsonNode data = post("complete", request);
if (!data.isBoolean() || !data.booleanValue()) throw unavailable();
}
private JsonNode post(String suffix, Object payload) {
if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank()
|| internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable();
try {
HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix))
.timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken)
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) throw unavailable();
JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body());
if (root == null || !root.isObject() || !root.path("code").isTextual()
|| !root.hasNonNull("data")) throw unavailable();
if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) {
JsonNode data = root.get("data");
if (!data.isObject() || !data.path("errorCode").isTextual()
|| !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")
|| !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable();
AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper));
throw new LingxingBudgetDeniedException(denial);
}
if (!"200".equals(root.path("code").textValue())) throw unavailable();
return root.get("data");
} catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision;
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); throw unavailable();
} catch (Exception ignored) { throw unavailable(); }
}
private static LingxingWmsException unavailable() {
return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null);
}
}
@@ -0,0 +1,122 @@
package com.baiyue.integration.provider.lingxing;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
import com.baiyue.integration.config.LingxingQuantityCaptureProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.EgressContext;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.Operation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
/** Passive whitelist snapshots only. No JSON serialization, HTTP or waiting locks on egress threads. */
@Component
public final class LingxingQuantityCapture {
private static final int MAX_BYTES=2097152,MAX_ROWS=2000,MAX_QUEUE=100;
private final LingxingQuantityCaptureProperties config;private final Sink sink;private final Clock clock;
private final String producer=UUID.randomUUID().toString();
private final ScheduledExecutorService worker;
private final ReentrantLock intake=new ReentrantLock();
private final ArrayDeque<Queued> queue=new ArrayDeque<>();
private final Set<String> selected=new HashSet<>(),details=new HashSet<>();
private final AtomicReference<String> failure=new AtomicReference<>();
private String observedRoot;
private volatile View grant;private volatile boolean closed;private int bytes,rows;private boolean attached;
@Autowired public LingxingQuantityCapture(LingxingQuantityCaptureProperties config,ObjectMapper mapper,ObjectProvider<Sink> sink){this(config,mapper,sink.getIfAvailable(),Clock.systemUTC());}
LingxingQuantityCapture(LingxingQuantityCaptureProperties config,ObjectMapper mapper,Sink sink,Clock clock){this(config,sink,clock,true);}
LingxingQuantityCapture(LingxingQuantityCaptureProperties config,Sink sink,Clock clock,boolean start){
this.config=config;this.sink=sink;this.clock=clock;
worker=Executors.newSingleThreadScheduledExecutor(r->{Thread t=new Thread(r,"lingxing-quantity-capture");t.setDaemon(true);return t;});
if(start&&config.enabled()&&sink!=null)worker.scheduleWithFixedDelay(this::pump,0,2,TimeUnit.SECONDS);
}
public void observe(JsonNode data,Operation operation,EgressContext context,Instant observedAt){
View current=grant;
if(closed||!config.enabled()||failure.get()!=null||current==null||"TERMINAL".equals(current.state())||"DRAINING".equals(current.state())||!clock.instant().isBefore(current.expiresAt()))return;
if(context==null||!context.wellFormed()||!"BUSINESS".equals(String.valueOf(context.executionPurpose()))||!Objects.equals(config.sourceAccount(),context.sourceAccount()))return;
if(operation!=Operation.OUTBOUND_QUERY&&operation!=Operation.OUTBOUND_DETAIL)return;
if(current.rootTaskId()!=null&&!current.rootTaskId().equals(context.rootTaskId())||Objects.equals(current.excludedRootTaskId(),context.rootTaskId()))return;
if(!intake.tryLock()){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
try{
if(current!=grant){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
if(operation==Operation.OUTBOUND_DETAIL)snapshot(data,true,context,observedAt,current);
else {JsonNode records=data.path("records");if(!records.isArray()||records.size()>100){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
for(JsonNode row:records){if(failure.get()!=null)break;snapshot(row,false,context,observedAt,current);}}
}catch(RuntimeException bad){failure.compareAndSet(null,"OBSERVER_FAILED");}finally{intake.unlock();}
}
private void snapshot(JsonNode data,boolean detail,EgressContext c,Instant time,View g){
String warehouse=text(data,"whCode",64),owner=text(data,"customerCode",128),order=text(data,"deliveryNo",128);
if(!g.warehouseCodes().contains(warehouse)||!("*".equals(g.ownerCode())||g.ownerCode().equals(owner)))return;
if(observedRoot!=null&&!observedRoot.equals(c.rootTaskId()))return;
String key=key(c.rootTaskId(),warehouse,owner,order);
if(detail&&(!selected.contains(key)||details.contains(key)))return;
if(!detail&&(selected.contains(key)||selected.size()>=100))return;
JsonNode items=data.path(detail?"skuVOList":"productList");boolean complete=items.isArray()&&!items.isEmpty();
if(items.size()>200){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
// Conservative UTF-8 bound: identifiers <=128 chars, quantity <=38 digits, bounded metadata.
int retained=4096+items.size()*1024;
if(queue.size()>=MAX_QUEUE||rows+items.size()>MAX_ROWS||bytes+retained>MAX_BYTES){failure.compareAndSet(null,"QUEUE_FULL");return;}
var quantities=new TreeMap<String,Sku>();
if(items.isArray())for(JsonNode item:items){String sku=text(item,"productSku",128);
Sku value=new Sku(sku,detail?null:quantity(item.get("qty")),detail?quantity(item.get("deliveryQty")):null,detail?quantity(item.get("actualDeliveryQty")):null);
quantities.merge(sku,value,(a,b)->new Sku(sku,sum(a.listQty(),b.listQty()),sum(a.forecastQty(),b.forecastQty()),sum(a.actualQty(),b.actualQty())));
}
String status=data.path("status").isIntegralNumber()?data.path("status").asText():null;
if(status!=null&&status.length()>32)throw new IllegalArgumentException();
Event event=new Event(g.diagnosticSessionId(),c.sourceAccount(),producer,c.rootTaskId(),c.sessionId(),detail?"OUTBOUND_DETAIL":"OUTBOUND_QUERY",warehouse,owner,order,detail?"DETAIL":"LIST",new Observation(status,optional(data,"outboundTime",64),time,c.attemptId(),complete),List.copyOf(quantities.values()));
queue.addLast(new Queued(event,retained,items.size()));bytes+=retained;rows+=items.size();
if(detail)details.add(key);else {selected.add(key);observedRoot=c.rootTaskId();}
}
/** Single worker owns HTTP; status(drained) occurs only after previously queued observations finish. */
void pump(){
if(closed||!config.enabled()||sink==null)return;
try{
if(grant==null||"TERMINAL".equals(grant.state())){
View next=sink.status(new Status(config.sourceAccount(),producer,!attached,false));attached=true;
if(next==null)return;
if(grant==null||!grant.diagnosticSessionId().equals(next.diagnosticSessionId()))reset(next);else grant=next;
}
if(grant==null||"TERMINAL".equals(grant.state()))return;
if(failure.get()!=null){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;}
while(true){
if(failure.get()!=null)break;
Queued next=poll();if(next==null)break;
try{
if(!clock.instant().isBefore(grant.expiresAt())){failure.compareAndSet(null,"INCOMPLETE");break;}
Receipt receipt=sink.observe(next.event());if(receipt==null||receipt.session()==null)throw new IllegalStateException();
grant=receipt.session();
if("TERMINAL".equals(grant.state())){clearQueue();break;}
}finally{release(next);}
}
if(failure.get()!=null&&!"TERMINAL".equals(grant.state())){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;}
if(!"TERMINAL".equals(grant.state())){
View next=sink.status(new Status(config.sourceAccount(),producer,false,false));if(next==null)throw new IllegalStateException();grant=next;
if("TERMINAL".equals(next.state()))clearQueue();
// After DRAINING is visible to intake, a later pump drains its tail before acknowledging.
if("DRAINING".equals(next.state())&&empty())grant=sink.status(new Status(config.sourceAccount(),producer,false,true));
}
}catch(Exception failed){failure.compareAndSet(null,"INCOMPLETE");}
}
private Queued poll(){intake.lock();try{return queue.pollFirst();}finally{intake.unlock();}}
private void release(Queued q){intake.lock();try{bytes-=q.bytes();rows-=q.rows();}finally{intake.unlock();}}
private boolean empty(){intake.lock();try{return queue.isEmpty();}finally{intake.unlock();}}
private void clearQueue(){intake.lock();try{for(Queued q:queue){bytes-=q.bytes();rows-=q.rows();}queue.clear();}finally{intake.unlock();}}
private void reset(View v){intake.lock();try{queue.clear();bytes=0;rows=0;selected.clear();details.clear();failure.set(null);observedRoot=v.rootTaskId();
for(Key k:v.selected()){String key=key(v.rootTaskId(),k.warehouseCode(),k.ownerCode(),k.orderNo());selected.add(key);if(k.paired())details.add(key);}grant=v;
}finally{intake.unlock();}}
private static String key(String root,String warehouse,String owner,String order){return root+"\u001f"+warehouse+"\u001f"+owner+"\u001f"+order;}
public String stopReason(){return failure.get()!=null?failure.get():grant==null?null:grant.stopReason();}
@PreDestroy public void close(){closed=true;worker.shutdownNow();clearQueue();}
private static String text(JsonNode n,String field,int max){String s=optional(n,field,max);if(s==null||s.isBlank())throw new IllegalArgumentException();return s;}
private static String optional(JsonNode n,String field,int max){JsonNode v=n.get(field);if(v==null||v.isNull())return null;if(!v.isTextual()||v.textValue().length()>max||v.textValue().codePoints().anyMatch(Character::isISOControl))throw new IllegalArgumentException();return v.textValue();}
private static BigDecimal quantity(JsonNode n){if(n==null||!n.isNumber())return null;BigDecimal value=n.decimalValue();return value.signum()<0||value.scale()>6||value.precision()-value.scale()>32?null:value.stripTrailingZeros();}
private static BigDecimal sum(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);}
private record Queued(Event event,int bytes,int rows){}
}
@@ -0,0 +1,17 @@
package com.baiyue.integration.provider.lingxing;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/** Internal diagnostic whitelist; excludes raw payloads, addresses and supplier credentials. */
public final class LingxingQuantityEvidence {
private LingxingQuantityEvidence(){}
public record Status(String sourceAccount,String producerId,boolean attach,boolean drained){}
public record Stop(String diagnosticSessionId,String sourceAccount,String producerId,String reason){}
public record Observation(String status,String outboundTime,Instant observedAt,String attemptId,Boolean skuSetComplete){}
public record Sku(String sku,BigDecimal listQty,BigDecimal forecastQty,BigDecimal actualQty){}
public record Event(String diagnosticSessionId,String sourceAccount,String producerId,String rootTaskId,String collectionSessionId,String operation,String warehouseCode,String ownerCode,String orderNo,String side,Observation observation,List<Sku> skus){}
public record Key(String warehouseCode,String ownerCode,String orderNo,boolean paired){}
public record View(String diagnosticSessionId,String sourceAccount,String rootTaskId,String collectionSessionId,String state,String stopReason,Instant startsAt,Instant expiresAt,Instant bindBefore,List<String> warehouseCodes,String ownerCode,String excludedRootTaskId,int candidates,int pairedOrders,int retainedRows,long retainedBytes,boolean deliveryUncertain,List<Key> selected){}
public record Receipt(String outcome,String comparison,View session){}
public interface Sink {View status(Status request);Receipt observe(Event event);View stop(Stop request);}
}
@@ -0,0 +1,50 @@
package com.baiyue.integration.provider.lingxing;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/** Invoked only by the bounded observer worker. Never retries or contacts the supplier. */
@Component
public final class LingxingQuantityHttpSink implements Sink {
private static final String PATH="/internal/lastleg/self-operated-analytics/quantity-diagnostics/";
private final URI baseUri;private final ObjectMapper json;private final String token;
private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).followRedirects(HttpClient.Redirect.NEVER).build();
public LingxingQuantityHttpSink(LingxingEgressProperties config,ObjectMapper json,@Value("${baiyue.security.internal-service-token:}") String token){this.baseUri=config.budgetAuthorityBaseUri();this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);this.token=token;}
public View status(Status request){return call("status",request,View.class);}
public Receipt observe(Event event){return call("observe",event,Receipt.class);}
public View stop(Stop request){return call("stop",request,View.class);}
private <T>T call(String path,Object payload,Class<T> type){
try{
if(baseUri==null||token==null||token.getBytes(StandardCharsets.UTF_8).length<32)throw unavailable();
byte[] body=json.writeValueAsBytes(payload);if(body.length>262144)throw unavailable();
HttpRequest request=HttpRequest.newBuilder(baseUri.resolve(PATH+path)).timeout(Duration.ofSeconds(3)).header("X-Internal-Service-Token",token).header("Content-Type","application/json; charset=UTF-8").POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
// Limit while receiving, not after allocating an arbitrary response String.
HttpResponse<byte[]> response=client.send(request,info->new LimitedBody(262144));
if(response.statusCode()!=200)throw unavailable();
var root=json.readTree(response.body());if(root==null||!"200".equals(root.path("code").asText()))throw unavailable();
if(root.path("data").isNull())return null;
return json.treeToValue(root.path("data"),type);
}catch(InterruptedException e){Thread.currentThread().interrupt();throw unavailable();}
catch(Exception e){throw unavailable();}
}
private static IllegalStateException unavailable(){return new IllegalStateException("DIAGNOSTIC_RECEIVER_UNAVAILABLE");}
static final class LimitedBody implements HttpResponse.BodySubscriber<byte[]> {
private final int limit;private final java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream();
private final java.util.concurrent.CompletableFuture<byte[]> result=new java.util.concurrent.CompletableFuture<>();
private java.util.concurrent.Flow.Subscription subscription;
LimitedBody(int limit){this.limit=limit;}
public java.util.concurrent.CompletionStage<byte[]> getBody(){return result;}
public void onSubscribe(java.util.concurrent.Flow.Subscription s){subscription=s;s.request(1);}
public void onNext(java.util.List<java.nio.ByteBuffer> buffers){
for(var buffer:buffers){if(buffer.remaining()>limit-out.size()){subscription.cancel();result.completeExceptionally(unavailable());return;}byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);out.writeBytes(bytes);}subscription.request(1);
}
public void onError(Throwable error){result.completeExceptionally(error);}
public void onComplete(){result.complete(out.toByteArray());}
}
}
@@ -0,0 +1,442 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import java.util.function.BiFunction;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.function.Function;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public final class LingxingWmsClient {
private static final String PAGE_PATH = "/openapi/v2/order/delivery/page";
private static final Set<Integer> AUTH_ERROR_CODES = Set.of(11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001);
private static final DateTimeFormatter SOURCE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final LingxingWmsProperties properties;
private final ObjectMapper mapper;
private final LingxingWmsSigner signer;
private final HttpClient httpClient;
private final Clock clock;
private final LingxingEgressGate egressGate;
private LingxingQuantityCapture quantityCapture;
@Autowired
void setQuantityCapture(LingxingQuantityCapture quantityCapture) {
this.quantityCapture = quantityCapture;
}
@Autowired
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) {
this(properties, mapper, Clock.systemUTC(), egressGate);
}
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock, LingxingEgressGate egressGate) {
this.properties = properties;
this.mapper = mapper.copy().disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
this.clock = clock;
this.egressGate = egressGate;
signer = new LingxingWmsSigner(this.mapper);
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout())
.followRedirects(HttpClient.Redirect.NEVER).build();
}
public PageResult queryPage(QueryRequest request, EgressContext context) {
validate(request);
Map<String, Object> data = pagination(request.current(), request.size());
data.put("timeType", "outboundTime");
data.put("startTime", SOURCE_TIME.format(request.startTime()));
data.put("endTime", SOURCE_TIME.format(request.endTime()));
data.put("status", 100);
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_QUERY,
(json, metadata) -> orders(json, request.current(), request.size(), null, metadata));
}
public PageResult locate(LocateRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.deliveryNo(), 128);
validatePagination(request.current(), request.size(), 1000);
Map<String, Object> data = pagination(request.current(), request.size());
data.put("deliveryNo", request.deliveryNo());
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_LOCATE,
(json, metadata) -> orders(json, request.current(), request.size(), request.deliveryNo(), metadata));
}
public QueryPage<Warehouse> queryWarehouses(WarehouseQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
validatePagination(request.current(), request.size(), 1000);
return execute("/openapi/v2/warehouse/page", pagination(request.current(), request.size()), context, Operation.WAREHOUSES,
(data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, row ->
new Warehouse(requiredText(row, "whCode", 64), optionalText(row, "whNameCn"),
requiredInt(row.get("warehouseType"), "WAREHOUSE_TYPE"), requiredInt(row.get("status"), "STATUS"),
optionalText(row, "timeZone"))));
}
public OrderDetail queryDetail(DetailRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64);
requireInput(request.customerCode(), 64);
requireInput(request.deliveryNo(), 128);
Map<String, Object> body = new LinkedHashMap<>();
body.put("whCode", request.warehouseCode());
body.put("customerCode", request.customerCode());
body.put("deliveryNo", request.deliveryNo());
return execute("/openapi/v2/order/delivery/detail", body, context, Operation.OUTBOUND_DETAIL,
(data, metadata) -> detail(data, request, metadata));
}
private OrderDetail detail(JsonNode data, DetailRequest request, AttemptMetadata metadata) {
String deliveryNo = requiredText(data, "deliveryNo", 128);
String customer = requiredText(data, "customerCode", 64);
String warehouse = requiredText(data, "whCode", 64);
if (!deliveryNo.equals(request.deliveryNo()) || !customer.equals(request.customerCode())
|| !warehouse.equals(request.warehouseCode())) throw invalidResponse("DETAIL_IDENTITY_MISMATCH", 200);
JsonNode rows = data.get("skuVOList");
if (rows == null || !rows.isArray() || rows.isEmpty()) throw invalidResponse("SKU_LIST_INVALID", 200);
List<SkuLine> lines = new ArrayList<>();
for (JsonNode row : rows) {
if (!row.isObject()) throw invalidResponse("SKU_ROW_INVALID", 200);
lines.add(new SkuLine(requiredText(row, "productSku", 128), optionalText(row, "productName"),
quantity(row.get("actualDeliveryQty")), quantity(row.get("deliveryQty"))));
}
return new OrderDetail(deliveryNo, optionalText(data, "sourceNo"), customer, warehouse,
requiredInt(data.get("status"), "STATUS"), optionalText(data, "orderCreateTime"),
optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata);
}
public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64);
validatePagination(request.current(), request.size(), 1000);
Map<String, Object> body = pagination(request.current(), request.size());
body.put("whCode", request.warehouseCode());
body.put("isHideInventory", 0);
return execute("/openapi/v2/stock/stockSku/page", body, context, Operation.PRODUCT_INVENTORY,
(data, metadata) -> inventory(data, request, metadata));
}
private QueryPage<ProductStock> inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) {
Set<List<Object>> keys = new HashSet<>();
return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> {
String customer = requiredText(row, "customerCode", 64);
String warehouse = requiredText(row, "whCode", 64);
String sku = requiredText(row, "productSku", 128);
int quality = requiredInt(row.get("productQuality"), "PRODUCT_QUALITY");
if (!warehouse.equals(request.warehouseCode())) throw invalidResponse("INVENTORY_WAREHOUSE_MISMATCH", 200);
if (!keys.add(List.of(customer, warehouse, sku, quality))) throw invalidResponse("DUPLICATE_INVENTORY_KEY", 200);
return new ProductStock(customer, warehouse, sku, optionalText(row, "productName"), quality,
quantity(row.get("stkQty")), quantity(row.get("freezeQty")), quantity(row.get("useStkQty")),
quantity(row.get("transitQty")), optionalText(row, "innerBatchNo"), optionalText(row, "outBatchNo"),
optionalText(row, "uniqueKey4tenant"));
});
}
private <T> T execute(String path, Map<String, Object> data, EgressContext context, Operation operation,
BiFunction<JsonNode, AttemptMetadata, T> projection) {
if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false);
if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false);
if (context != null && context.executionPurpose() == CollectionMode.VALIDATION
&& data.containsKey("size") && (int)data.get("size") > 10) throw badRequest();
String hash;
try {
hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
(path + "\n" + mapper.writer().without(SerializationFeature.INDENT_OUTPUT,
SerializationFeature.WRAP_ROOT_VALUE).writeValueAsString(data)).getBytes(StandardCharsets.UTF_8)));
} catch (Exception ignored) { throw failure("REQUEST_HASH_FAILED", "领星请求授权摘要生成失败", false); }
return egressGate.execute(context, operation, hash, handle -> {
var signed = signer.sign(properties.appKey(), properties.appSecret(), path, data, clock.instant());
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(path))
.timeout(properties.requestTimeout()).header("Accept", "application/json")
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build();
handle.startHttp();
HttpResponse<String> response = send(httpRequest);
if (response.statusCode() == 429) handle.rateLimited(retryAfter(response));
validateStatus(response.statusCode());
JsonNode json;
try { json = envelope(response.body()); }
catch (LingxingWmsException error) {
if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) {
handle.rateLimited(retryAfter(response));
}
throw error;
}
T result = projection.apply(json, handle.metadata());
if (quantityCapture != null && (operation == Operation.OUTBOUND_QUERY
|| operation == Operation.OUTBOUND_DETAIL)) {
// Diagnostics must never alter the normal projection or the egress completion result.
try { quantityCapture.observe(json, operation, context, clock.instant()); }
catch (RuntimeException ignored) { /* Passive diagnostic failure is isolated. */ }
}
return result;
});
}
private Instant retryAfter(HttpResponse<?> response) {
String value = response.headers().firstValue("Retry-After").orElse(null);
if (value == null || value.length() > 128) return null;
try {
if (value.matches("[0-9]{1,9}")) return clock.instant().plusSeconds(Long.parseLong(value));
Instant time = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
return time.isAfter(clock.instant()) ? time : null;
} catch (RuntimeException ignored) { return null; }
}
private static Map<String, Object> pagination(int current, int size) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("current", current);
data.put("size", size);
return data;
}
private static void validatePagination(int current, int size, int maximum) {
if (current < 1 || size < 1 || size > maximum) throw badRequest();
}
private static void requireInput(String value, int maximum) {
if (value == null || value.isBlank() || value.length() > maximum) throw badRequest();
}
private static LingxingWmsException badRequest() {
return failure("INVALID_REQUEST", "领星 WMS 查询参数不符合内部契约", false);
}
private void validate(QueryRequest request) {
if (request == null || request.startTime() == null || request.endTime() == null
|| !request.startTime().truncatedTo(ChronoUnit.SECONDS).isBefore(request.endTime().truncatedTo(ChronoUnit.SECONDS))
|| request.startTime().isBefore(request.endTime().minusMonths(1))
|| request.current() < 1 || request.size() < 1 || request.size() > 100) {
// These are local request bounds, not a claim about the supplier's pagination limits.
throw failure("INVALID_REQUEST", "领星 WMS 查询范围最多一个自然月,每页条数须为 1 至 100", false);
}
}
private HttpResponse<String> send(HttpRequest request) {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
} catch (HttpTimeoutException exception) {
throw failure("TIMEOUT", "领星 WMS 请求超时", true);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw failure("INTERRUPTED", "领星 WMS 请求被中断", true);
} catch (IOException exception) {
throw failure("IO_FAILED", "领星 WMS 网络请求失败", true);
}
}
private void validateStatus(int status) {
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流,账号已暂停", false, status, null);
if (status == 401 || status == 403) {
throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null);
}
if (status != 200) {
throw upstreamFailure("HTTP_FAILED", "领星 WMS 返回异常 HTTP 状态", status >= 500, status, null);
}
}
private JsonNode envelope(String body) {
JsonNode root;
try {
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS,
DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body);
} catch (JsonProcessingException exception) {
throw invalidResponse("JSON", null);
}
if (root == null || !root.isObject()) throw invalidResponse("ROOT_TYPE_" + nodeType(root), null);
JsonNode sourceCode = root.get("code");
// Official OMP ErrorResponse is distinct from its integer-code business response.
if (sourceCode != null && sourceCode.isTextual()) {
Integer errorCode = diagnosticCode(sourceCode);
if (errorCode != null && AUTH_ERROR_CODES.contains(errorCode)) throw sourceRejected(errorCode);
throw invalidResponse("CODE_TYPE_STRING", errorCode);
}
if (sourceCode == null || !sourceCode.isIntegralNumber() || !sourceCode.canConvertToInt()) {
throw invalidResponse("CODE_TYPE_" + nodeType(sourceCode), diagnosticCode(sourceCode));
}
if (sourceCode.intValue() != 200) {
throw sourceRejected(sourceCode.intValue());
}
JsonNode success = root.get("success");
// OMP marks success required, but the verified WMS response omits it; validate it when present.
if (success != null) {
if (!success.isBoolean()) throw invalidResponse("SUCCESS_TYPE_" + nodeType(success), 200);
if (!success.booleanValue()) throw sourceRejected(200);
}
JsonNode data = root.get("data");
if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200);
return data;
}
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) {
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", true, metadata, record -> {
String deliveryNo = requiredText(record, "deliveryNo", 128);
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
int status = requiredInt(record.get("status"), "STATUS");
if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200);
return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64),
requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"), listSkus(record.get("productList")));
});
return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(),
page.sourceSize(), 200, page.observedAt(), metadata);
}
private List<ListSku> listSkus(JsonNode products) {
if (products == null || products.isNull()) return null;
if (!products.isArray()) throw invalidResponse("PRODUCT_LIST_TYPE_INVALID", 200);
List<ListSku> items = new ArrayList<>();
for (JsonNode item : products) {
if (!item.isObject()) throw invalidResponse("PRODUCT_LIST_ROW_INVALID", 200);
items.add(new ListSku(optionalText(item, "productSku"), optionalText(item, "productName"), quantity(item.get("qty"))));
}
return List.copyOf(items);
}
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) {
JsonNode records = data.get("records");
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
Long total = optionalTotal(data.get("total"));
if (total == null) throw invalidResponse("TOTAL_MISSING", 200);
int sourceCurrent = requiredInt(data.get(currentField), "CURRENT");
int sourceSize = requiredInt(data.get(sizeField), "SIZE");
int pages = requiredInt(data.get("pages"), "PAGES");
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
long expectedPages = total / size + (total % size == 0 ? 0 : 1);
if (pages < 0 || pages != expectedPages || (total == 0 && current != 1)
|| (total > 0 && current > pages)) throw invalidResponse("PAGES_MISMATCH", 200);
long expectedRows = total == 0 ? 0 : Math.min(size, total - (long)(current - 1) * size);
if (records.size() != expectedRows) throw invalidResponse("PAGE_ROW_COUNT_MISMATCH", 200);
List<T> summaries = new ArrayList<>();
for (JsonNode record : records) {
if (!record.isObject()) throw invalidResponse("ROW_TYPE_" + nodeType(record), 200);
summaries.add(projection.apply(record));
}
return new QueryPage<>(summaries, total, current, size, pages, sourceCurrent, sourceSize, 200, clock.instant(), metadata);
}
private static String requiredText(JsonNode row, String field, int maximum) {
String text = optionalText(row, field);
if (text == null || text.isBlank() || text.length() > maximum) throw invalidResponse("IDENTITY_INVALID", 200);
return text;
}
private static int requiredInt(JsonNode value, String stage) {
if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
return value.intValue();
}
private static BigDecimal quantity(JsonNode value) {
if (missingNumber(value)) return null;
if (value.isNumber()) return value.decimalValue();
// Nonempty malformed quantities are a contract failure, never invented zero.
throw invalidResponse("QUANTITY_TYPE_" + nodeType(value), 200);
}
private static String optionalText(JsonNode record, String field) {
JsonNode value = record.get(field);
if (value == null || value.isNull()) return null;
if (!value.isTextual()) {
String stage = switch (field) {
case "deliveryNo" -> "DELIVERY_NO";
case "sourceNo" -> "SOURCE_NO";
case "customerCode" -> "CUSTOMER_CODE";
case "whCode" -> "WAREHOUSE_CODE";
case "createTime" -> "CREATE_TIME";
case "outboundTime" -> "OUTBOUND_TIME";
default -> "TEXT_FIELD";
};
throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
}
return value.textValue();
}
private static Long optionalTotal(JsonNode value) {
if (missingNumber(value)) return null;
if (!value.isIntegralNumber() || !value.canConvertToLong()) {
throw invalidResponse("TOTAL_TYPE_" + nodeType(value), 200);
}
if (value.longValue() < 0) throw invalidResponse("TOTAL_NEGATIVE", 200);
return value.longValue();
}
private static Integer diagnosticCode(JsonNode value) {
if (value == null) return null;
if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue();
// Only the documented authentication error envelope accepts string error codes.
if (!value.isTextual() || !value.textValue().matches("[0-9]{1,10}")) return null;
try {
return Integer.valueOf(value.textValue());
} catch (NumberFormatException exception) {
return null;
}
}
private static String nodeType(JsonNode value) {
return value == null ? "MISSING" : value.getNodeType().name();
}
private static boolean missingNumber(JsonNode value) {
// OMP explicitly serializes null numeric fields as an empty string or null.
return value == null || value.isNull() || (value.isTextual() && value.textValue().isEmpty());
}
private static LingxingWmsException sourceRejected(int code) {
if (code == 31001 || code == 429) return upstreamFailure("SOURCE_RATE_LIMITED", "领星 WMS 来源限流,账号已暂停", false, 200, code);
if (code == 11008) {
return upstreamFailure("AUTHORIZATION_REJECTED",
"领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code);
}
if (code == 11006) {
return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code);
}
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", false, 200, code);
}
private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) {
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合接口契约(" + stage + ")",
false, 200, sourceCode);
}
private static LingxingWmsException failure(String code, String message, boolean retryable) {
return upstreamFailure(code, message, retryable, null, null);
}
private static LingxingWmsException upstreamFailure(String code, String message, boolean retryable,
Integer status, Integer sourceCode) {
return new LingxingWmsException("LINGXING_WMS_" + code, message, retryable, status, sourceCode);
}
}
@@ -0,0 +1,32 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
/** Only fixed messages and numeric upstream metadata may leave the adapter. */
public final class LingxingWmsException extends RuntimeException {
private final String code;
private final boolean retryable;
private final Integer upstreamStatus;
private final Integer sourceCode;
private AttemptMetadata egress;
private AuthorizationDenial denial;
public LingxingWmsException(String code, String message, boolean retryable,
Integer upstreamStatus, Integer sourceCode) {
// Upstream exceptions can contain signed URLs or response fragments; never attach them.
super(message);
this.code = code;
this.retryable = retryable;
this.upstreamStatus = upstreamStatus;
this.sourceCode = sourceCode;
}
public String code() { return code; }
public boolean retryable() { return retryable; }
public Integer upstreamStatus() { return upstreamStatus; }
public Integer sourceCode() { return sourceCode; }
public AttemptMetadata egress() { return egress; }
public LingxingWmsException withEgress(AttemptMetadata metadata) { this.egress = metadata; return this; }
public AuthorizationDenial denial() { return denial; }
public LingxingWmsException withDenial(AuthorizationDenial metadata) { this.denial = metadata; return this; }
}
@@ -0,0 +1,74 @@
package com.baiyue.integration.provider.lingxing;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.StringJoiner;
/** OMP service-provider signature; the data object keeps its transmitted insertion order. */
public final class LingxingWmsSigner {
private final ObjectMapper mapper;
private final ObjectWriter writer;
public LingxingWmsSigner(ObjectMapper mapper) {
this.mapper = mapper;
writer = mapper.writer().without(SerializationFeature.INDENT_OUTPUT, SerializationFeature.WRAP_ROOT_VALUE);
}
public SignedRequest sign(String appKey, String appSecret, String path, Map<String, ?> data, Instant requestedAt) {
if (appKey == null || appKey.isBlank() || appSecret == null || appSecret.isBlank()
|| path == null || !path.startsWith("/openapi/v2/") || path.contains("?")
|| data == null || requestedAt == null) throw failure();
try {
JsonNode dataNode = mapper.valueToTree(data);
String timestamp = Long.toString(requestedAt.getEpochSecond());
// Official OMP sample uses Map/List string form, not JSON, and includes parameter names.
String input = appSecret + path + "appKey" + appKey + "data" + parameterValue(dataNode)
+ "timestamp" + timestamp + appSecret;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
ObjectNode body = mapper.createObjectNode();
body.put("appKey", appKey);
body.set("data", dataNode);
body.put("timestamp", timestamp);
body.put("sign", signature);
return new SignedRequest(writer.writeValueAsString(body));
} catch (JsonProcessingException | GeneralSecurityException | IllegalArgumentException exception) {
throw failure();
}
}
private static String parameterValue(JsonNode value) {
if (value.isObject()) {
StringJoiner rendered = new StringJoiner(", ", "{", "}");
value.fields().forEachRemaining(field -> rendered.add(field.getKey() + "=" + parameterValue(field.getValue())));
return rendered.toString();
}
if (value.isArray()) {
StringJoiner rendered = new StringJoiner(", ", "[", "]");
value.forEach(child -> rendered.add(parameterValue(child)));
return rendered.toString();
}
return value.isNull() ? "null" : value.asText();
}
private static LingxingWmsException failure() {
return new LingxingWmsException("LINGXING_WMS_SIGNATURE_ERROR", "领星 WMS 请求签名失败", false, null, null);
}
public record SignedRequest(String body) {
@Override public String toString() { return "SignedRequest[REDACTED]"; }
}
}
@@ -0,0 +1,77 @@
# Select explicitly with --spring.config.location=classpath:/application-lingxing-local.yml.
# No remote imports, database, registry or scheduled jobs are used by this local instance.
server:
address: 127.0.0.1
port: ${INTEGRATION_LOCAL_PORT:19210}
spring:
application:
name: integration-service
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
cloud:
bootstrap:
enabled: false
discovery:
enabled: false
service-registry:
auto-registration:
enabled: false
nacos:
config:
enabled: false
import-check:
enabled: false
discovery:
enabled: false
register-enabled: false
baiyue:
runtime:
scheduling-enabled: false
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
integration:
lingxing-wms:
enabled: ${LINGXING_WMS_ENABLED:false}
base-uri: https://api.xlwms.com
app-key: ${LINGXING_OMS_APP_KEY:}
app-secret: ${LINGXING_OMS_APP_SECRET:}
connect-timeout: 5s
request-timeout: 15s
egress:
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
min-start-interval: 5s
cooldown-fallback: 5m
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
# Missing proof/authority fails closed even when the old enabled switch is true.
baidu-ocr:
enabled: false
amazon-sp-api:
enabled: false
carrier-schedule:
matson:
enabled: false
hede:
enabled: false
sml:
enabled: false
zim:
enabled: false
evergreen:
enabled: false
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: never
@@ -35,6 +35,24 @@ baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
integration:
lingxing-wms:
enabled: ${LINGXING_WMS_ENABLED:false}
base-uri: https://api.xlwms.com
app-key: ${LINGXING_OMS_APP_KEY:}
app-secret: ${LINGXING_OMS_APP_SECRET:}
connect-timeout: 5s
request-timeout: 15s
egress:
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
continuous-business-collection-enabled: ${LINGXING_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false}
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
min-start-interval: 5s
cooldown-fallback: 5m
# Finite sessions require external-exclusive-until; continuous BUSINESS still requires the exclusive grant ID and authority.
# Missing proof/authority fails closed even when the old enabled switch is true.
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
api-key: ${BAIDU_OCR_API_KEY:}
@@ -0,0 +1,493 @@
openapi: 3.0.3
info:
title: Lingxing WMS Internal Read API
version: 1.0.0
description: >-
Single-page or single-order adapter. No scheduling, business persistence or public Gateway route.
Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone.
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled commands and their legitimate derived work; the adapter does not schedule work. Provider-query endpoints apply the same gate.
Missing exclusive proof, persistent account budget authorization or control context fails closed before egress.
At most one account request is in flight; request starts obey the bound minStartInterval (VALIDATION at least 5 seconds).
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries;
429 is never retried, immediately pauses the account and returns persistent cooldown metadata.
VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
External server configuration supplies externalExclusiveGrantId and a VALIDATION externalExclusiveUntil;
the nonempty grant must match context.exclusiveGrantId. BUSINESS has no grant, session or root time cutoff;
explicit stops, persistent account protections and each short single-attempt permit remain enforced.
A BUSINESS rate-limit pause is recoverable after both the persistently returned
cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new
strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state.
Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost
or malformed authorization and uncertain completion hold an unrecoverable local pause until
operations verify persistent account state. Completion failure never automatically recovers.
A well-formed explicit authorization denial is deterministic and does not cause a permanent pause.
sourceAccount defaults to lingxing-primary and budgetAuthorityBaseUri defaults internally to
http://lastleg-service:9303; neither default enables egress.
x-lastleg-egress-coordination:
description: >-
Integration calls these Lastleg endpoints with X-Internal-Service-Token. These are dependencies,
not Integration Controller paths. Lastleg prepares attemptId server-side and binds context and operation
before authorization. authorize only consumes an already prepared attempt, first binds the opaque
requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings.
A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true
and budgetConsumed=true, expires after now but no later than now plus 30 seconds (also within externalExclusiveUntil for VALIDATION),
and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and
AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose
a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress;
pending or ambiguous attempts remain conservatively consumed, including after restart.
authorize:
method: POST
path: /internal/lastleg/self-operated-analytics/egress/authorize
requestSchema: '#/components/schemas/AuthorizationRequest'
responseSchema: '#/components/schemas/AttemptPermitResponse'
complete:
method: POST
path: /internal/lastleg/self-operated-analytics/egress/complete
requestSchema: '#/components/schemas/CompletionRequest'
responseSchema: '#/components/schemas/CompletionResponse'
security:
- InternalServiceToken: []
paths:
/internal/integrations/lingxing/wms/egress/runtime:
get:
operationId: lingxingEgressRuntime
summary: Read sanitized configuration from the active egress gate
description: >-
Internal token authentication only. No provider, database, budget-authority, permit or persistence side effect.
Returns actual bound configuration, not a verified claim or a commit identity. Fingerprints use lowercase
SHA-256 of UTF-8 "selfop-runtime:v1:<domain>\n" plus the exact original nonblank value, without trimming.
Domains are source-account, grant and budget-authority. Missing/blank values yield null, never an empty-value hash.
security: [{InternalServiceToken: []}]
responses:
'200':
description: ApiResponse code 200 with runtime snapshot, or A0401 for missing/invalid internal token
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data: {$ref: '#/components/schemas/EgressRuntimeSnapshot'}
/internal/integrations/lingxing/wms/warehouses/query:
post:
summary: Query one page of normal warehouses
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Calls /openapi/v2/warehouse/page without filters. Maps page/pageSize into current/size; preserves original metadata.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/WarehouseQueryRequest'}
responses:
'200':
description: Standard envelope with WarehousePage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/WarehouseResponse'}
/internal/integrations/lingxing/wms/outbound-orders/query:
post:
summary: Query one page of completed outbound orders by source outbound time
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Calls /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. No external warehouse filter. At current=1,size=100 only, records=[],total=0,pages=0,sourceCurrent=1,sourceSize=10 is accepted and internal size remains 100. All other pagination metadata is strictly required. No completeness claim across pages.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/QueryRequest'}
responses:
'200':
description: Standard envelope with OrderPage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/OrderResponse'}
/internal/integrations/lingxing/wms/outbound-orders/locate:
post:
summary: Locate one page by delivery number without time or status filters
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Sends only current,size,deliveryNo to delivery/page. Every returned deliveryNo must match exactly. Caller reads all required pages and verifies original customer identity and uniqueness before accepting the current warehouse. Missing results do not prove cancellation.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/LocateRequest'}
responses:
'200':
description: Standard envelope with OrderPage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/OrderResponse'}
/internal/integrations/lingxing/wms/outbound-orders/detail:
post:
summary: Read one order using warehouse, customer and delivery identifiers
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Maps warehouseCode to whCode for delivery/detail. All three returned identifiers must match. Returns only skuVOList; packageList is never added to quantities. Duplicate SKU lines remain separate for caller normalization. Missing quantities remain null; no quantity unit is inferred.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/DetailRequest'}
responses:
'200':
description: Standard envelope with OrderDetail or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/DetailResponse'}
/internal/integrations/lingxing/wms/product-inventory/query:
post:
summary: Query one page of all qualities and customers in a required warehouse
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
description: Calls stock/stockSku/page with whCode and isHideInventory=0. No SKU, quality, customer or quantity filters. Warehouse mismatch and duplicate business keys within a page fail closed. Caller validates cross-page keys and counts. Unknown quantities stay null, negative quantities remain evidence, stockCompare is not accumulated.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InventoryQueryRequest'}
responses:
'200':
description: Standard envelope with InventoryPage or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/InventoryResponse'}
components:
parameters:
LingxingEgressContext:
name: X-Lingxing-Egress-Context
in: header
required: true
description: >-
JSON control context; no credentials. Maximum 2048 characters, all printable ASCII (32 through 126).
Unknown fields, duplicate fields and trailing JSON are rejected. Identifiers are strings supplied
by the server-side coordinator and do not confer authorization by themselves.
content:
application/json:
schema: {$ref: '#/components/schemas/EgressContext'}
securitySchemes:
InternalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
schemas:
EgressRuntimeSnapshot:
type: object
additionalProperties: false
required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, grantPresent, intervalMs, permitMaxSeconds, providerEnabled]
properties:
schemaVersion: {type: integer, enum: [1]}
service: {type: string, enum: [integration]}
observedAt: {type: string, format: date-time}
mode: {type: string, enum: [OFF, VALIDATION, BUSINESS]}
environment: {type: string}
continuousEnabled: {type: boolean}
sourceAccountFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
grantPresent: {type: boolean}
grantFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
intervalMs:
oneOf: [{type: integer, format: int64}, {type: string, pattern: '^[0-9]+$'}]
description: Actual bound interval; shared long-value JSON serialization may encode it as a decimal string.
permitMaxSeconds: {type: integer, enum: [30]}
providerEnabled: {type: boolean}
budgetAuthorityFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
EgressContext:
type: object
additionalProperties: false
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
properties:
sourceAccount: {type: string, minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: 'Server-configured account alias, never App Key or Secret.'}
executionPurpose: {type: string, enum: [VALIDATION, BUSINESS]}
rootTaskId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable root control identifier; child tasks share root budget.}
sessionId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable session control identifier; reopening or restart does not reset it.}
attemptId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Prepared unique single-attempt control identifier; duplicate authorization is refused.}
exclusiveGrantId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Must equal the valid externalExclusiveGrantId configured server-side.}
EgressRequestHash:
type: string
pattern: '^[0-9a-f]{64}$'
description: >-
Lowercase hexadecimal SHA256 of UTF-8 bytes of OMP path, one LF character and compact
insertion-ordered external business JSON. Lastleg treats this digest as opaque and binds
it on the first authorization of an already prepared attempt.
example: fa60a92c78b4803878930e5914e6db03591acc31b7c09abad53c91912fa3d627
x-preimage-example: |-
/openapi/v2/stock/stockSku/page
{"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0}
EgressOperation:
type: string
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY]
EgressMetadata:
type: object
required: [attemptId, actualHttpAttempts, accountPaused]
properties:
attemptId: {type: string, nullable: true, description: Null only if no valid control context was accepted.}
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1, description: 'Supplier HTTP attempts actually started, including failed calls; authorization callbacks do not count.'}
accountPaused: {type: boolean}
cooldownUntil: {type: string, format: date-time, nullable: true, description: Account cooldown deadline to persist; valid supplier retry time must not be shortened.}
AuthorizationRequest:
type: object
required: [context, operation, requestHash]
properties:
context: {$ref: '#/components/schemas/EgressContext'}
operation: {$ref: '#/components/schemas/EgressOperation'}
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
AuthorizationDenial:
type: object
required: [errorCode, sampleDone]
properties:
errorCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'}
sampleDone: {type: boolean, description: Strict JSON boolean; validation budget normal completion is distinct from a failure.}
waitUntil: {type: string, format: date-time, nullable: true, description: Nullable ISO-8601 Instant from the persistent authority.}
AuthorizationDenialResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
code: {type: string, enum: [B0001]}
data: {$ref: '#/components/schemas/AuthorizationDenial'}
AttemptPermit:
type: object
required: [context, operation, requestHash, allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed]
properties:
context: {$ref: '#/components/schemas/EgressContext'}
operation: {$ref: '#/components/schemas/EgressOperation'}
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
allowedAttempts: {type: integer, enum: [1]}
expiresAt: {type: string, format: date-time, description: Must be after now and no later than now plus 30 seconds and configured externalExclusiveUntil.}
exclusiveConfirmed: {type: boolean, enum: [true]}
budgetConsumed: {type: boolean, enum: [true], description: Lastleg has atomically consumed persistent account and root/session budget before returning this permit.}
CompletionRequest:
type: object
required: [context, operation, requestHash, actualHttpAttempts, accountPaused]
properties:
context: {$ref: '#/components/schemas/EgressContext'}
operation: {$ref: '#/components/schemas/EgressOperation'}
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1}
errorCode: {type: string, nullable: true, description: Sanitized local error code; null on success.}
upstreamStatus: {type: integer, nullable: true}
sourceCode: {type: integer, nullable: true}
accountPaused: {type: boolean, description: True on 429 or explicit provider rate limiting; all account request families must pause.}
cooldownUntil: {type: string, format: date-time, nullable: true}
AttemptPermitResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
code: {type: string, enum: ['200', B0001]}
data:
oneOf:
- {$ref: '#/components/schemas/AttemptPermit'}
- {$ref: '#/components/schemas/AuthorizationDenial'}
description: Code 200 requires AttemptPermit; code B0001 requires AuthorizationDenial. Lost or malformed responses are uncertain and fail closed with a local pause.
CompletionResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data: {type: boolean, enum: [true], description: Successful acknowledgement confirms durable completion and cooldown persistence; any other result fails closed.}
WarehouseQueryRequest:
type: object
required: [current, size]
properties:
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
QueryRequest:
type: object
required: [startTime, endTime, current, size]
properties:
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime. Must precede endTime and be no earlier than endTime minus one calendar month. No source timezone inference.}
endTime: {type: string, example: '2026-10-12T08:00:00'}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 100, description: 'Local request cap, not supplier quota.'}
LocateRequest:
type: object
required: [deliveryNo, current, size]
properties:
deliveryNo: {type: string, minLength: 1, maxLength: 128}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
DetailRequest:
type: object
required: [warehouseCode, customerCode, deliveryNo]
properties:
warehouseCode: {type: string, minLength: 1, maxLength: 64}
customerCode: {type: string, minLength: 1, maxLength: 64}
deliveryNo: {type: string, minLength: 1, maxLength: 128}
InventoryQueryRequest:
type: object
required: [warehouseCode, current, size]
properties:
warehouseCode: {type: string, minLength: 1, maxLength: 64}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
Pagination:
type: object
required: [total, current, size, pages, sourceCurrent, sourceSize, sourceCode, observedAt, egress]
properties:
egress: {$ref: '#/components/schemas/EgressMetadata'}
total: {type: string, pattern: '^[0-9]+$', description: Required provider total. Long follows platform string serialization.}
current: {type: integer}
size: {type: integer}
pages: {type: integer, minimum: 0}
sourceCurrent: {type: integer}
sourceSize: {type: integer}
sourceCode: {type: integer, enum: [200]}
observedAt: {type: string, format: date-time}
OrderSummary:
type: object
required: [deliveryNo, customerCode, whCode, status]
properties:
deliveryNo: {type: string}
sourceNo: {type: string, nullable: true}
customerCode: {type: string}
whCode: {type: string}
status: {type: integer}
createTime: {type: string, nullable: true}
outboundTime: {type: string, nullable: true}
productList:
type: array
nullable: true
description: Per-SKU list quantities; missing items, SKU or qty require one detail supplement for this order. Zero is a known quantity.
items:
type: object
properties:
productSku: {type: string, nullable: true}
productName: {type: string, nullable: true}
qty: {type: number, nullable: true}
Warehouse:
type: object
required: [whCode, warehouseType, status]
properties:
whCode: {type: string}
whNameCn: {type: string, nullable: true}
warehouseType: {type: integer}
status: {type: integer}
timeZone: {type: string, nullable: true, description: Warehouse metadata only.}
SkuLine:
type: object
required: [productSku]
properties:
productSku: {type: string}
productName: {type: string, nullable: true}
actualDeliveryQty: {type: number, nullable: true, description: 'BigDecimal actual quantity, unknown is null.'}
deliveryQty: {type: number, nullable: true, description: Forecast only; never a replacement for actual quantity.}
OrderDetail:
type: object
required: [deliveryNo, customerCode, whCode, status, skuVOList, sourceCode, observedAt, egress]
properties:
egress: {$ref: '#/components/schemas/EgressMetadata'}
deliveryNo: {type: string}
sourceNo: {type: string, nullable: true}
customerCode: {type: string}
whCode: {type: string}
status: {type: integer}
orderCreateTime: {type: string, nullable: true}
outboundTime: {type: string, nullable: true}
skuVOList: {type: array, minItems: 1, items: {$ref: '#/components/schemas/SkuLine'}}
sourceCode: {type: integer, enum: [200]}
observedAt: {type: string, format: date-time}
ProductStock:
type: object
required: [customerCode, whCode, productSku, productQuality]
properties:
customerCode: {type: string}
whCode: {type: string}
productSku: {type: string}
productName: {type: string, nullable: true}
productQuality: {type: integer}
stkQty: {type: number, nullable: true}
freezeQty: {type: number, nullable: true}
useStkQty: {type: number, nullable: true}
transitQty: {type: number, nullable: true}
innerBatchNo: {type: string, nullable: true}
outBatchNo: {type: string, nullable: true}
uniqueKey4tenant: {type: string, nullable: true}
OrderPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}}
WarehousePage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/Warehouse'}}
InventoryPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/ProductStock'}}
ProviderFailure:
type: object
required: [errorCode, retryable, egress]
properties:
egress: {$ref: '#/components/schemas/EgressMetadata'}
denial:
nullable: true
allOf:
- {$ref: '#/components/schemas/AuthorizationDenial'}
description: Well-formed deterministic authority denial, alongside egress; null for other failures.
errorCode: {type: string}
retryable: {type: boolean, description: Caller scheduling hint only; never authorizes implicit retries or reuse of an attempt. False for 429 and validation session failure.}
upstreamStatus: {type: integer, nullable: true}
sourceCode: {type: integer, nullable: true}
Envelope:
type: object
required: [code, message, data, timestamp]
properties:
code: {type: string, description: '200 success; A0401 identity; A0422 validation; B0001 or S0503 provider/configuration failure.'}
message: {type: string, description: 'Sanitized fixed local message, never raw supplier content.'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
OrderResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/OrderPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
WarehouseResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/WarehousePage'}
- {$ref: '#/components/schemas/ProviderFailure'}
DetailResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/OrderDetail'}
- {$ref: '#/components/schemas/ProviderFailure'}
InventoryResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/InventoryPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
@@ -0,0 +1,71 @@
package com.baiyue.integration.config;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class LingxingEgressPropertiesTest {
@Test void continuousBusinessFlagBindsOnlyWhenExplicitlyEnabled() {
String prefix = "baiyue.integration.lingxing-wms.egress";
var input = new java.util.HashMap<String, String>();
input.put(prefix + ".collection-mode", "BUSINESS");
var source = new org.springframework.boot.context.properties.source.MapConfigurationPropertySource(input);
var binder = new org.springframework.boot.context.properties.bind.Binder(source);
var target = org.springframework.boot.context.properties.bind.Bindable.of(LingxingEgressProperties.class);
assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isFalse();
source.put(prefix + ".continuous-business-collection-enabled", "true");
assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isTrue();
}
private LingxingEgressProperties properties(CollectionMode mode, Duration interval, Duration cooldown) {
return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown, false);
}
@Test void businessAcceptsPositiveMillisecondIntervalsIncludingProbeSteps() {
for (long millis : new long[]{1, 1200, 1500, 2000, 3000, 5000, 300000}) {
assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval())
.isEqualTo(Duration.ofMillis(millis));
}
}
@Test void businessRejectsSubMillisecondNonPositiveAndExcessiveIntervals() {
for (Duration interval : List.of(Duration.ofMillis(-1), Duration.ZERO,
Duration.ofNanos(999999), Duration.ofMinutes(5).plusMillis(1))) {
assertThatThrownBy(() -> properties(CollectionMode.BUSINESS, interval, null))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Test void validationRetainsFiveSecondMinimumAndAllModesDefaultToFiveSeconds() {
for (CollectionMode mode : CollectionMode.values()) {
assertThat(properties(mode, null, null).minStartInterval()).isEqualTo(Duration.ofSeconds(5));
if (mode == CollectionMode.VALIDATION) {
assertThatThrownBy(() -> properties(mode, Duration.ofMillis(4999), null))
.isInstanceOf(IllegalArgumentException.class);
assertThat(properties(mode, Duration.ofSeconds(5), null).minStartInterval())
.isEqualTo(Duration.ofSeconds(5));
}
}
assertThat(properties(null, null, null).collectionMode()).isEqualTo(CollectionMode.OFF);
}
@Test void switchingBusinessOffCanRetainItsThreeSecondInterval() {
var business = properties(CollectionMode.BUSINESS, Duration.ofSeconds(3), null);
assertThat(properties(CollectionMode.OFF, business.minStartInterval(), null).minStartInterval())
.isEqualTo(Duration.ofSeconds(3));
assertThatThrownBy(() -> properties(CollectionMode.OFF, Duration.ZERO, null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test void businessIntervalChangeDoesNotRelaxCooldownBounds() {
for (Duration cooldown : List.of(Duration.ofMillis(4999), Duration.ofDays(1).plusMillis(1))) {
assertThatThrownBy(() -> properties(CollectionMode.BUSINESS, Duration.ofMillis(1), cooldown))
.isInstanceOf(IllegalArgumentException.class);
}
for (Duration cooldown : List.of(Duration.ofSeconds(5), Duration.ofDays(1))) {
assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(1), cooldown).cooldownFallback())
.isEqualTo(cooldown);
}
}
}
@@ -0,0 +1,48 @@
package com.baiyue.integration.controller;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
import com.baiyue.integration.provider.lingxing.LingxingEgressGate;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class LingxingEgressRuntimeControllerTest {
@Test void internalTokenIsRequiredBeforeGateInspectionAndPublicPathDoesNotExist() throws Exception {
String token = UUID.randomUUID().toString();var guard = new InternalServiceTokenGuard();
ReflectionTestUtils.setField(guard, "token", token);ReflectionTestUtils.invokeMethod(guard, "initializeToken");
var gate = mock(LingxingEgressGate.class);var provider = new LingxingWmsProperties(false,null,null,null,null,null);
var converter = new MappingJackson2HttpMessageConverter(Jackson2ObjectMapperBuilder.json()
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build());
var mvc = MockMvcBuilders.standaloneSetup(new LingxingEgressRuntimeController(guard,gate,provider))
.setMessageConverters(converter).setControllerAdvice(new GlobalExceptionHandler()).build();
String path = "/internal/integrations/lingxing/wms/egress/runtime";
for (String bad : new String[] {"", "wrong", "Bearer user-jwt"}) {
mvc.perform(get(path).header("X-Internal-Service-Token",bad)).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
}
verifyNoInteractions(gate);
mvc.perform(get("/api/integrations/lingxing/wms/egress/runtime")).andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(org.hamcrest.Matchers.not("200")))
.andExpect(jsonPath("$.data.service").doesNotExist());
verifyNoInteractions(gate);
var snapshot = new LingxingEgressRuntimeSnapshot(1,"integration",Instant.parse("2026-10-13T14:00:00Z"),
"OFF","test",false,null,false,null,5000,30,false,null);
when(gate.runtimeSnapshot(false)).thenReturn(snapshot);
mvc.perform(get(path).header("X-Internal-Service-Token",token)).andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200")).andExpect(jsonPath("$.data.service").value("integration"))
.andExpect(jsonPath("$.data.observedAt").value("2026-10-13T14:00:00Z"))
.andExpect(jsonPath("$.data.mode").value("OFF")).andExpect(jsonPath("$.data.providerEnabled").value(false))
.andExpect(jsonPath("$.data.verified").doesNotExist()).andExpect(jsonPath("$.data.grant").doesNotExist());
verify(gate).runtimeSnapshot(false);verifyNoMoreInteractions(gate);
}
}
@@ -0,0 +1,163 @@
package com.baiyue.integration.controller;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.common.web.LongIdJacksonConfiguration;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class LingxingInternalControllerTest {
private static final String PATH = "/internal/integrations/lingxing/wms/outbound-orders/query";
private static final String BODY = """
{"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1}
""";
private static final String CONTEXT = "{\"sourceAccount\":\"demo\",\"executionPurpose\":\"VALIDATION\",\"rootTaskId\":\"root-1\",\"sessionId\":\"session-1\",\"attemptId\":\"attempt-1\",\"exclusiveGrantId\":\"grant-1\"}";
private final String token = UUID.randomUUID().toString();
private LingxingWmsClient client;
private MockMvc mvc;
@BeforeEach
void setUp() {
var guard = new InternalServiceTokenGuard();
ReflectionTestUtils.setField(guard, "token", token);
ReflectionTestUtils.invokeMethod(guard, "initializeToken");
client = mock(LingxingWmsClient.class);
var builder = Jackson2ObjectMapperBuilder.json();
try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) {
context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder);
}
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client, builder.build()))
.setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build()))
.setControllerAdvice(new GlobalExceptionHandler()).build();
}
@Test
void missingOrWrongInternalTokenCannotCallProvider() throws Exception {
mvc.perform(post(PATH).contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
mvc.perform(post(PATH).header("X-Internal-Service-Token", "wrong")
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
verifyNoInteractions(client);
}
@ParameterizedTest
@CsvSource(value = {
"/warehouses/query|{\"current\":1,\"size\":1000}",
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":1000}"
}, delimiter = '|')
void everyNewEndpointRequiresInternalIdentity(String suffix, String request) throws Exception {
String path = "/internal/integrations/lingxing/wms" + suffix;
for (String invalidToken : new String[] {"", "wrong", "Bearer user-jwt"}) {
mvc.perform(post(path).header("X-Internal-Service-Token", invalidToken)
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
}
verifyNoInteractions(client);
}
@ParameterizedTest
@CsvSource(value = {
"/outbound-orders/query|{\"startTime\":\"2026-09-12T08:00:00\",\"endTime\":\"2026-10-12T08:00:00\",\"current\":1,\"size\":1}",
"/warehouses/query|{\"current\":1,\"size\":10}",
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":10}"
}, delimiter = '|')
void everyEndpointRejectsMissingControlContextBeforeProvider(String suffix, String request) throws Exception {
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"))
.andExpect(jsonPath("$.data.egress.actualHttpAttempts").value(0));
verifyNoInteractions(client);
}
@Test
void controlHeaderRejectsUnknownDuplicateAndTrailingFields() throws Exception {
for (String header : new String[] {
CONTEXT.replace("\"sourceAccount\":", "\"extra\":1,\"sourceAccount\":"),
CONTEXT.replace("\"sourceAccount\":", "\"sourceAccount\":\"other\",\"sourceAccount\":"),
CONTEXT + " {}", " ", "x".repeat(2049), "{}", "null",
CONTEXT.replace("VALIDATION", "OFF"), CONTEXT.replace("root-1", "root with spaces")
}) {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", header)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"));
}
verifyNoInteractions(client);
}
@Test
void inventoryRequiresWarehouseAndDetailRequiresAllThreeIdentifiers() throws Exception {
for (String suffix : new String[] {"/product-inventory/query", "/outbound-orders/detail"}) {
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix)
.header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT).contentType(MediaType.APPLICATION_JSON)
.content("{\"current\":1,\"size\":1000,\"deliveryNo\":\"DO-DEMO\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
}
verifyNoInteractions(client);
}
@Test
void authenticatedReadReturnsOnlyOutboundSummary() throws Exception {
when(client.queryPage(any(), any())).thenReturn(new PageResult(
List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100,
"2026-10-01 08:00:00", "2026-10-02 08:00:00", null)),
1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1"))
.andExpect(jsonPath("$.data.records[0].deliveryNo").value("TEST-OUT-001"))
.andExpect(jsonPath("$.data.records[0].recipient").doesNotExist());
}
@Test
void invalidPageCannotReachProvider() throws Exception {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0")))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
verifyNoInteractions(client);
}
@Test
void providerFailureRemainsAnExplicitBusinessFailure() throws Exception {
when(client.queryPage(any(), any())).thenThrow(new LingxingWmsException(
"LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED"))
.andExpect(jsonPath("$.data.upstreamStatus").value(429))
.andExpect(jsonPath("$.data.records").doesNotExist());
}
}
@@ -0,0 +1,384 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.UnaryOperator;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LingxingEgressGateTest {
@Test void debugTimingRecordsActualCallAndCompletionWithoutContextOrPayload() {
var logger = (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(LingxingEgressGate.class);
var previous = logger.getLevel();
var appender = new ch.qos.logback.core.read.ListAppender<ch.qos.logback.classic.spi.ILoggingEvent>();
appender.start(); logger.addAppender(appender); logger.setLevel(ch.qos.logback.classic.Level.DEBUG);
try {
var gate = gate();
Object body = new Object();
assertThat(gate.<Object>execute(context("private-attempt"), Operation.OUTBOUND_DETAIL, HASH, handle -> {
handle.startHttp(); clock.advanceMillis(123); return body;
})).isSameAs(body);
assertThat(appender.list).hasSize(1);
assertThat(appender.list.get(0).getFormattedMessage())
.contains("operation=OUTBOUND_DETAIL", "start=" + NOW, "end=" + NOW.plusMillis(123),
"upstreamStatus=200", "errorCode=null", "completionConfirmed=true")
.doesNotContain("demo-account", "private-attempt", "demo-grant", HASH);
ledger.failComplete = true;
var failure = assertThrows(LingxingWmsException.class, () -> gate.execute(context("private-failure"),
Operation.OUTBOUND_DETAIL, HASH, handle -> {
handle.startHttp(); clock.advanceMillis(50); throw rateLimit();
}));
assertThat(failure.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
assertThat(appender.list).hasSize(2);
assertThat(appender.list.get(1).getFormattedMessage())
.contains("upstreamStatus=429", "errorCode=RATE_LIMIT", "completionConfirmed=false")
.doesNotContain("private-failure", "synthetic rate limit", "demo-account", HASH);
logger.setLevel(ch.qos.logback.classic.Level.INFO);
ledger.failComplete = false;
gate().execute(context("debug-disabled"), Operation.WAREHOUSES, HASH, handle -> body);
assertThat(appender.list).hasSize(2);
} finally {
logger.detachAppender(appender); appender.stop(); logger.setLevel(previous);
}
}
static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
static final String HASH = "a".repeat(64);
private final MutableClock clock = new MutableClock(NOW);
private final LedgerAuthority ledger = new LedgerAuthority(clock);
private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); }
static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); }
static LingxingEgressProperties props(CollectionMode mode, String environment) {
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null, false);
}
private LingxingEgressGate continuousGate(CollectionMode mode, String grant, Instant until) {
return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", "demo-account", grant, until,
URI.create("http://127.0.0.1"), null, null, true), ledger, clock, clock::advanceMillis);
}
@Test void continuousBusinessCrossesOldGrantExpiryButKeepsFiniteSingleUsePermits() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", NOW.minusSeconds(1));
assertThat(gate.execute(businessContext("continuous"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
clock.advanceMillis(86400000);
var restarted = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThat(restarted.execute(businessContext("next-day"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1,
clock.instant().plusSeconds(31), true, true);
assertThatThrownBy(() -> restarted.execute(businessContext("too-long"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("BUDGET_PERMIT_INVALID");
assertThat(ledger.completions).hasSize(2);
}
@Test void continuousFlagNeverReplacesMatchingGrantOrFiniteValidationWindow() {
for (String grant : Arrays.asList(null, "", "other-grant")) {
var gate = continuousGate(CollectionMode.BUSINESS, grant, null);
assertThatThrownBy(() -> gate.execute(businessContext("missing-grant"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
for (Instant until : Arrays.asList(null, NOW)) {
var gate = continuousGate(CollectionMode.VALIDATION, "demo-grant", until);
assertThatThrownBy(() -> gate.execute(context("validation"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
assertThat(ledger.authorizations).isZero();
}
@Test void continuousBusinessStillPausesOnUnconfirmedCompletion() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
ledger.failComplete = true;
assertThatThrownBy(() -> gate.execute(businessContext("lost"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_COMPLETION_UNCONFIRMED");
ledger.failComplete = false; clock.advanceMillis(86400000);
assertThatThrownBy(() -> gate.execute(businessContext("after-lost"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
}
@Test void manualBusinessWithContinuousFlagOffCrossesOldCutoffButRejectsExpiredSinglePermit() {
var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test",
"demo-account", "demo-grant", NOW, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false),
ledger, clock, clock::advanceMillis);
var manual = new EgressContext("demo-account", CollectionMode.BUSINESS, "6", "selfop-6", "manual-after-cutoff", "demo-grant");
assertThat(gate.execute(manual, Operation.OUTBOUND_DETAIL, HASH,
h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.context()).isEqualTo(manual));
ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1,
clock.instant(), true, true);
assertThatThrownBy(() -> gate.execute(new EgressContext("demo-account", CollectionMode.BUSINESS, "6", "selfop-6", "manual-expired-permit", "demo-grant"),
Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_PERMIT_INVALID");
assertThat(ledger.authorizations).isEqualTo(2);assertThat(ledger.completions).hasSize(1);
}
@Test void manualBusinessWithoutGrantCutoffStillRejectsPermitExpiringWhileSigning() {
var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test",
"demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false),
ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> gate.execute(businessContext("manual-signing-expired"), Operation.OUTBOUND_DETAIL, HASH,
h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE");
assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero());
}
@Test void continuousBusinessRetains1200MillisSpacingAndOneHttpPerPermit() {
var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test",
"demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, true),
ledger, clock, clock::advanceMillis);
gate.execute(businessContext("first-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; });
gate.execute(businessContext("second-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; });
assertThat(Duration.between(NOW, clock.instant()).toMillis()).isGreaterThanOrEqualTo(1200);
assertThatThrownBy(() -> gate.execute(businessContext("one-send"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE");
assertThat(ledger.completions).hasSize(3).allSatisfy(c -> assertThat(c.actualHttpAttempts()).isEqualTo(1));
}
@Test void continuousBusinessCannotSendWithExpiredPermitAfterSigning() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThatThrownBy(() -> gate.execute(businessContext("expired-permit"), Operation.OUTBOUND_DETAIL, HASH,
h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE");
assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero());
}
@Test void continuousBusinessRateCooldownStillRequiresFreshAuthorityApproval() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("continuous-rate"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.rateLimited(null); throw rateLimit(); }));
assertThatThrownBy(() -> gate.execute(businessContext("during-cooldown"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("ACCOUNT_PAUSED");
clock.advanceMillis(300000);ledger.denial = new AuthorizationDenial("ACCOUNT_REQUIRES_REVIEW", false, null);
assertThatThrownBy(() -> gate.execute(businessContext("authority-denied"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("BUDGET_AUTHORIZATION_DENIED");
assertThat(ledger.completions).hasSize(1);
ledger.denial = null;
assertThat(gate.execute(businessContext("fresh-approved"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).accountPaused()).isFalse();
assertThat(ledger.completions).hasSize(2);
}
@Test void failureCompletionPreservesNullableTransportAndSourceCodes() {
for (Integer status : new Integer[] {null, 503}) {
var gate = gate();
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("nullable-" + status),
Operation.WAREHOUSES, HASH, handle -> {
handle.startHttp();
throw new LingxingWmsException("LINGXING_WMS_IO_FAILED", "固定失败摘要", true, status, null);
}));
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
var completion = ledger.completions.get(ledger.completions.size() - 1);
assertThat(completion.actualHttpAttempts()).isEqualTo(1);
assertThat(completion.upstreamStatus()).isEqualTo(status);
assertThat(completion.sourceCode()).isNull();
}
}
@Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() {
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null, false);
assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF);
assertThat(defaults.continuousBusinessCollectionEnabled()).isFalse();
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF");
for(String environment:List.of("dev","gray","staging","prod","TEST","")) {
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, environment), ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> business.execute(businessContext("rejected"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED");
}
assertThat(ledger.authorizations).isZero();
}
@Test void testAndProductionBusinessUseTheSameSingleConsumedPermitAndCompletionProtocol() {
for(String environment:List.of("test","production")) {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,environment),ledger,clock,clock::advanceMillis);
var result=gate.execute(businessContext(environment+"-inventory"),Operation.PRODUCT_INVENTORY,HASH,handle->{handle.startHttp();return handle.metadata();});
assertThat(result.actualHttpAttempts()).isEqualTo(1);assertThat(result.accountPaused()).isFalse();
}
assertThat(ledger.authorizations).isEqualTo(2);assertThat(ledger.consumed).hasSize(2);assertThat(ledger.completions).hasSize(2);
assertThat(ledger.completions).allSatisfy(c->{assertThat(c.context().executionPurpose()).isEqualTo(CollectionMode.BUSINESS);assertThat(c.operation()).isEqualTo(Operation.PRODUCT_INVENTORY);assertThat(c.actualHttpAttempts()).isEqualTo(1);});
}
@Test void testBusinessStillRejectsPurposeAccountAndGrantMismatchBeforeAuthorization() {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis);
assertThatThrownBy(()->gate.execute(context("wrong-purpose"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID");
assertThatThrownBy(()->gate.execute(new EgressContext("other-account",CollectionMode.BUSINESS,"root","session","wrong-account","demo-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID");
assertThatThrownBy(()->gate.execute(new EgressContext("demo-account",CollectionMode.BUSINESS,"root","session","wrong-grant","other-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
assertThat(ledger.authorizations).isZero();assertThat(ledger.completions).isEmpty();
}
@Test void testBusinessUnconfirmedCompletionStillRequiresManualReviewAfterTimePasses() {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"test"),ledger,clock,clock::advanceMillis);
ledger.failComplete=true;
var failure=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("test-lost-ack"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;}));
assertThat(failure.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
assertThat(failure.egress().actualHttpAttempts()).isEqualTo(1);assertThat(failure.egress().accountPaused()).isTrue();
clock.advanceMillis(600000);ledger.failComplete=false;
assertThatThrownBy(()->gate.execute(businessContext("test-after-uncertainty"),Operation.PRODUCT_INVENTORY,HASH,h->{h.startHttp();return true;})).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);assertThat(ledger.completions).hasSize(1);
}
@Test void proofAndContextAreServerBoundBeforeAuthorization() {
var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", "a", "client-claimed-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "bad session", "a", "demo-grant"));
for (var c : contexts) assertThrows(LingxingWmsException.class, () -> gate().execute(c, Operation.WAREHOUSES, HASH, h -> true));
clock.advanceMillis(3600000);
assertThrows(LingxingWmsException.class, () -> gate().execute(context("a"), Operation.WAREHOUSES, HASH, h -> true));
assertThat(ledger.authorizations).isZero();
}
@Test void budgetPermitMustBeOneConsumedExclusiveUnexpiredAndExactlyBound() {
List<UnaryOperator<AttemptPermit>> mutations = List.of(
p -> null,
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 2, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant().plusSeconds(31), true, true),
p -> new AttemptPermit(context("different"), p.operation(), p.requestHash(), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), Operation.OUTBOUND_DETAIL, p.requestHash(), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), "b".repeat(64), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), false, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), true, false));
var calls = new AtomicInteger(); int n = 0;
for (var mutation : mutations) {
ledger.mutation = mutation; var gate = gate(); var c = context("attempt-" + ++n);
assertThatThrownBy(() -> gate.execute(c, Operation.WAREHOUSES, HASH, h -> calls.incrementAndGet())).hasMessageContaining("BUDGET_PERMIT_INVALID");
assertThatThrownBy(() -> gate.execute(context("next-" + c.attemptId()), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
}
assertThat(calls).hasValue(0); assertThat(ledger.completions).isEmpty();
}
@Test void everyAttemptIsPersistentlyConsumedAndCannotReplayEvenAcrossGateInstances() {
var c = context("single-use");
assertThat(gate().execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
assertThat(ledger.consumed).containsExactly("single-use");
var second = gate(); var sends = new AtomicInteger();
assertThatThrownBy(() -> second.execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return sends.incrementAndGet(); })).hasMessageContaining("BUDGET_AUTHORITY_UNAVAILABLE");
assertThat(sends).hasValue(0); assertThat(ledger.completions).hasSize(1);
}
@Test void handleCanStartOnlyOnceAndExpiredHandleCannotSend() {
var error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("once"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.startHttp(); return true; }));
assertThat(error.code()).endsWith("ATTEMPT_NOT_SENDABLE");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("expires"), Operation.WAREHOUSES, HASH,
h -> { clock.advanceMillis(30000); h.startHttp(); return true; }));
assertThat(error.egress().actualHttpAttempts()).isZero();
assertThat(ledger.completions).extracting(CompletionRequest::actualHttpAttempts).containsExactly(1, 0);
}
@Test void authorizationAndCompletionFailuresPauseAllFurtherCalls() {
ledger.failAuthorize = true; var gate = gate();
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("unavailable"), Operation.WAREHOUSES, HASH, h -> true));
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isTrue();
assertThatThrownBy(() -> gate.execute(context("next"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
ledger.failAuthorize = false; ledger.failComplete = true; var fresh = gate();
error = assertThrows(LingxingWmsException.class, () -> fresh.execute(context("callback"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
assertThat(error.code()).endsWith("BUDGET_COMPLETION_UNCONFIRMED");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
assertThatThrownBy(() -> fresh.execute(context("after-callback"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
}
@Test void startsAreSerializedAndSpacedByAtLeastFiveSeconds() throws Exception {
var gate = gate(); var executor = Executors.newFixedThreadPool(2);
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
var starts = new CopyOnWriteArrayList<Instant>();
try {
var first = executor.submit(() -> gate.execute(context("first"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); starts.add(clock.instant()); entered.countDown();
try { if (!release.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); }
catch (InterruptedException e) { throw new IllegalStateException(e); }
return true;
}));
assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue();
var secondReady = new CountDownLatch(1);
var second = executor.submit(() -> { secondReady.countDown(); return gate.execute(context("second"), Operation.OUTBOUND_DETAIL, HASH,
h -> { h.startHttp(); starts.add(clock.instant()); return true; }); });
assertThat(secondReady.await(2, TimeUnit.SECONDS)).isTrue();
assertThat(ledger.authorizations).isEqualTo(1); assertThat(second.isDone()).isFalse();
release.countDown(); assertThat(first.get(2, TimeUnit.SECONDS)).isTrue(); assertThat(second.get(2, TimeUnit.SECONDS)).isTrue();
assertThat(starts).hasSize(2); assertThat(Duration.between(starts.get(0), starts.get(1))).isGreaterThanOrEqualTo(Duration.ofSeconds(5));
assertThat(ledger.completions).hasSize(2);
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() {
assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null, false)).isInstanceOf(IllegalArgumentException.class);
assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
}
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {
var gate = gate(); ledger.denial = new AuthorizationDenial("VALIDATION_TOTAL_LIMIT", true, null);
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("denied"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
assertThat(error.denial()).isEqualTo(ledger.denial);
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isFalse();
assertThat(ledger.consumed).isEmpty(); assertThat(ledger.completions).isEmpty();
ledger.denial = null;
assertThat(gate.execute(context("fresh-session-attempt"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
}
private static EgressContext businessContext(String attempt) {
return new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", attempt, "demo-grant");
}
private LingxingWmsException rateLimit() { return new LingxingWmsException("RATE_LIMIT", "synthetic rate limit", false, 429, null); }
@Test void businessCooldownRequiresFreshDurablePermitBeforeRecovery() {
var gate = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("limited"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(clock.instant().plusSeconds(600)); throw rateLimit();
}));
int used = ledger.authorizations;
assertThatThrownBy(() -> gate.execute(businessContext("too-early"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(used);
clock.advanceMillis(600000); ledger.denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, clock.instant().plusSeconds(60));
var denied = assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("still-denied"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; }));
assertThat(denied.egress().actualHttpAttempts()).isZero(); assertThat(denied.egress().accountPaused()).isTrue();
ledger.denial = null;
var accepted = gate.execute(businessContext("accepted"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return h.metadata(); });
assertThat(accepted.actualHttpAttempts()).isEqualTo(1); assertThat(accepted.accountPaused()).isFalse(); assertThat(accepted.cooldownUntil()).isNull();
assertThat(ledger.completions).hasSize(2);
}
@Test void committedRateCompletionWithLostAckReturnsUncertaintyInsteadOfRecoverableRateError() {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"production"),ledger,clock,clock::advanceMillis);
ledger.failComplete=true;
var error=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("rate-lost-ack"),Operation.WAREHOUSES,HASH,handle->{
handle.startHttp();handle.rateLimited(clock.instant().plusSeconds(600));throw rateLimit();
}));
assertThat(error.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);assertThat(error.egress().accountPaused()).isTrue();
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(600));
assertThat(ledger.completions).singleElement().satisfies(result->{
assertThat(result.upstreamStatus()).isEqualTo(429);assertThat(result.actualHttpAttempts()).isEqualTo(1);
assertThat(result.accountPaused()).isTrue();
});
clock.advanceMillis(601000);ledger.failComplete=false;
assertThatThrownBy(()->gate.execute(businessContext("after-uncertain-ack"),Operation.WAREHOUSES,HASH,handle->true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
}
@Test void validationRatePauseAndUnconfirmedCallbackNeverRecoverByTime() {
var gate = gate();
assertThrows(LingxingWmsException.class, () -> gate.execute(context("limited"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(null); throw rateLimit();
}));
clock.advanceMillis(600000);
assertThatThrownBy(() -> gate.execute(context("next"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
ledger.failComplete = true;
assertThrows(LingxingWmsException.class, () -> business.execute(businessContext("lost-complete"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(null); throw rateLimit();
}));
clock.advanceMillis(600000); ledger.failComplete = false;
assertThatThrownBy(() -> business.execute(businessContext("no-recover"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(2);
}
static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
synchronized void advanceMillis(long millis) { now = now.plusMillis(millis); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public synchronized Instant instant() { return now; }
}
/** Synthetic durable single-use ledger, shared between independently constructed gates. */
static class LedgerAuthority implements LingxingBudgetAuthority {
final Clock clock;
final Set<String> consumed = new LinkedHashSet<>();
final List<CompletionRequest> completions = new ArrayList<>();
volatile int authorizations;
boolean failAuthorize, failComplete;
AuthorizationDenial denial;
UnaryOperator<AttemptPermit> mutation = UnaryOperator.identity();
LedgerAuthority(Clock clock) { this.clock = clock; }
@Override public synchronized AttemptPermit authorize(AuthorizationRequest request) {
authorizations++;
if (denial != null) throw new LingxingBudgetDeniedException(denial);
if (failAuthorize || !consumed.add(request.context().attemptId())) throw new IllegalStateException("budget refused");
return mutation.apply(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, clock.instant().plusSeconds(30), true, true));
}
@Override public synchronized void complete(CompletionRequest request) {
completions.add(request); if (failComplete) throw new IllegalStateException("completion not durable");
}
}
}
@@ -0,0 +1,57 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.HexFormat;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class LingxingEgressRuntimeSnapshotTest {
private static final Instant NOW = Instant.parse("2026-10-13T14:00:00Z");
private final LingxingBudgetAuthority authority = mock(LingxingBudgetAuthority.class);
private LingxingEgressGate gate(CollectionMode mode, String account, String grant, URI uri) {
return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", account, grant, null, uri,
mode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1200), null, true),
authority, Clock.fixed(NOW, ZoneOffset.UTC), millis -> { throw new AssertionError("DIAGNOSTIC_MUST_NOT_WAIT"); });
}
private static String hash(String domain, String value) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8)));
}
@Test void sameGateReturnsActualBindingsWithDomainSeparatedExactRawFingerprints() throws Exception {
var snapshot = gate(CollectionMode.BUSINESS, "same-raw-value", "same-raw-value", URI.create("http://authority:9303")).runtimeSnapshot(true);
assertThat(snapshot.schemaVersion()).isEqualTo(1);assertThat(snapshot.service()).isEqualTo("integration");
assertThat(snapshot.observedAt()).isEqualTo(NOW);assertThat(snapshot.mode()).isEqualTo("BUSINESS");
assertThat(snapshot.environment()).isEqualTo("test");assertThat(snapshot.continuousEnabled()).isTrue();
assertThat(snapshot.providerEnabled()).isTrue();assertThat(snapshot.grantPresent()).isTrue();
assertThat(snapshot.intervalMs()).isEqualTo(1200);assertThat(snapshot.permitMaxSeconds()).isEqualTo(30);
assertThat(snapshot.sourceAccountFingerprint()).isEqualTo(hash("source-account", "same-raw-value"));
assertThat(snapshot.grantFingerprint()).isEqualTo(hash("grant", "same-raw-value")).isNotEqualTo(snapshot.sourceAccountFingerprint());
assertThat(snapshot.budgetAuthorityFingerprint()).isEqualTo(hash("budget-authority", "http://authority:9303"));
assertThat(gate(CollectionMode.BUSINESS, " raw ", " raw ", null).runtimeSnapshot(false).grantFingerprint())
.isEqualTo(hash("grant", " raw ")).isNotEqualTo(hash("grant", "raw"));
verifyNoInteractions(authority);
}
@Test void offAndValidationAreObservableAndAbsentOrBlankValuesAreNotHashed() {
for (String value : new String[] {null, "", " "}) {
var s = gate(CollectionMode.OFF, value, value, null).runtimeSnapshot(false);
assertThat(s.mode()).isEqualTo("OFF");assertThat(s.sourceAccountFingerprint()).isNull();
assertThat(s.grantPresent()).isFalse();assertThat(s.grantFingerprint()).isNull();
assertThat(s.budgetAuthorityFingerprint()).isNull();assertThat(s.providerEnabled()).isFalse();
}
assertThat(gate(CollectionMode.VALIDATION, null, null, null).runtimeSnapshot(false).intervalMs()).isEqualTo(5000);
verifyNoInteractions(authority);
}
@Test void serializedSnapshotContainsNoRawIdentityAddressCredentialOrVerifiedClaim() throws Exception {
var raw = new ObjectMapper().findAndRegisterModules().writeValueAsString(
gate(CollectionMode.BUSINESS, "private-account", "private-grant", URI.create("http://private-host:9303")).runtimeSnapshot(true));
assertThat(raw).doesNotContain("private-account", "private-grant", "private-host", "http://", "verified", "appSecret", "internalServiceToken");
verifyNoInteractions(authority);
}
}
@@ -0,0 +1,143 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.*;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Loopback authority only; the persisted collector ledger is tested by its own service. */
class LingxingHttpBudgetAuthorityTest {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final AtomicReference<String> response = new AtomicReference<>();
private final AtomicInteger status = new AtomicInteger(200);
private final AtomicInteger calls = new AtomicInteger();
private final AtomicReference<String> path = new AtomicReference<>(), token = new AtomicReference<>(), body = new AtomicReference<>();
private HttpServer server;
private static final String TOKEN = "synthetic-internal-token-0123456789";
private final AuthorizationRequest request = new AuthorizationRequest(LingxingEgressGateTest.context("attempt"), Operation.WAREHOUSES, LingxingEgressGateTest.HASH);
@BeforeEach void start() throws IOException {
response.set("{\"code\":\"200\",\"data\":true}");
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> {
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
token.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
exchange.getResponseHeaders().set("Location", "/redirect-must-not-follow");
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status.get(), bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
}
@AfterEach void stop() { server.stop(0); }
private LingxingHttpBudgetAuthority authority(String serviceToken) {
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false), mapper, serviceToken);
}
private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); }
private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1,
LingxingEgressGateTest.NOW.plusSeconds(30), true, true)); }
@Test void fixedInternalEndpointsAndTokenCarryExactAuthorizationAndCompletionContext() throws Exception {
response.set("{\"code\":\"200\",\"data\":" + permit() + "}");
var permit = authority(TOKEN).authorize(request);
assertThat(permit.context()).isEqualTo(request.context()); assertThat(permit.allowedAttempts()).isEqualTo(1);
assertThat(path.get()).isEqualTo("/internal/lastleg/self-operated-analytics/egress/authorize");
assertThat(token.get()).isEqualTo(TOKEN);
assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(request));
response.set("{\"code\":\"200\",\"data\":true}");
authority(TOKEN).complete(completion());
assertThat(path.get()).endsWith("/egress/complete"); assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(completion()));
assertThat(calls.get()).isEqualTo(2);
}
@ParameterizedTest @ValueSource(strings = {"false", "\"true\"", "1", "{}", "null"})
void completionRequiresLiteralBooleanAcknowledgment(String data) {
response.set("{\"code\":\"200\",\"data\":" + data + "}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"200", "\"201\"", "null"})
void envelopeRequiresExactStringSuccessCode(String code) {
response.set("{\"code\":" + code + ",\"data\":true}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(ints = {302, 401, 429, 503})
void statusErrorsNeverRetryOrFollowRedirectAndNeverEchoAuthorityBody(int code) {
status.set(code); response.set("private-fixture@example.test");
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.getCause()).isNull(); assertThat(error.getMessage()).doesNotContain("private-fixture", "synthetic-token");
assertThat(calls.get()).isEqualTo(1);
}
@Test void missingTokenPreventsAllAuthorityHttp() {
assertThrows(LingxingWmsException.class, () -> authority("").authorize(request));
assertThrows(LingxingWmsException.class, () -> authority(null).complete(completion()));
assertThat(calls.get()).isZero();
}
@Test void missingServerConfiguredAuthorityPreventsHttp() {
var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null, false), mapper, "synthetic-token");
assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero();
}
@Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception {
String valid = permit();
String[] invalid = {valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":2"),
valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":\"1\""),
valid.replace("\"exclusiveConfirmed\":true", "\"exclusiveConfirmed\":\"true\""),
valid.replace("\"budgetConsumed\":true", "\"budgetConsumed\":false"),
valid.substring(0, valid.length() - 1) + ",\"untrusted\":true}",
valid.replace("\"operation\":\"WAREHOUSES\"", "\"operation\":\"UNKNOWN\"")};
for (var data : invalid) {
response.set("{\"code\":\"200\",\"data\":" + data + "}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
}
assertThat(calls.get()).isEqualTo(invalid.length);
}
@Test void trailingTokensAndMalformedResponseFailClosed() {
for (String value : new String[] {"{\"code\":\"200\",\"data\":true} {}", "[]", "{"}) {
response.set(value); assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
}
assertThat(calls.get()).isEqualTo(3);
}
@Test void knownAuthorityDenialIsTypedAndDoesNotBecomeUnavailability() {
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"SESSION_EXPIRED\",\"sampleDone\":true,\"waitUntil\":null}}");
var error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.denial()).isEqualTo(new AuthorizationDenial("SESSION_EXPIRED", true, null));
assertThat(calls.get()).isEqualTo(1);
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:10:00Z\"}}");
error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.denial().waitUntil()).isEqualTo(LingxingEgressGateTest.NOW.plusSeconds(600));
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
}
@ParameterizedTest @ValueSource(strings = {
"{\"errorCode\":\"private@example.test\",\"sampleDone\":true,\"waitUntil\":null}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":\"true\",\"waitUntil\":null}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":\"invalid\"}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":null,\"extra\":true}"
})
void malformedDenialFailsAsUnavailability(String data) {
response.set("{\"code\":\"B0001\",\"data\":" + data + "}");
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.code()).endsWith("BUDGET_AUTHORITY_UNAVAILABLE");
assertThat(error.getMessage()).doesNotContain("private@example.test"); assertThat(calls.get()).isEqualTo(1);
}
@Test void allControlDtosRoundTripWithProductionTimeSerialization() throws Exception {
var permit = new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, LingxingEgressGateTest.NOW.plusSeconds(30), true, true);
var metadata = new AttemptMetadata("attempt", 1, true, LingxingEgressGateTest.NOW.plusSeconds(600));
var denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, metadata.cooldownUntil());
for (var dto : new Object[] {request.context(), request, permit, completion(), metadata, denial}) {
assertThat(mapper.readValue(mapper.writeValueAsString(dto), dto.getClass())).isEqualTo(dto);
}
}
}
@@ -0,0 +1,50 @@
package com.baiyue.integration.provider.lingxing;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
import static org.assertj.core.api.Assertions.*;
import com.baiyue.integration.config.LingxingQuantityCaptureProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import org.junit.jupiter.api.Test;
class LingxingQuantityCaptureTest {
final Instant now=Instant.parse("2026-10-14T00:00:00Z");final ObjectMapper json=new ObjectMapper();
final EgressContext context=new EgressContext("account",CollectionMode.BUSINESS,"2","selfop-2","attempt","grant");
View view(){return new View("diag","account",null,null,"ARMED",null,now,now.plusSeconds(36000),now.plusSeconds(21600),List.of("W"),"*","1",0,0,0,0,false,List.of());}
com.fasterxml.jackson.databind.JsonNode list(String id)throws Exception{return json.readTree("{\"records\":[{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\""+id+"\",\"productList\":[{\"productSku\":\"S\",\"qty\":10}]}]}");}
class Fake implements Sink {View state=view();List<Event> received=new ArrayList<>();String stopped;public View status(Status s){return state;}public Receipt observe(Event e){received.add(e);return new Receipt("ACCEPTED",null,state);}public View stop(Stop s){stopped=s.reason();return state;}}
LingxingQuantityCapture capture(Sink sink){return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true,"account"),sink,Clock.fixed(now,ZoneOffset.UTC),false);}
@Test void observationDoesNotCallReceiverAndLocateNeverBinds()throws Exception{
Fake sink=new Fake();try(var c=new TestClose(capture(sink))){c.value.pump();c.value.observe(list("a"),Operation.OUTBOUND_LOCATE,context,now);c.value.pump();assertThat(sink.received).isEmpty();
c.value.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThat(sink.received).isEmpty();c.value.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).operation()).isEqualTo("OUTBOUND_QUERY");}
}
@Test void slowReceiverNeverBlocksNormalObservationAndFullQueueStopsOnlyDiagnosis()throws Exception{
CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1);Fake sink=new Fake(){public Receipt observe(Event e){entered.countDown();try{release.await(3,TimeUnit.SECONDS);}catch(InterruptedException x){Thread.currentThread().interrupt();}return super.observe(e);}};
var c=capture(sink);c.pump();c.observe(list("first"),Operation.OUTBOUND_QUERY,context,now);var worker=Executors.newSingleThreadExecutor();try{var pending=worker.submit(c::pump);assertThat(entered.await(1,TimeUnit.SECONDS)).isTrue();
assertThatCode(()->{for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now);}).doesNotThrowAnyException();
release.countDown();pending.get(5,TimeUnit.SECONDS);
}finally{release.countDown();worker.shutdownNow();c.close();}
}
@Test void failureIsolatedAndNoRearmOfSameGrant()throws Exception{
Fake sink=new Fake(){public Receipt observe(Event e){throw new IllegalStateException("receiver down");}};var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThatCode(c::pump).doesNotThrowAnyException();assertThat(c.stopReason()).isEqualTo("INCOMPLETE");c.pump();assertThat(sink.stopped).isEqualTo("INCOMPLETE");}finally{c.close();}
}
@Test void selectedCandidatesRecoveredWithoutReobservingList()throws Exception{
Fake sink=new Fake();sink.state=new View("diag","account","2","selfop-2","BOUND",null,now,now.plusSeconds(14400),now.plusSeconds(21600),List.of("W"),"*","1",1,0,1,100,true,List.of(new Key("W","C","a",false)));
var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);c.observe(json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"a\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}"),Operation.OUTBOUND_DETAIL,context,now);c.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).side()).isEqualTo("DETAIL");}finally{c.close();}
}
@Test void malformedOrOversizedSnapshotsAreIsolated()throws Exception{
Fake sink=new Fake();var c=capture(sink);try{c.pump();var data=json.createObjectNode();var records=data.putArray("records");for(int i=0;i<101;i++)records.addObject();assertThatCode(()->c.observe(data,Operation.OUTBOUND_QUERY,context,now)).doesNotThrowAnyException();c.pump();assertThat(sink.stopped).isEqualTo("OBSERVER_FAILED");assertThat(sink.received).isEmpty();}finally{c.close();}
}
@Test void queueIsBoundedAndNoProducerWaitsForIt()throws Exception{
Fake sink=new Fake();var c=capture(sink);try{c.pump();for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now);
var detail=json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"o0\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}");
c.observe(detail,Operation.OUTBOUND_DETAIL,context,now);assertThat(c.stopReason()).isEqualTo("QUEUE_FULL");c.pump();assertThat(sink.stopped).isEqualTo("QUEUE_FULL");assertThat(sink.received).isEmpty();
}finally{c.close();}
}
@Test void diagnosticLockContentionNeverWaitsOnCollector()throws Exception{
Fake sink=new Fake();var c=capture(sink);c.pump();var field=LingxingQuantityCapture.class.getDeclaredField("intake");field.setAccessible(true);var lock=(java.util.concurrent.locks.ReentrantLock)field.get(c);
var pool=Executors.newSingleThreadExecutor();lock.lock();try{var observation=pool.submit(()->{try{c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);}catch(Exception e){throw new RuntimeException(e);}});observation.get(500,TimeUnit.MILLISECONDS);assertThat(c.stopReason()).isEqualTo("OBSERVER_FAILED");}finally{lock.unlock();pool.shutdownNow();c.close();}
}
record TestClose(LingxingQuantityCapture value)implements AutoCloseable{public void close(){value.close();}}
}
@@ -0,0 +1,32 @@
package com.baiyue.integration.provider.lingxing;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.Flow;
import static org.assertj.core.api.Assertions.*;
class LingxingQuantityHttpSinkTest {
@Test void internalWireContractAuthenticatesAndDoesNotRetry()throws Exception{
var server=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0);var calls=new java.util.concurrent.atomic.AtomicInteger();var header=new java.util.concurrent.atomic.AtomicReference<String>();var path=new java.util.concurrent.atomic.AtomicReference<String>();
server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/",exchange->{calls.incrementAndGet();header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));path.set(exchange.getRequestURI().getPath());exchange.getRequestBody().readAllBytes();byte[] payload="{\"code\":\"200\",\"data\":null}".getBytes(java.nio.charset.StandardCharsets.UTF_8);exchange.sendResponseHeaders(200,payload.length);exchange.getResponseBody().write(payload);exchange.close();});server.start();
try{
var props=new com.baiyue.integration.config.LingxingEgressProperties(com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode.OFF,"test",null,null,null,java.net.URI.create("http://127.0.0.1:"+server.getAddress().getPort()),null,null,false);
String token="synthetic-internal-token-000000000000";var sink=new LingxingQuantityHttpSink(props,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(),token);
assertThat(sink.status(new LingxingQuantityEvidence.Status("account","producer",true,false))).isNull();assertThat(header.get()).isEqualTo(token);assertThat(path.get()).endsWith("/status");assertThat(calls.get()).isEqualTo(1);
sink.stop(new LingxingQuantityEvidence.Stop("diag","account","producer","INCOMPLETE"));assertThat(path.get()).endsWith("/stop");assertThat(calls.get()).isEqualTo(2);
}finally{server.stop(0);}
}
@Test void bodyLimitCancelsBeforeRetainingOversizedResponse(){
var body=new LingxingQuantityHttpSink.LimitedBody(16);boolean[] cancelled={false};body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){cancelled[0]=true;}});
body.onNext(List.of(ByteBuffer.wrap(new byte[8])));body.onNext(List.of(ByteBuffer.wrap(new byte[9])));
assertThat(cancelled[0]).isTrue();assertThat(body.getBody().toCompletableFuture()).isCompletedExceptionally();
}
@Test void hundredFullUnicodeKeysFitTheBoundedResponse()throws Exception{
var keys=new java.util.ArrayList<LingxingQuantityEvidence.Key>();for(int i=0;i<100;i++)keys.add(new LingxingQuantityEvidence.Key("倉".repeat(64),"客".repeat(128),"単".repeat(125)+i,false));
byte[] payload=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(java.util.Map.of("code","200","data",java.util.Map.of("selected",keys)));
assertThat(payload.length).isGreaterThan(65536).isLessThan(262144);
var body=new LingxingQuantityHttpSink.LimitedBody(262144);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(payload)));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).isEqualTo(payload);
}
@Test void boundedSuccessfulBodyIsExact(){
var body=new LingxingQuantityHttpSink.LimitedBody(16);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(new byte[]{1,2})));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).containsExactly(1,2);
}
}
@@ -0,0 +1,313 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Synthetic loopback transport only; no supplier or application services. */
class LingxingWmsClientTest {
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
private static final String PRIVATE = "private-secret@example.test";
private static final String ORDER = """
{"deliveryNo":"DO-DEMO","sourceNo":"SRC-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO",
"status":100,"outboundTime":"2026-10-01 01:00:00"}
""";
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
private final AtomicInteger calls = new AtomicInteger();
private final AtomicInteger status = new AtomicInteger(200);
private final AtomicReference<String> response = new AtomicReference<>();
private final AtomicReference<String> body = new AtomicReference<>();
private final AtomicReference<String> path = new AtomicReference<>();
private HttpServer server;
private final AtomicInteger attempts = new AtomicInteger();
private final AtomicReference<String> retryAfter = new AtomicReference<>();
private final AtomicReference<CompletionRequest> completion = new AtomicReference<>();
@BeforeEach void start() throws IOException {
response.set(page("current", "size", 1, 1, 1, 1, ORDER));
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/openapi/v2/", exchange -> {
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
if (retryAfter.get() != null) exchange.getResponseHeaders().set("Retry-After", retryAfter.get());
exchange.sendResponseHeaders(status.get(), bytes.length);
exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
}
@AfterEach void stop() { server.stop(0); }
@Test void observerFailureCannotChangeBusinessResponseOrIssueExtraRequests() {
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
org.mockito.Mockito.doThrow(new IllegalStateException("diagnostic failure")).when(observer)
.observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(Operation.class),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
var c = client(); c.setQuantityCapture(observer);
assertThat(c.queryPage(query(1, 1), context()).records()).hasSize(1);
assertThat(calls.get()).isEqualTo(1);
}
@Test void deniedOrRateLimitedRequestsNeverReachObserver() {
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
var disabled = client(CollectionMode.OFF, true, true); disabled.setQuantityCapture(observer);
assertThrows(LingxingWmsException.class, () -> disabled.queryPage(query(1, 1), context()));
assertThat(calls.get()).isZero();
var enabled = client(); enabled.setQuantityCapture(observer); status.set(429);
assertThrows(LingxingWmsException.class, () -> enabled.queryPage(query(1, 1), context()));
org.mockito.Mockito.verifyNoInteractions(observer); assertThat(calls.get()).isEqualTo(1);
}
@Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception {
var result = client().queryPage(query(1, 1), context());
var data = mapper.readTree(body.get()).path("data");
assertThat(data.size()).isEqualTo(6);
assertThat(data.path("timeType").asText()).isEqualTo("outboundTime");
assertThat(data.path("status").intValue()).isEqualTo(100);
assertThat(data.has("whCode")).isFalse();
assertThat(path.get()).isEqualTo("/openapi/v2/order/delivery/page");
assertThat(result.observedAt()).isEqualTo(NOW);
assertThat(result.pages()).isEqualTo(1);
}
@Test void listProductsPreserveDecimalsZeroAndMissingQuantity() {
response.set(page("current","size",1,1,1,1,ORDER.strip().replace("}","""
,"productList":[{"productSku":"SKU","qty":2.123456},{"productSku":"ZERO","qty":0},
{"productSku":"UNKNOWN"},{"qty":4}]}""")));
var products=client().queryPage(query(1,1),context()).records().get(0).productList();
assertThat(products).hasSize(4);
assertThat(products.get(0).qty()).isEqualByComparingTo("2.123456");
assertThat(products.get(1).qty()).isEqualByComparingTo("0");
assertThat(products.get(2).qty()).isNull();assertThat(products.get(3).productSku()).isNull();
assertThat(calls.get()).isEqualTo(1);
}
@Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception {
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", "")));
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
var c = client(); c.setQuantityCapture(observer);
var result = c.locate(new LocateRequest("DO-DEMO", 1, 1), context());
org.mockito.Mockito.verifyNoInteractions(observer);
var data = mapper.readTree(body.get()).path("data");
assertThat(data.size()).isEqualTo(3);
assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse();
assertThat(result.records().get(0).status()).isEqualTo(99);
assertThat(result.records().get(0).outboundTime()).isEmpty();
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("DO-DEMO", "DO-DEMO-X")));
contract(() -> client().locate(new LocateRequest("DO-DEMO", 1, 1), context()), "LOCATE_DELIVERY_NO_MISMATCH");
}
@Test void exactEmptyExceptionKeepsSourceSizeAndNeverAppliesToInventory() {
response.set(page("current", "size", 1, 10, 0, 0, ""));
var result = client().queryPage(query(1, 100), context());
assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero();
assertThat(result.size()).isEqualTo(100); assertThat(result.sourceSize()).isEqualTo(10);
contract(() -> client().queryPage(query(1, 99), context()), "SIZE_MISMATCH");
contract(() -> client().queryPage(query(2, 100), context()), "CURRENT_MISMATCH");
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 100), context()), "SIZE_MISMATCH");
response.set(page("current", "size", 1, 10, 1, 1, ORDER));
contract(() -> client().queryPage(query(1, 100), context()), "SIZE_MISMATCH");
}
@Test void warehousesMapPageAndPageSizeWithoutTreatingTimezoneAsSource() throws Exception {
response.set(page("page", "pageSize", 1, 1000, 1, 1, """
{"whCode":"W-DEMO","whNameCn":"Demo","warehouseType":1,"status":0,"timeZone":"America/New_York","address":"private"}
"""));
var result = client().queryWarehouses(new WarehouseQueryRequest(1, 1000), context());
assertThat(result.sourceSize()).isEqualTo(1000);
assertThat(result.records().get(0).timeZone()).isEqualTo("America/New_York");
assertThat(mapper.readTree(body.get()).path("data").size()).isEqualTo(2);
assertThat(path.get()).isEqualTo("/openapi/v2/warehouse/page");
assertThat(mapper.writeValueAsString(result)).doesNotContain("address");
}
@Test void detailPreservesDecimalLinesAndIgnoresPackageDuplicates() throws Exception {
response.set("""
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":100,
"orderCreateTime":"2026-09-01 01:00:00","outboundTime":"2026-10-01 01:00:00",
"skuVOList":[{"productSku":"SKU","actualDeliveryQty":2.123456,"deliveryQty":6},
{"productSku":"SKU","actualDeliveryQty":3,"deliveryQty":6}],
"packageList":[{"skuList":[{"actualDeliveryQty":"5"}]}],"email":"private"}}
""");
var result = client().queryDetail(detail(), context());
assertThat(result.skuVOList()).hasSize(2);
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isEqualByComparingTo("2.123456");
assertThat(result.orderCreateTime()).isEqualTo("2026-09-01 01:00:00");
assertThat(mapper.writeValueAsString(result.skuVOList())).doesNotContain("email", "packageList");
assertThat(mapper.readTree(body.get()).path("data").toString())
.isEqualTo("{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}");
response.set(response.get().replace("W-DEMO", "OTHER"));
contract(() -> client().queryDetail(detail(), context()), "DETAIL_IDENTITY_MISMATCH");
}
@Test void cancelledDetailRetainsUnknownQuantityAndEmptyTime() {
response.set("""
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":99,
"outboundTime":"","skuVOList":[{"productSku":"SKU","actualDeliveryQty":"","deliveryQty":1}]}}
""");
var result = client().queryDetail(detail(), context());
assertThat(result.status()).isEqualTo(99); assertThat(result.outboundTime()).isEmpty();
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isNull();
}
@Test void inventoryPreservesIndependentNumbersAndUnknownsAndRejectsDuplicates() throws Exception {
String stock = """
{"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU","productQuality":0,
"stkQty":120.123456,"freezeQty":20,"useStkQty":100,"transitQty":30,"innerBatchNo":"",
"stockCompare":{"stkQty":999},"phone":"private"}
""";
response.set(page("current", "size", 1, 1000, 1, 1, stock));
var result = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context());
assertThat(mapper.readTree(body.get()).path("data").toString())
.isEqualTo("{\"current\":1,\"size\":1000,\"whCode\":\"W-DEMO\",\"isHideInventory\":0}");
var row = result.records().get(0);
assertThat(row.stkQty()).isEqualByComparingTo("120.123456");
assertThat(row.useStkQty()).isEqualByComparingTo("100"); assertThat(row.transitQty()).isEqualByComparingTo("30");
assertThat(row.innerBatchNo()).isEmpty();
assertThat(mapper.writeValueAsString(result.records())).doesNotContain("phone", "stockCompare");
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("120.123456", "0")
.replace("\"freezeQty\":20", "\"freezeQty\":\"\"").replace("\"useStkQty\":100", "\"useStkQty\":null")
.replace("\"transitQty\":30", "\"transitQty\":-1")));
row = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()).records().get(0);
assertThat(row.stkQty()).isZero(); assertThat(row.freezeQty()).isNull(); assertThat(row.useStkQty()).isNull();
assertThat(row.transitQty()).isEqualByComparingTo("-1");
response.set(page("current", "size", 1, 1000, 2, 1, stock + "," + stock));
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "DUPLICATE_INVENTORY_KEY");
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("W-DEMO", "OTHER")));
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "INVENTORY_WAREHOUSE_MISMATCH");
}
@ParameterizedTest @ValueSource(strings = {"total", "pages", "current", "size"})
void missingPaginationIsRejected(String field) {
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"" + field + "\":1,", ""));
contract(() -> client().queryPage(query(1, 1), context()), "");
}
@Test void shortPageAndWrongPageCountAreRejected() {
response.set(page("current", "size", 1, 2, 2, 1, ORDER));
contract(() -> client().queryPage(query(1, 2), context()), "PAGE_ROW_COUNT_MISMATCH");
response.set(page("current", "size", 1, 1, 1, 2, ORDER));
contract(() -> client().queryPage(query(1, 1), context()), "PAGES_MISMATCH");
}
@ParameterizedTest @ValueSource(ints = {401, 403, 302})
void nonretryableHttpNeverEchoesOrRetries(int code) {
status.set(code); response.set(PRIVATE);
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(error.retryable()).isFalse(); assertThat(error.getCause()).isNull();
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(ints = {429, 500, 503})
void everyFailureHasOneHttpAttemptAndNeverImplicitRetry(int code) {
status.set(code); response.set(PRIVATE);
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(calls.get()).isEqualTo(1);
assertThat(error.getMessage()).doesNotContain(PRIVATE);
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
assertThat(completion.get().actualHttpAttempts()).isEqualTo(1);
assertThat(completion.get().upstreamStatus()).isEqualTo(code);
}
@ParameterizedTest @ValueSource(strings = {"600", "Mon, 12 Oct 2026 00:10:00 GMT", "invalid"})
void rateLimitPausesAccountAcrossAllEntrypointsAndReportsCooldown(String header) {
status.set(429); response.set(PRIVATE); retryAfter.set(header);
var client = client();
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query(1, 1), context()));
assertThat(error.egress().accountPaused()).isTrue();
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(header.equals("invalid") ? 300 : 600));
assertThat(completion.get().accountPaused()).isTrue();
assertThat(completion.get().cooldownUntil()).isEqualTo(error.egress().cooldownUntil());
assertThatThrownBy(() -> client.locate(new LocateRequest("DO-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryWarehouses(new WarehouseQueryRequest(1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(calls.get()).isEqualTo(1);
}
@Test void validationRejectsLargePageBeforeHttpOrBudget() {
var c = client(CollectionMode.VALIDATION, true, true);
assertThatThrownBy(() -> c.queryPage(query(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.locate(new LocateRequest("DO-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.queryWarehouses(new WarehouseQueryRequest(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@Test void validationNeverRetriesEvenServerError() {
status.set(503); response.set(PRIVATE);
assertThrows(LingxingWmsException.class, () -> client(CollectionMode.VALIDATION, true, true)
.queryPage(query(1, 1), context(CollectionMode.VALIDATION)));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"31001", "\"429\""})
void businessRateLimitCodesPauseWithoutHttpRetry(String sourceCode) {
response.set("{\"code\":" + sourceCode + ",\"msg\":\"" + PRIVATE + "\"}");
var c = client();
var error = assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
assertThat(completion.get().sourceCode()).isIn(31001, 429);
assertThatThrownBy(() -> c.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(calls.get()).isEqualTo(1); assertThat(error.getMessage()).doesNotContain(PRIVATE);
}
@Test void allFiveEntrypointsRequireControlContext() {
var c = client();
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), null));
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), null));
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@Test void allFiveEntrypointsFailClosedForOffMissingGrantOrBudget() {
for (var c : new LingxingWmsClient[] {client(CollectionMode.OFF, true, true),
client(CollectionMode.BUSINESS, false, true), client(CollectionMode.BUSINESS, true, false)}) {
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), context()));
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context()));
}
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@ParameterizedTest @ValueSource(strings = {"\"11006\"", "11008", "\"200\"", "null"})
void rejectedEnvelopesNeverEchoRawMessage(String code) {
response.set("{\"code\":" + code + ",\"msg\":\"" + PRIVATE + "\"}");
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(error.getCause()).isNull();
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"false", "null", "\"true\""})
void presentSuccessMustBeBooleanTrue(String success) {
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success));
assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
}
private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); }
private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) {
var clock = new LingxingEgressGateTest.MutableClock(NOW);
var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
"synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2));
var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null,
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null, false);
var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) {
@Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); }
};
var gate = new LingxingEgressGate(props, ledger, clock, clock::advanceMillis);
return new LingxingWmsClient(p, mapper, clock, gate);
}
private EgressContext context() { return context(CollectionMode.BUSINESS); }
private EgressContext context(CollectionMode mode) {
return new EgressContext("demo-account", mode, "demo-root", "demo-session", "attempt-" + attempts.incrementAndGet(), "demo-grant");
}
private static QueryRequest query(int current, int size) {
var end = LocalDateTime.of(2026, 10, 12, 8, 0); return new QueryRequest(end.minusMonths(1), end, current, size);
}
private static DetailRequest detail() { return new DetailRequest("W-DEMO", "C-DEMO", "DO-DEMO"); }
private static String page(String c, String s, int current, int size, long total, int pages, String rows) {
return "{\"code\":200,\"data\":{\"" + c + "\":" + current + ",\"" + s + "\":" + size
+ ",\"total\":" + total + ",\"pages\":" + pages + ",\"records\":[" + rows + "]}}";
}
private static void contract(Runnable action, String stage) {
var error = assertThrows(LingxingWmsException.class, action::run);
assertThat(error.code()).isEqualTo("LINGXING_WMS_INVALID_RESPONSE");
assertThat(error.getMessage()).contains(stage).doesNotContain(PRIVATE); assertThat(error.retryable()).isFalse();
}
}
@@ -0,0 +1,90 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LingxingWmsSignerTest {
@Test
void matchesOfficialOmpParameterRenderingIncludingNamesPathAndBothSecrets() throws Exception {
var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
var signer = new LingxingWmsSigner(mapper);
var data = new LinkedHashMap<String, Object>();
data.put("status", 0);
data.put("current", 1);
data.put("size", 10);
var signed = signer.sign("YOUR_APP_KEY", "YOUR_APP_SECRET", "/openapi/v2/customer/list", data,
Instant.ofEpochSecond(1790056664));
var body = mapper.readTree(signed.body());
String officialInput = "YOUR_APP_SECRET/openapi/v2/customer/listappKeyYOUR_APP_KEYdata{status=0, current=1, size=10}timestamp1790056664YOUR_APP_SECRET";
assertThat(body.path("sign").textValue()).isEqualTo(hmac("YOUR_APP_SECRET", officialInput));
var fieldNames = new ArrayList<String>();
body.path("data").fieldNames().forEachRemaining(fieldNames::add);
assertThat(fieldNames).containsExactly("status", "current", "size");
assertThat(signed.body()).doesNotContain("\n", "YOUR_APP_SECRET", "authcode", "reqTime");
assertThat(signed.toString()).doesNotContain("YOUR_APP_KEY", "YOUR_APP_SECRET", body.path("sign").textValue());
}
@Test
void nestedDataUsesTransmittedOrderAndMapListValueSyntax() throws Exception {
var mapper = new ObjectMapper();
var signer = new LingxingWmsSigner(mapper);
var child = new LinkedHashMap<String, Object>();
child.put("z", "中文");
child.put("A", 2);
var data = new LinkedHashMap<String, Object>();
data.put("items", List.of(child));
var signed = signer.sign("key", "secret", "/openapi/v2/customer/list", data, Instant.ofEpochSecond(1770000000));
String input = "secret/openapi/v2/customer/listappKeykeydata{items=[{z=中文, A=2}]}timestamp1770000000secret";
assertThat(mapper.readTree(signed.body()).path("sign").textValue()).isEqualTo(hmac("secret", input));
}
@Test
void propertiesBindWmsOnlyAndRedactBothCredentials() {
var source = new MapConfigurationPropertySource(Map.of(
"baiyue.integration.lingxing-wms.enabled", "true",
"baiyue.integration.lingxing-wms.app-key", "example-key",
"baiyue.integration.lingxing-wms.app-secret", "example-secret"));
var properties = new Binder(source).bind("baiyue.integration.lingxing-wms", Bindable.of(LingxingWmsProperties.class)).get();
assertThat(properties.enabled()).isTrue();
assertThat(properties.hasCredentials()).isTrue();
assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com"));
assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15));
assertThat(properties.toString()).doesNotContain("example-key", "example-secret");
}
@Test
void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() {
var properties = new LingxingWmsProperties(false, null, null, null, null, null);
assertThat(properties.enabled()).isFalse();
assertThat(properties.hasCredentials()).isFalse();
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null))
.isInstanceOf(IllegalArgumentException.class);
}
private static String hmac(String key, String input) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
}
}
+2 -11
View File
@@ -22,6 +22,7 @@
| 功能 | 首个入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` |
| 自营领星统计 | `selfoperated/analytics/`;平台入口 `controller/PlatformAnalyticsController.java`,契约见 `../../docs/自营业务/README.md`;权限源 `../iam/src/main/resources/db/selfoperated-analytics-permissions.sql`。 |
| 订单查询与控制 / 导出 | `controller/LastlegOrderController.java`;异步导出见 `export/` 与 `lastleg-exports-v1.yaml` |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` |
@@ -105,17 +106,7 @@
## 数据库表索引与注释约束
| 业务域 | 表名 | 中文含义 |
|---|---|---|
| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 |
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 |
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 |
| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 |
表名与中文含义按需查 `docs/business-index.md` 的“数据库表索引”;修改约束如下。
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。
- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。
+14
View File
@@ -66,3 +66,17 @@
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 |
## 数据库表索引
| 业务域 | 表名 | 中文含义 |
|---|---|---|
| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 |
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 |
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 |
| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 |
@@ -7,11 +7,23 @@
现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。
- 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。
- 25条已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。
- 已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。
- 未审阅的新增类引用仍按原切片门禁拦截;车辆应用层的向内依赖规则对所有引用执行,不能通过逐类记录绕过。fleet继续只消费车辆应用端口/服务,不读取车辆持久化适配器。
- 主源码禁止包循环。`DeliveryDocumentSnapshot` 是DTO快照解析和摘要装配,已从 `delivery/model` 移至 `delivery/service`,消除原DTO与model互相引用;枚举及公开JSON结构不变。不为循环设长期例外。
- 离线反向测试覆盖未授权新消费者、退役类型恢复/引用、通配记录、绕过车辆应用层和新增包环。通过门禁只证明这些结构规则,不证明消除了所有存量耦合或完成部署/业务验收。
### 预约、车队与导出依赖收口(2026-10-12)
- 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。
- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号保持原 LB/TR 规则,由本域生成;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。
- 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。
- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。
- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。
### 平台自营统计边界
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
### 车次接入边界
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
@@ -13,6 +13,7 @@ const sourceRoots = {
const internalPrefix = 'com.baiyue.lastleg.';
const sliceRules = [
['selfoperated-analytics', /^com\.baiyue\.lastleg\.selfoperated\.analytics(?:\.|$)/],
['fleet', /^com\.baiyue\.lastleg\.fleet(?:\.|$)/],
['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/],
['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/],
@@ -10,6 +10,27 @@ const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
const report = analyze();
const prefix = 'com.baiyue.lastleg.selfoperated.analytics.';
const classes = report.classes.filter(entry => entry.fqcn.startsWith(prefix));
assert.ok(classes.length > 0);
assert.ok(classes.some(entry => entry.packageName.endsWith('.inventory')));
assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound')));
assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics'));
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics'
|| edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }]);
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{
source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard',
}]);
assert.deepEqual(report.packageSccs.filter(cycle => cycle.some(pkg => pkg.startsWith(prefix))), []);
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const source = classes.find(entry => entry.packageName.endsWith('.inventory')).fqcn;
const target = 'com.baiyue.lastleg.inventory.query.service.InventoryQueryService';
report.classDependencies.push({ source, target });
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`)));
});
test('fleet stays a business slice and its policies have no infrastructure dependencies', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.fqcn.startsWith('com.baiyue.lastleg.fleet.'));
@@ -30,6 +51,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'),
'dispatch must use fleet ports and guards, not its persistence mappers');
@@ -42,6 +67,7 @@ function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
encoding: 'utf8',
maxBuffer: 8 * 1024 * 1024,
}));
}
@@ -85,7 +111,10 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat
target: 'com.baiyue.lastleg.security.OverseasPermissions',
}]);
assert.ok(incoming.length > 0);
assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.')));
assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.')
|| edge.source === 'com.baiyue.lastleg.export.LastlegExportProviderService'
&& [vehiclePrefix + '.application.VehicleApplicationService', vehiclePrefix + '.application.model.VehicleModels'].includes(edge.target)),
'vehicle consumers use application contracts; the export composition has two exact read-only references');
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith(vehiclePrefix))), false);
assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false);
assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false);
@@ -126,6 +155,22 @@ test('reviewed references do not allow another consumer into the same slice', ()
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`)));
});
test('export composition approvals do not grant persistence access or new consumers', () => {
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const source = 'com.baiyue.lastleg.export.LastlegExportProviderService';
const cases = [
{ source, target: vehiclePrefix + '.adapter.out.persistence.VehicleMapper' },
{ source, target: 'com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper' },
{ source: 'com.baiyue.lastleg.export.LastlegExportColumns', target: 'com.baiyue.lastleg.fleet.quote.FleetQuoteService' },
];
for (const edge of cases) {
const report = analyze();
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)),
'each unreviewed consumer/target pair must still fail');
}
});
test('restoring a retired projection or importing its missing type still fails', () => {
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const report = analyze();
@@ -0,0 +1,308 @@
/**
* TEST-only Gateway acceptance. Default is a local plan; --execute performs login,
* read-only queries and, with --probe-off-command, one deliberately rejected INVENTORY
* command after verifying OFF. Never collects, publishes, creates fixtures or changes config.
* Credentials: node --env-file=<explicit test-*.env> BEFORE this script; this script does not read credential files.
* Required env: CHONGCHONG_ENV=TEST, CHONGCHONG_ADMIN_API_BASE_URL (ends /api),
* CHONGCHONG_ADMIN_USERNAME/PASSWORD, CHONGCHONG_MERCHANT_USERNAME/PASSWORD,
* SELFOP_READONLY_USERNAME/PASSWORD (read permission only),
* SELFOP_DENIED_USERNAME/PASSWORD (platform without read or sync permission).
* Example: node --env-file=<test file> <script> --environment TEST --source-account <account>
* --warehouse <configured warehouse> --other-warehouse <second configured warehouse>
* --report <local json> --synthetic-fixture <fixture notes json> --execute --probe-off-command
* Reports contain case IDs, status, machine codes and request/trace IDs only; no payloads,
* URLs, credentials or customer identifiers. Partial/missing evidence is BLOCKED (exit 2).
* Re-run reads current state and uses a fresh rejection request key. No deletion or SQL.
* Register this service-local entry in the maintained script index before release.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { randomUUID } from 'node:crypto';
const ROOT='/platform/v1/last-leg/self-operated-analytics';
const READ='platform:self-operated-analytics:list', SYNC='platform:self-operated-analytics:sync';
export const READ_ROUTES=Object.freeze(['sku-sales-summary','sku-daily-sales','hot-skus','slow-skus',
'inventory-coverage','owners','owner-overview','sync-overview','sync-tasks','sync-runs']);
export const SAMPLE_REQUIREMENTS=Object.freeze([
'Two configured warehouses with BUSINESS/PUBLISHED outbound and inventory versions; do not collect or publish via this script.',
'At least two owners sharing one SKU, with distinguishable quantities and complete/partial/unknown days.',
'At least one existing warehouse sync task and attempt for task-detail and task/run warehouse isolation.',
]);
const demand=(condition,code)=>{if(!condition)throw new Error(code);};
const success=r=>r.http===200&&String(r.body?.code)==='200';
export const denied=r=>[401,403].includes(r.http)||(r.http===200&&['A0401','A0403'].includes(String(r.body?.code)));
export const businessRejected=r=>r.http===200&&['B0001','A0400','A0404','A0409','A0422'].includes(String(r.body?.code));
// A wrong scope, generic business error or infrastructure failure is not evidence of the OFF gate.
export const offRejected=r=>businessRejected(r)&&r.body?.message==='采集功能未启用,当前环境不允许执行采集或重试';
const safeCode=value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,80}$/.test(value)?value:null;
export function evidence(r){return {http:r.http,code:safeCode(String(r.body?.code??'')),
requestId:safeCode(r.body?.requestId??r.requestId),traceId:safeCode(r.body?.traceId??r.traceId)};}
export function parseArguments(args){
const o={execute:false,probeOff:false};
const names={'--environment':'environment','--source-account':'account','--warehouse':'warehouse',
'--other-warehouse':'otherWarehouse','--report':'report','--synthetic-fixture':'syntheticFixture'};
const seen=new Set();
for(let i=0;i<args.length;i++){
const key=args[i];demand(!seen.has(key),'DUPLICATE_ARGUMENT');seen.add(key);
if(key==='--execute'){o.execute=true;continue;}
if(key==='--probe-off-command'){o.probeOff=true;continue;}
demand(names[key]&&args[i+1]&&!args[i+1].startsWith('--'),'INVALID_ARGUMENT');o[names[key]]=args[++i];
}
demand(o.environment==='TEST','EXPLICIT_TEST_REQUIRED');
if(o.execute){demand(o.account&&o.warehouse&&o.report,'SCOPE_AND_REPORT_REQUIRED');}
if(o.otherWarehouse)demand(o.otherWarehouse!==o.warehouse,'SECOND_WAREHOUSE_MUST_DIFFER');
return o;
}
export function validateEnvironment(env){
demand(env.CHONGCHONG_ENV?.toUpperCase()==='TEST','TEST_ENV_REQUIRED');
let base;try{base=new URL(env.CHONGCHONG_ADMIN_API_BASE_URL);}catch{throw new Error('INVALID_GATEWAY');}
demand(['https:','http:'].includes(base.protocol)&&!base.username&&!base.password&&!base.search&&!base.hash&&
/\/api\/?$/.test(base.pathname),'INVALID_GATEWAY');
return base.href.replace(/\/$/,'');
}
export function plan(){return {environment:'TEST',mode:'PLAN',thirdPartyCalls:false,fixtureWrites:false,
routes:READ_ROUTES,authentication:['anonymous','authorized platform','read-only platform','denied platform','merchant'],
offProbe:'Opt-in only; rechecks OFF immediately before one rejected INVENTORY command. S0500 is a failure.',
syntheticEvidence:'--synthetic-fixture <reviewed notes JSON> enables thirteen numerical/isolation checks; never proves provider coverage.',
samples:SAMPLE_REQUIREMENTS,report:'Only case identifiers, outcomes, machine codes and request/trace IDs.'};}
const query=(values)=>new URLSearchParams(Object.entries(values).filter(([,v])=>v!==null&&v!==undefined).map(([k,v])=>[k,String(v)])).toString();
export function pin(context){
demand(context&&context.salesRunId!==null&&context.inventoryRunId!==null&&context.queryAsOf,'CONTEXT_MISSING');
return Object.fromEntries(['salesRunId','inventoryRunId','salesScopeHash','inventoryScopeHash','queryAsOf'].map(k=>[k,context[k]]));
}
function sameContext(a,b){return ['salesRunId','inventoryRunId','salesScopeHash','inventoryScopeHash','queryAsOf'].every(k=>a?.[k]===b?.[k]);}
export function fixtureDate(anchor,offset=0){
demand(/^\d{4}-\d{2}-\d{2}$/.test(anchor??''),'FIXTURE_ANCHOR_REQUIRED');
const date=new Date(anchor+'T00:00:00Z');date.setUTCDate(date.getUTCDate()+offset);return date.toISOString().slice(0,10);
}
export function validateSyntheticNotes(notes,options){
demand(notes?.synthetic===true&&notes.fixtureId==='SYNTHETIC-SELFOP-20261012'&&notes.sourceAccount==='lingxing-primary'&&
options.account===notes.sourceAccount&&options.warehouse==='SELFOP-TEST-A'&&options.otherWarehouse==='SELFOP-TEST-B'&&
notes.currentVersions?.A?.salesRunId&&notes.currentVersions?.B?.inventoryRunId&&notes.oldSalesRunId&&
Array.isArray(notes.assertions),'SYNTHETIC_SCOPE_MISMATCH');
}
// TEST uses Jackson NON_NULL; these nullable OpenAPI fields are optional. Missing is unknown, never zero.
const nullish=value=>value===null||value===undefined;
const quantity=(actual,expected)=>expected===null?nullish(actual):!nullish(actual)&&Number.isFinite(Number(actual))&&Number(actual)===Number(expected);
const fieldsMatch=(actual,expected)=>actual&&Object.entries(expected).every(([key,value])=>value===null?nullish(actual[key]):typeof value==='number'?quantity(actual[key],value):actual[key]===value);
/** Expectations come from the reviewed synthetic manifest; only GET requests are returned. */
export function syntheticCases(notes,context){
const d=context.anchorDate,from=fixtureDate(d,-2),until=fixtureDate(d,-1),window={rangePreset:'CUSTOM',startDate:from,endDate:until,bucket:'DAY'};
const shared='SYNTH-SHARED-SKU',owner='SYNTH-OWNER-A';
const assertion=(endpoint,predicate=()=>true)=>{
const rows=notes.assertions.filter(a=>a.endpoint===endpoint&&predicate(a));demand(rows.length===1,'FIXTURE_EXPECTATION_AMBIGUOUS');return rows[0];
};
const pair=(actual,version,hash)=>actual&&String(actual.salesRunId)===String(version.salesRunId)&&String(actual.inventoryRunId)===String(version.inventoryRunId)&&
actual.salesScopeHash===hash&&actual.inventoryScopeHash===hash;
const scopedRows=(data,warehouse)=>Array.isArray(data?.records)&&data.records.every(r=>r.warehouseCode===warehouse&&r.sourceAccount===notes.sourceAccount);
const cases=[];
const add=(id,route,params,verify,{warehouse='SELFOP-TEST-A',version=notes.currentVersions.A,pinned=true}={})=>cases.push({id:'synthetic.'+id,route,
params:{sourceAccount:notes.sourceAccount,warehouseCode:warehouse,current:1,size:100,...(pinned?pin(context):{}),...params},
verify:data=>['sync-overview'].includes(route)?verify(data):pair(data?.context,version,notes.hashes[warehouse==='SELFOP-TEST-A'?'A':'B'])&&
(!pinned||data.context.queryAsOf===context.queryAsOf)&&verify(data)});
const owners=assertion('owners').expected;
add('owners','owners',{},data=>quantity(data.total,owners.total)&&data.records.length===owners.total&&
JSON.stringify(data.records.map(r=>r.customerCode).sort())===JSON.stringify([...owners.customerCodes].sort())&&data.records.every(r=>nullish(r.customerName)));
const historical=assertion('sku-sales-summary',a=>a.expected?.records).expected;
add('date-range-and-owner-isolation','sku-sales-summary',{...window,sku:shared},data=>scopedRows(data,'SELFOP-TEST-A')&&quantity(data.total,historical.total)&&
data.records.length===historical.total&&Object.entries(historical.records).every(([code,expected])=>{
const rows=data.records.filter(r=>r.customerCode===code&&r.sku===shared);return rows.length===1&&fieldsMatch(rows[0],expected);
}));
const daily=assertion('sku-daily-sales').expected;
add('daily-exact-axis','sku-daily-sales',{...window,customerCode:owner,sku:shared},data=>data.points?.length===daily.points.length&&
data.points.every((p,i)=>p.businessDate===fixtureDate(d,Number(daily.points[i].businessDate.slice(1)))&&
quantity(p.outboundQuantity,daily.points[i].outboundQuantity)&&p.dataStatus===daily.points[i].dataStatus)&&
quantity(data.summary?.outboundQuantity,daily.summaryOutboundQuantity)&&data.summary.customerCode===owner&&data.summary.sku===shared);
const today=assertion('sku-sales-summary',a=>a.params?.rangePreset==='TODAY'&&typeof a.expected==='object').expected;
add('today-partial','sku-sales-summary',{rangePreset:'TODAY',sku:shared},data=>scopedRows(data,'SELFOP-TEST-A')&&data.records.length===2&&
Object.entries(today).every(([code,expected])=>fieldsMatch(data.records.find(r=>r.customerCode===code&&r.sku===shared),expected)));
const old=assertion('sku-sales-summary',a=>a.params?.salesRunId).expected;
add('old-version-fixed','sku-sales-summary',{...window,customerCode:owner,sku:shared,salesRunId:notes.oldSalesRunId},data=>
scopedRows(data,'SELFOP-TEST-A')&&data.records.length===1&&fieldsMatch(data.records[0],old),
{version:{...notes.currentVersions.A,salesRunId:notes.oldSalesRunId}});
const inventory=assertion('inventory-coverage').expected;
add('inventory-zero-unknown-and-age','inventory-coverage',{},data=>{
if(!scopedRows(data,'SELFOP-TEST-A')||!quantity(data.total,inventory.total)||data.records.length!==inventory.total)return false;
const row=sku=>data.records.find(r=>r.customerCode===owner&&r.sku===sku);
return Object.entries(inventory.sharedByOwner).every(([code,expected])=>fieldsMatch(data.records.find(r=>r.customerCode===code&&r.sku===shared),expected))&&
fieldsMatch(row(inventory.zero.sku),{physicalQuantity:0,availableQuantity:0})&&
fieldsMatch(row(inventory.unknown.sku),{physicalQuantity:null,availableQuantity:null,inventoryCoverageDays:null})&&
row(inventory.unknown.sku)?.quantityStatuses?.physicalQuantity==='UNKNOWN'&&row(inventory.unknown.sku)?.quantityStatuses?.availableQuantity==='UNKNOWN'&&
fieldsMatch(row(inventory.new.sku),{observationEligible:false,inventoryCoverageDays:null});
});
for(const route of ['hot-skus','slow-skus']){
const expected=assertion(route).expected;
add(route+'-eligibility-order',route,route==='hot-skus'?{windowDays:30}:{},data=>scopedRows(data,'SELFOP-TEST-A')&&quantity(data.total,expected.total)&&
data.records.length===expected.total&&data.records.every((r,i)=>r.customerCode===expected.order[i]&&r.sku===expected.sku&&quantity(r.outboundQuantity,expected.outboundQuantities[i])));
}
const other=assertion('sku-sales-summary',a=>a.params?.warehouseCode==='SELFOP-TEST-B').expected;
add('warehouse-b-positive','sku-sales-summary',{rangePreset:'CUSTOM',startDate:until,endDate:until,bucket:'DAY',sku:shared},data=>
scopedRows(data,'SELFOP-TEST-B')&&quantity(data.total,other.total)&&data.records.length===1&&fieldsMatch(data.records[0],{customerCode:other.customerCode,outboundQuantity:other.outboundQuantity}),
{warehouse:'SELFOP-TEST-B',version:notes.currentVersions.B,pinned:false});
add('zero-sales-versus-unknown','sku-daily-sales',{...window,customerCode:owner,sku:'SYNTH-ZERO'},data=>data.points?.length===2&&
data.points.every((p,i)=>p.businessDate===fixtureDate(d,i-2)&&p.dataStatus==='COMPLETE'&&quantity(p.outboundQuantity,0)&&quantity(p.observedQuantity,0)));
add('pre-existence-is-unknown','sku-daily-sales',{...window,customerCode:owner,sku:'SYNTH-NEW'},data=>data.points?.length===2&&
data.points[0].businessDate===from&&data.points[0].dataStatus==='UNKNOWN'&&nullish(data.points[0].outboundQuantity)&&nullish(data.points[0].observedQuantity)&&
data.points[1].businessDate===until&&data.points[1].dataStatus==='COMPLETE'&&quantity(data.points[1].outboundQuantity,0));
const overview=assertion('sync-overview').expected;
for(const [letter,warehouse] of [['A','SELFOP-TEST-A'],['B','SELFOP-TEST-B']]){
const expected=overview[letter];
const present=new Set((expected.sampleDates??[]).map(date=>fixtureDate(d,Number(date.slice(1)))));
const missing=Array.from({length:30},(_,i)=>fixtureDate(d,i-30)).filter(date=>!present.has(date));
add('daily-sample-gaps-'+letter,'sync-overview',{},data=>data.collectionMode==='OFF'&&data.warehouses?.some(w=>{
const samples=w.inventoryDailySamples;return w.warehouseCode===warehouse&&w.dataFamily==='INVENTORY'&&!w.executionEnabled&&!w.publicationEnabled&&samples&&
samples.windowFrom===fixtureDate(d,-30)&&samples.windowUntil===d&&samples.expectedDays===30&&quantity(samples.sampleCount,expected.sampleCount)&&
(expected.accumulationStartedOn===null?nullish(samples.accumulationStartedOn):samples.accumulationStartedOn===fixtureDate(d,Number(expected.accumulationStartedOn.slice(1))))&&
JSON.stringify(samples.missingDates)===JSON.stringify(missing);
}),{warehouse,pinned:false});
}
return cases;
}
export async function runAcceptance(options,{env=process.env,fetchImpl=fetch,save=()=>{},syntheticNotes}={}){
if(!options.execute)return plan();
demand(options.environment==='TEST','EXPLICIT_TEST_REQUIRED');
const base=validateEnvironment(env),sessions=[];
const notes=syntheticNotes??(options.syntheticFixture?JSON.parse(readFileSync(resolve(options.syntheticFixture),'utf8')):null);
if(notes)validateSyntheticNotes(notes,options);
const result={environment:'TEST',mode:'EXECUTE',evidenceType:notes?'SYNTHETIC_QUERY_FIXTURE_NOT_PROVIDER_EVIDENCE':'HTTP_ACCESS_AND_QUERY_CHECKS',
synthetic:!!notes,startedAt:new Date().toISOString(),cases:[],sampleRequirements:SAMPLE_REQUIREMENTS,
uncovered:['Actual context expiration after 30 elapsed minutes is deferred; not exercised by backdating queryAsOf.',
'Upstream Lingxing coverage, collection and publication transactions, real concurrency and performance are not verified.',
'Impersonated platform identity is not prepared or exercised.']};
const checkpoint=()=>save(result);
const record=(id,status,response,reason)=>{result.cases.push({id,status,...(response?evidence(response):{}),...(reason?{reason}: {})});checkpoint();};
const call=async(method,route,session,body)=>{
demand(method==='GET'||(method==='POST'&&['/auth/login','/auth/logout',ROOT+'/sync-tasks'].includes(route)),'UNAUTHORIZED_ROUTE');
let response;try{response=await fetchImpl(base+route,{method,redirect:'error',signal:AbortSignal.timeout(20000),
headers:{Accept:'application/json',...(session?{Authorization:`Bearer ${session.accessToken}`} :{}),
...(body===undefined?{}:{'Content-Type':'application/json'})},...(body===undefined?{}:{body:JSON.stringify(body)})});}
catch{throw new Error('HTTP_TRANSPORT_FAILED');}
let payload;try{payload=await response.json();}catch{throw new Error('HTTP_JSON_REQUIRED');}
return {http:response.status,body:payload,requestId:response.headers?.get('x-request-id'),traceId:response.headers?.get('x-trace-id')};
};
const login=async(label,prefix,check)=>{
if(!env[prefix+'_USERNAME']||!env[prefix+'_PASSWORD']){record(label+'.login','BLOCKED',null,'CREDENTIALS_MISSING');return null;}
const r=await call('POST','/auth/login',null,{loginId:env[prefix+'_USERNAME'],password:env[prefix+'_PASSWORD']});
if(!success(r)||!r.body.data?.accessToken){record(label+'.login','FAIL',r,'LOGIN_FAILED');return null;}
const session=r.body.data;sessions.push(session);
if(!check(new Set(session.authorities??[]))){record(label+'.login','BLOCKED',r,'PERMISSION_FIXTURE_MISMATCH');return null;}
record(label+'.login','PASS',r);return session;
};
const scoped={sourceAccount:options.account,warehouseCode:options.warehouse,current:1,size:20};
const get=(route,session,params=scoped)=>call('GET',ROOT+'/'+route+'?'+query(params),session);
const check=async(id,route,session,params,predicate)=>{
const r=await get(route,session,params);const passed=predicate(r);record(id,passed?'PASS':'FAIL',r);return passed?r.body.data:null;
};
try{
for(const route of [...READ_ROUTES,'sync-tasks/SELFOP-NOT-EXIST'])
await check('anonymous.'+route,route,null,scoped,denied);
const admin=await login('platform','CHONGCHONG_ADMIN',a=>a.has(READ)&&a.has(SYNC));
const readonly=await login('readonly','SELFOP_READONLY',a=>a.has(READ)&&!a.has(SYNC));
const forbidden=await login('denied','SELFOP_DENIED',a=>!a.has(READ)&&!a.has(SYNC));
const merchant=await login('merchant','CHONGCHONG_MERCHANT',()=>true);
for(const [label,session] of [['denied',forbidden],['merchant',merchant]])if(session){
for(const route of [...READ_ROUTES,'sync-tasks/SELFOP-NOT-EXIST'])await check(label+'.'+route,route,session,scoped,denied);
}
if(!admin){record('business.reads','BLOCKED',null,'AUTHORIZED_PLATFORM_REQUIRED');return result;}
const overview=await check('off.overview','sync-overview',admin,scoped,r=>success(r)&&r.body.data?.collectionMode==='OFF'&&
r.body.data.warehouses?.length>0&&r.body.data.warehouses.every(w=>w.warehouseCode===options.warehouse&&!w.executionEnabled&&!w.publicationEnabled));
if(!overview){record('off.commands','BLOCKED',null,'OFF_PREFLIGHT_REQUIRED');return result;}
if(readonly)await check('readonly.query','sync-overview',readonly,scoped,r=>success(r)&&r.body.data.collectionMode==='OFF');
const sales=await check('query.sales','sku-sales-summary',admin,{...scoped,rangePreset:'LAST_3_DAYS',bucket:'DAY'},r=>
success(r)&&Array.isArray(r.body.data?.records)&&r.body.data.records.every(row=>row.sourceAccount===options.account&&row.warehouseCode===options.warehouse));
const context=sales?pin(sales.context):null;
const fixed=context?{...scoped,...context}:scoped;
for(const route of ['hot-skus','slow-skus','inventory-coverage','owners'])await check('query.'+route,route,admin,fixed,r=>
success(r)&&Array.isArray(r.body.data?.records)&&(!context||sameContext(sales.context,r.body.data.context))&&
r.body.data.records.every(row=>row.warehouseCode===undefined||row.warehouseCode===options.warehouse));
for(const route of ['sync-tasks','sync-runs']){
const data=await check('query.'+route,route,admin,scoped,r=>success(r)&&Array.isArray(r.body.data?.records)&&
(route!=='sync-tasks'||r.body.data.records.every(t=>t.warehouseCode===options.warehouse&&t.taskRole==='WAREHOUSE_SYNC')));
if(route==='sync-runs'){
if(data?.records.length){
for(const taskNo of new Set(data.records.map(r=>r.taskNo))){
if(!taskNo){record('scope.run-warehouse','FAIL',null,'ATTEMPT_TASK_NUMBER_REQUIRED');continue;}
await check('scope.run-warehouse','sync-tasks/'+encodeURIComponent(taskNo),admin,scoped,r=>success(r)&&
r.body.data.task?.taskNo===taskNo&&r.body.data.task.warehouseCode===options.warehouse);
}
}else record('query.attempt-sample','BLOCKED',null,'EXISTING_WAREHOUSE_ATTEMPT_REQUIRED');
}
}
await check('scope.foreign-account','sync-overview',admin,{...scoped,sourceAccount:'SELFOP-UNCONFIGURED-'+randomUUID()},businessRejected);
await check('scope.foreign-warehouse','sku-sales-summary',admin,{...scoped,warehouseCode:'SELFOP-UNCONFIGURED-'+randomUUID(),rangePreset:'TODAY'},businessRejected);
if(context){
await check('context.second-page','sku-sales-summary',admin,{...fixed,current:2,rangePreset:'LAST_3_DAYS',bucket:'DAY'},r=>success(r)&&sameContext(sales.context,r.body.data.context));
await check('context.future-clock','sku-sales-summary',admin,{...fixed,queryAsOf:new Date(Date.now()+86400000).toISOString(),rangePreset:'TODAY'},businessRejected);
if(options.otherWarehouse&&(Number(context.salesRunId)>0||Number(context.inventoryRunId)>0)){
const configured=await check('scope.second-warehouse-configured','sync-overview',admin,{...scoped,warehouseCode:options.otherWarehouse},r=>success(r)&&
r.body.data.warehouses?.some(w=>w.warehouseCode===options.otherWarehouse));
if(configured)await check('context.cross-warehouse','sku-sales-summary',admin,{...fixed,warehouseCode:options.otherWarehouse,rangePreset:'TODAY'},businessRejected);
}
else record('context.cross-warehouse','BLOCKED',null,'SECOND_CONFIGURED_WAREHOUSE_REQUIRED');
}
const sample=sales?.records?.[0];
if(sample){
await check('scope.exact-owner-sku','sku-sales-summary',admin,{...fixed,customerCode:sample.customerCode,sku:sample.sku,rangePreset:'LAST_3_DAYS'},r=>success(r)&&
r.body.data.records.length>0&&r.body.data.records.every(row=>row.customerCode===sample.customerCode&&row.sku===sample.sku));
await check('query.daily','sku-daily-sales',admin,{...fixed,customerCode:sample.customerCode,sku:sample.sku,rangePreset:'LAST_3_DAYS',bucket:'DAY'},r=>success(r)&&
r.body.data.summary?.customerCode===sample.customerCode&&r.body.data.summary?.sku===sample.sku&&r.body.data.points?.length===3&&
r.body.data.points.every((p,i,arr)=>['COMPLETE','PARTIAL','UNKNOWN'].includes(p.dataStatus)&&(!i||p.businessDate>arr[i-1].businessDate)&&
(p.dataStatus!=='UNKNOWN'||nullish(p.outboundQuantity)&&nullish(p.observedQuantity))));
const {current:unusedCurrent,size:unusedSize,...overviewScope}=fixed;
await check('query.owner-overview','owner-overview',admin,{...overviewScope,customerCode:sample.customerCode},r=>success(r)&&r.body.data.customerCode===sample.customerCode);
const peer=sales.records.find(row=>row.sku===sample.sku&&row.customerCode!==sample.customerCode);
if(peer)await check('scope.same-sku-other-owner','sku-sales-summary',admin,{...fixed,customerCode:peer.customerCode,sku:peer.sku,rangePreset:'LAST_3_DAYS'},r=>success(r)&&
r.body.data.records.length===1&&r.body.data.records[0].customerCode===peer.customerCode&&r.body.data.records[0].sku===peer.sku&&
r.body.data.records[0].observedQuantity===peer.observedQuantity);
else record('scope.same-sku-other-owner','BLOCKED',null,'SECOND_OWNER_SAME_SKU_REQUIRED');
}else record('query.sample-dependent','BLOCKED',null,'PUBLISHED_SALES_SAMPLE_REQUIRED');
if(notes&&sales){
demand(sales.context.anchorDate,'FIXTURE_ANCHOR_REQUIRED');
for(const scenario of syntheticCases(notes,sales.context))await check(scenario.id,scenario.route,admin,scenario.params,r=>success(r)&&scenario.verify(r.body.data));
}else if(notes)record('synthetic.business','BLOCKED',null,'FIRST_SCREEN_CONTEXT_REQUIRED');
else result.uncovered.push('Reviewed synthetic fixture numerical, old-version and daily representative assertions were not requested.');
const taskPage=await get('sync-tasks',admin,scoped);const task=success(taskPage)?taskPage.body.data.records?.[0]:null;
if(task?.taskNo){await check('query.task-detail','sync-tasks/'+encodeURIComponent(task.taskNo),admin,scoped,r=>success(r)&&r.body.data.task?.taskNo===task.taskNo);
if(options.otherWarehouse)await check('scope.cross-warehouse-task','sync-tasks/'+encodeURIComponent(task.taskNo),admin,{...scoped,warehouseCode:options.otherWarehouse},businessRejected);
}else record('query.task-detail','BLOCKED',null,'EXISTING_WAREHOUSE_TASK_REQUIRED');
// Probe is opt-in and OFF is checked again, immediately before any command request.
if(options.probeOff){
const before=await get('sync-overview',admin,scoped);
demand(success(before)&&before.body.data?.collectionMode==='OFF','OFF_CHANGED_STOPPED');
const command={requestKey:'off-acceptance-'+randomUUID(),warehouseCode:options.warehouse,action:'INVENTORY'};
for(const [label,session] of [['anonymous',null],['readonly',readonly],['denied',forbidden],['merchant',merchant]]){
if(label!=='anonymous'&&!session)continue;
const r=await call('POST',ROOT+'/sync-tasks',session,command);record('sync.permission.'+label,denied(r)?'PASS':'FAIL',r);
demand(!success(r),'UNEXPECTED_ACCEPTANCE_STOPPED');
}
const immediatelyBefore=await get('sync-overview',admin,scoped);
demand(success(immediatelyBefore)&&immediatelyBefore.body.data.collectionMode==='OFF','OFF_CHANGED_STOPPED');
const tasksBefore=await get('sync-tasks',admin,scoped);demand(success(tasksBefore),'TASK_BASELINE_REQUIRED');
const r=await call('POST',ROOT+'/sync-tasks',admin,command);
record('sync.off-controlled-rejection',offRejected(r)?'PASS':'FAIL',r);
demand(!success(r),'UNEXPECTED_ACCEPTANCE_STOPPED');
const tasksAfter=await get('sync-tasks',admin,scoped);
record('sync.off-no-task-created',success(tasksAfter)&&tasksBefore.body.data.total===tasksAfter.body.data.total?'PASS':'FAIL',tasksAfter);
}else record('sync.off-controlled-rejection','BLOCKED',null,'OPT_IN_PROBE_REQUIRED');
}catch(error){record('run.blocker','FAIL',null,safeCode(error.message)??'UNEXPECTED_ERROR');}
finally{
for(const session of sessions)if(session.refreshToken){try{const r=await call('POST','/auth/logout',session,{refreshToken:session.refreshToken});record('session.logout',success(r)?'PASS':'FAIL',r);}catch{record('session.logout','FAIL',null,'LOGOUT_FAILED');}}
result.finishedAt=new Date().toISOString();result.outcome=result.cases.some(c=>c.status==='FAIL')?'FAIL':result.cases.some(c=>c.status==='BLOCKED')?'BLOCKED':'PASS';checkpoint();
}
return result;
}
if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){
try{
const options=parseArguments(process.argv.slice(2));
const save=result=>{if(options.report){mkdirSync(dirname(resolve(options.report)),{recursive:true});writeFileSync(resolve(options.report),JSON.stringify(result,null,2));}};
const report=await runAcceptance(options,{save});
if(!options.execute)console.log(JSON.stringify(report,null,2));
else {console.log(JSON.stringify({outcome:report.outcome,cases:report.cases.length}));process.exitCode=report.outcome==='PASS'?0:report.outcome==='BLOCKED'?2:1;}
}catch{console.error('SELFOP_ACCEPTANCE_CONFIGURATION_FAILED');process.exitCode=1;}
}
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseArguments,validateEnvironment,runAcceptance,businessRejected,offRejected,evidence,pin,fixtureDate,validateSyntheticNotes,syntheticCases} from './selfoperated-test-env-acceptance.mjs';
const env={CHONGCHONG_ENV:'TEST',CHONGCHONG_ADMIN_API_BASE_URL:'https://test.example.invalid/api',
CHONGCHONG_ADMIN_USERNAME:'admin-secret',CHONGCHONG_ADMIN_PASSWORD:'password-secret',
CHONGCHONG_MERCHANT_USERNAME:'merchant-secret',CHONGCHONG_MERCHANT_PASSWORD:'merchant-password-secret',
SELFOP_READONLY_USERNAME:'readonly-secret',SELFOP_READONLY_PASSWORD:'readonly-password-secret',
SELFOP_DENIED_USERNAME:'denied-secret',SELFOP_DENIED_PASSWORD:'denied-password-secret'};
const options={execute:true,environment:'TEST',probeOff:true,account:'fixture-account',warehouse:'W1',otherWarehouse:'W2',report:'unused.json'};
const READ='platform:self-operated-analytics:list',SYNC='platform:self-operated-analytics:sync';
function fixture({mode='OFF',offCode='B0001',offMessage='采集功能未启用,当前环境不允许执行采集或重试',acceptCommand=false,changeMode=false}={}){
const requests=[];
const context={salesRunId:'11',inventoryRunId:'22',salesScopeHash:'sales-hash',inventoryScopeHash:'stock-hash',queryAsOf:new Date().toISOString()};
const sales=[{sourceAccount:options.account,warehouseCode:'W1',customerCode:'owner-secret-A',sku:'sku-secret',observedQuantity:4},
{sourceAccount:options.account,warehouseCode:'W1',customerCode:'owner-secret-B',sku:'sku-secret',observedQuantity:8}];
const response=(code='200',data=null,http=200,message='do-not-report-sensitive-message')=>({status:http,headers:new Headers({'x-request-id':'request-1','x-trace-id':'trace-1'}),
json:async()=>({code,data,requestId:'request-1',traceId:'trace-1',message})});
const fetchImpl=async(url,init)=>{
const u=new URL(url),path=u.pathname.substring(4),params=u.searchParams,body=init.body?JSON.parse(init.body):null;
requests.push({url,method:init.method,body,headers:init.headers});
if(path==='/auth/login'){
const label=body.loginId.split('-')[0];const authorities=label==='admin'?[READ,SYNC]:label==='readonly'?[READ]:[];
return response('200',{accessToken:'token-secret-'+label,refreshToken:'refresh-secret-'+label,authorities});
}
if(path==='/auth/logout')return response();
const bearer=init.headers.Authorization??'';
if(!bearer)return response('A0401',null,401);
if(bearer.endsWith('denied')||bearer.endsWith('merchant'))return response('A0403');
if(init.method==='POST'){
if(bearer.endsWith('readonly'))return response('A0403');
return response(acceptCommand?'200':offCode,acceptCommand?{taskNo:'unexpected'}:null,200,offMessage);
}
const route=path.split('/').at(-1),warehouse=params.get('warehouseCode');
if(params.get('sourceAccount')?.startsWith('SELFOP-UNCONFIGURED')||warehouse?.startsWith('SELFOP-UNCONFIGURED'))return response('B0001');
if(route==='sync-overview'){
const count=requests.filter(r=>new URL(r.url).pathname.endsWith('/sync-overview')).length;
return response('200',{collectionMode:changeMode&&count>6?'BUSINESS':mode,warehouses:[{warehouseCode:warehouse,executionEnabled:false,publicationEnabled:false}]});
}
if(params.has('queryAsOf')&&(params.get('queryAsOf')!==context.queryAsOf||warehouse==='W2'))return response('B0001');
if(route==='sku-sales-summary')return response('200',{context,records:params.get('current')==='2'?[]:sales.filter(r=>
!params.has('customerCode')||r.customerCode===params.get('customerCode')),total:2});
if(route==='sku-daily-sales')return response('200',{context,summary:{customerCode:params.get('customerCode'),sku:params.get('sku')},points:[
{businessDate:'2026-10-10',dataStatus:'COMPLETE',outboundQuantity:4,observedQuantity:4},
{businessDate:'2026-10-11',dataStatus:'UNKNOWN',outboundQuantity:null,observedQuantity:null},
{businessDate:'2026-10-12',dataStatus:'PARTIAL',outboundQuantity:null,observedQuantity:1}]});
if(route==='owner-overview')return params.has('current')||params.has('size')?response('B0001'):response('200',{context,customerCode:params.get('customerCode')});
if(path.includes('/sync-tasks/'))return warehouse==='W1'?response('200',{task:{taskNo:'TASK-FIXTURE',warehouseCode:'W1'}}):response('B0001');
if(route==='sync-tasks')return response('200',{records:[{taskNo:'TASK-FIXTURE',warehouseCode:warehouse,taskRole:'WAREHOUSE_SYNC'}],total:1});
if(route==='sync-runs')return response('200',{records:[{taskNo:'TASK-FIXTURE'}],total:1});
return response('200',{context,records:[],total:0});
};
return {requests,fetchImpl};
}
test('explicit TEST execution and independent warehouse are required',()=>{
assert.throws(()=>parseArguments(['--environment','prod']),/TEST/);
assert.throws(()=>parseArguments(['--environment','TEST','--execute']),/SCOPE/);
assert.throws(()=>parseArguments(['--environment','TEST','--warehouse','W1','--other-warehouse','W1']),/DIFFER/);
assert.throws(()=>parseArguments(['--environment','TEST','--execute','--execute']),/DUPLICATE/);
assert.equal(parseArguments(['--environment','TEST']).execute,false);
assert.throws(()=>validateEnvironment({...env,CHONGCHONG_ENV:'DEV'}),/TEST/);
assert.throws(()=>validateEnvironment({...env,CHONGCHONG_ADMIN_API_BASE_URL:'https://user:secret@test.example.invalid/api'}),/GATEWAY/);
assert.throws(()=>validateEnvironment({...env,CHONGCHONG_ADMIN_API_BASE_URL:'https://test.example.invalid/api?token=secret'}),/GATEWAY/);
});
test('plan makes no network requests and never reads env files',async()=>{
const p=await runAcceptance({execute:false},{env:{},fetchImpl:()=>{throw Error('network');}});
assert.equal(p.mode,'PLAN');assert.equal(p.fixtureWrites,false);assert.equal(p.thirdPartyCalls,false);
});
test('real protocol coverage passes without printing or saving sensitive payloads',async()=>{
const f=fixture(),snapshots=[];const result=await runAcceptance(options,{env,fetchImpl:f.fetchImpl,save:r=>snapshots.push(JSON.stringify(r))});
assert.equal(result.outcome,'PASS',JSON.stringify(result));
for(const id of ['scope.same-sku-other-owner','context.cross-warehouse','sync.off-controlled-rejection','sync.off-no-task-created'])
assert.equal(result.cases.find(c=>c.id===id)?.status,'PASS',id);
const serialized=snapshots.join('');
for(const forbidden of ['password-secret','token-secret','refresh-secret','owner-secret','sku-secret','test.example.invalid','sensitive-message'])assert.ok(!serialized.includes(forbidden),forbidden);
const commands=f.requests.filter(r=>r.method==='POST'&&!new URL(r.url).pathname.startsWith('/api/auth/'));
assert.equal(commands.length,5);assert.ok(commands.every(r=>new URL(r.url).pathname.endsWith('/sync-tasks')&&r.body.action==='INVENTORY'));
assert.equal(f.requests.filter(r=>new URL(r.url).pathname.endsWith('/auth/logout')).length,4);
});
test('OFF preflight blocks every command when mode is not OFF',async()=>{
const f=fixture({mode:'VALIDATION'});const result=await runAcceptance(options,{env,fetchImpl:f.fetchImpl});
assert.equal(result.outcome,'FAIL');assert.ok(!f.requests.some(r=>r.method==='POST'&&new URL(r.url).pathname.endsWith('/sync-tasks')));
});
test('without opt-in no sync POST occurs; missing permission identity stays blocked',async()=>{
const f=fixture();const result=await runAcceptance({...options,probeOff:false},{env:{...env,SELFOP_READONLY_PASSWORD:''},fetchImpl:f.fetchImpl});
assert.equal(result.outcome,'BLOCKED');assert.ok(!f.requests.some(r=>r.method==='POST'&&new URL(r.url).pathname.endsWith('/sync-tasks')));
});
test('a changed OFF preflight stops before all sync POST requests',async()=>{
const f=fixture({changeMode:true});const result=await runAcceptance(options,{env,fetchImpl:f.fetchImpl});
assert.equal(result.outcome,'FAIL');assert.ok(!f.requests.some(r=>r.method==='POST'&&new URL(r.url).pathname.endsWith('/sync-tasks')));
assert.equal(result.cases.find(c=>c.id==='run.blocker').reason,'OFF_CHANGED_STOPPED');
});
test('internal errors never pass as controlled OFF rejection; unexpected acceptance aborts',async()=>{
assert.equal(businessRejected({http:200,body:{code:'S0500'}}),false);
assert.equal(businessRejected({http:503,body:{code:'B0001'}}),false);
assert.equal(offRejected({http:200,body:{code:'B0001',message:'SELFOP_WAREHOUSE_UNCONFIGURED'}}),false);
for(const config of [{offCode:'S0500'},{offMessage:'SELFOP_WAREHOUSE_UNCONFIGURED'},{acceptCommand:true}]){
const f=fixture(config);const result=await runAcceptance(options,{env,fetchImpl:f.fetchImpl});
assert.equal(result.outcome,'FAIL');assert.equal(result.cases.find(c=>c.id==='sync.off-controlled-rejection').status,'FAIL');
}
});
test('evidence whitelist retains diagnosis and drops arbitrary body content',()=>{
assert.deepEqual(evidence({http:200,body:{code:'B0001',requestId:'request-2',traceId:'trace-2',password:'secret',data:{accessToken:'secret'}}}),
{http:200,code:'B0001',requestId:'request-2',traceId:'trace-2'});
assert.equal(evidence({http:500,body:{code:'secret with spaces'}}).code,null);
assert.throws(()=>pin({salesRunId:0,inventoryRunId:0}),/CONTEXT/);
assert.equal(pin({salesRunId:0,inventoryRunId:0,queryAsOf:'2026-10-12T00:00:00Z'}).salesRunId,0);
});
function syntheticFixture(){
const notes={synthetic:true,fixtureId:'SYNTHETIC-SELFOP-20261012',sourceAccount:'lingxing-primary',hashes:{A:'hash-A',B:'hash-B'},
currentVersions:{A:{salesRunId:'202',inventoryRunId:'203'},B:{salesRunId:'206',inventoryRunId:'207'}},oldSalesRunId:'201',assertions:[
{endpoint:'owners',expected:{total:2,customerCodes:['SYNTH-OWNER-A','SYNTH-OWNER-B']}},
{endpoint:'sku-sales-summary',expected:{total:2,records:{'SYNTH-OWNER-A':{outboundQuantity:30,observedQuantity:30,dataStatus:'COMPLETE',completeDayCount:2,partialDayCount:0,unknownDayCount:0},
'SYNTH-OWNER-B':{outboundQuantity:10,observedQuantity:10,dataStatus:'COMPLETE'}}}},
{endpoint:'sku-daily-sales',expected:{points:[{businessDate:'D-2',outboundQuantity:10,dataStatus:'COMPLETE'},{businessDate:'D-1',outboundQuantity:20,dataStatus:'COMPLETE'}],summaryOutboundQuantity:30}},
{endpoint:'sku-sales-summary',params:{rangePreset:'TODAY'},expected:{'SYNTH-OWNER-A':{outboundQuantity:null,observedQuantity:5,dataStatus:'PARTIAL'},'SYNTH-OWNER-B':{outboundQuantity:null,observedQuantity:2,dataStatus:'PARTIAL'}}},
{endpoint:'sku-sales-summary',params:{salesRunId:'201'},expected:{outboundQuantity:3,observedQuantity:3,dataStatus:'COMPLETE'}},
{endpoint:'inventory-coverage',expected:{total:5,sharedByOwner:{'SYNTH-OWNER-A':{physicalQuantity:60,availableQuantity:60,outboundQuantity30d:30,inventoryCoverageDays:60},
'SYNTH-OWNER-B':{physicalQuantity:20,availableQuantity:20,outboundQuantity30d:10,inventoryCoverageDays:60}},zero:{sku:'SYNTH-ZERO'},unknown:{sku:'SYNTH-UNKNOWN'},new:{sku:'SYNTH-NEW'}}},
{endpoint:'hot-skus',expected:{total:2,order:['SYNTH-OWNER-A','SYNTH-OWNER-B'],sku:'SYNTH-SHARED-SKU',outboundQuantities:[30,10]}},
{endpoint:'slow-skus',expected:{total:2,order:['SYNTH-OWNER-B','SYNTH-OWNER-A'],sku:'SYNTH-SHARED-SKU',outboundQuantities:[10,30]}},
{endpoint:'sku-sales-summary',params:{warehouseCode:'SELFOP-TEST-B'},expected:{total:1,customerCode:'SYNTH-OWNER-A',outboundQuantity:999}},
{endpoint:'sync-overview',expected:{A:{sampleCount:2,sampleDates:['D-3','D-1'],accumulationStartedOn:'D-3'},B:{sampleCount:0,accumulationStartedOn:null}}},
]};
const context={...notes.currentVersions.A,salesScopeHash:'hash-A',inventoryScopeHash:'hash-A',queryAsOf:'2026-10-12T04:00:00Z',anchorDate:'2026-10-12'};
const cases=syntheticCases(notes,context),payloads=new Map();
const row=(customerCode,sku,fields={})=>({sourceAccount:notes.sourceAccount,warehouseCode:'SELFOP-TEST-A',customerCode,sku,...fields});
for(const scenario of cases){
const id=scenario.id.substring(10),data={context:{...context},records:[],total:0};
if(id==='owners'){data.records=['SYNTH-OWNER-A','SYNTH-OWNER-B'].map(customerCode=>({customerCode,customerName:null}));data.total=2;}
if(id==='date-range-and-owner-isolation'){data.records=Object.entries(notes.assertions[1].expected.records).map(([code,fields])=>row(code,'SYNTH-SHARED-SKU',fields));data.total=2;}
if(id==='daily-exact-axis'){data.summary={customerCode:'SYNTH-OWNER-A',sku:'SYNTH-SHARED-SKU',outboundQuantity:30};data.points=notes.assertions[2].expected.points.map((p,i)=>({...p,businessDate:fixtureDate(context.anchorDate,i-2)}));}
if(id==='today-partial'){data.records=Object.entries(notes.assertions[3].expected).map(([code,fields])=>row(code,'SYNTH-SHARED-SKU',fields));data.total=2;}
if(id==='old-version-fixed'){data.context.salesRunId='201';data.records=[row('SYNTH-OWNER-A','SYNTH-SHARED-SKU',notes.assertions[4].expected)];data.total=1;}
if(id==='inventory-zero-unknown-and-age'){
data.records=Object.entries(notes.assertions[5].expected.sharedByOwner).map(([code,fields])=>row(code,'SYNTH-SHARED-SKU',fields));
data.records.push(row('SYNTH-OWNER-A','SYNTH-ZERO',{physicalQuantity:0,availableQuantity:0}),
row('SYNTH-OWNER-A','SYNTH-UNKNOWN',{physicalQuantity:null,availableQuantity:null,inventoryCoverageDays:null,quantityStatuses:{physicalQuantity:'UNKNOWN',availableQuantity:'UNKNOWN'}}),
row('SYNTH-OWNER-A','SYNTH-NEW',{observationEligible:false,inventoryCoverageDays:null}));data.total=5;
}
if(id==='hot-skus-eligibility-order'||id==='slow-skus-eligibility-order'){
const expected=notes.assertions[id.startsWith('hot')?6:7].expected;
data.records=expected.order.map((code,i)=>row(code,expected.sku,{outboundQuantity:expected.outboundQuantities[i]}));data.total=2;
}
if(id==='warehouse-b-positive'){
data.context={...context,...notes.currentVersions.B,salesScopeHash:'hash-B',inventoryScopeHash:'hash-B'};
data.records=[{...row('SYNTH-OWNER-A','SYNTH-SHARED-SKU',{outboundQuantity:999}),warehouseCode:'SELFOP-TEST-B'}];data.total=1;
}
if(id==='zero-sales-versus-unknown')data.points=[-2,-1].map(offset=>({businessDate:fixtureDate(context.anchorDate,offset),dataStatus:'COMPLETE',outboundQuantity:'0.000000',observedQuantity:0}));
if(id==='pre-existence-is-unknown')data.points=[{businessDate:'2026-10-10',dataStatus:'UNKNOWN',outboundQuantity:null,observedQuantity:null},
{businessDate:'2026-10-11',dataStatus:'COMPLETE',outboundQuantity:0}];
if(id.startsWith('daily-sample-gaps-')){
const letter=id.at(-1),expected=notes.assertions[9].expected[letter],present=new Set((expected.sampleDates??[]).map(p=>fixtureDate(context.anchorDate,Number(p.slice(1)))));
data.collectionMode='OFF';data.warehouses=[{warehouseCode:'SELFOP-TEST-'+letter,dataFamily:'INVENTORY',executionEnabled:false,publicationEnabled:false,
inventoryDailySamples:{windowFrom:'2026-09-12',windowUntil:'2026-10-12',expectedDays:30,sampleCount:expected.sampleCount,
accumulationStartedOn:expected.accumulationStartedOn===null?null:'2026-10-09',missingDates:Array.from({length:30},(_,i)=>fixtureDate(context.anchorDate,i-30)).filter(p=>!present.has(p))}}];
}
payloads.set(scenario.id,data);
}
return {notes,context,cases,payloads};
}
test('reviewed synthetic fixture covers all thirteen business checks with bound old/current version pairs',()=>{
const f=syntheticFixture();assert.equal(f.cases.length,13);
validateSyntheticNotes(f.notes,{account:'lingxing-primary',warehouse:'SELFOP-TEST-A',otherWarehouse:'SELFOP-TEST-B'});
assert.throws(()=>validateSyntheticNotes({...f.notes,synthetic:false},options),/SYNTHETIC/);
for(const c of f.cases)assert.equal(c.verify(f.payloads.get(c.id)),true,c.id);
const old=f.cases.find(c=>c.id==='synthetic.old-version-fixed');
assert.equal(old.params.salesRunId,'201');assert.equal(old.params.inventoryRunId,'203');assert.equal(old.params.queryAsOf,f.context.queryAsOf);
assert.ok(f.cases.every(c=>c.route!=='sync-tasks'&&!c.route.includes('publish')));
assert.equal(fixtureDate('2026-03-01',-1),'2026-02-28');
});
test('synthetic checks reject mixed owners/warehouses, silent current-version fallback, zero-filling and ineligible Top rows',()=>{
const f=syntheticFixture();
const reject=(id,mutate)=>{const c=f.cases.find(c=>c.id==='synthetic.'+id),data=structuredClone(f.payloads.get(c.id));mutate(data);assert.equal(c.verify(data),false,id);};
reject('date-range-and-owner-isolation',d=>{d.records[0].observedQuantity+=999;});
reject('date-range-and-owner-isolation',d=>{d.records[1].customerCode='SYNTH-OWNER-A';});
reject('warehouse-b-positive',d=>{d.records[0].warehouseCode='SELFOP-TEST-A';});
reject('old-version-fixed',d=>{d.context.salesRunId='202';});
reject('inventory-zero-unknown-and-age',d=>{d.records[3].availableQuantity=0;});
reject('pre-existence-is-unknown',d=>{d.points[0].observedQuantity=0;});
reject('daily-exact-axis',d=>{d.points[1].businessDate='2026-10-12';});
reject('hot-skus-eligibility-order',d=>{d.records[0].sku='SYNTH-NEW';});
reject('slow-skus-eligibility-order',d=>{d.records.reverse();});
reject('daily-sample-gaps-A',d=>{d.warehouses[0].inventoryDailySamples.missingDates.push('2026-10-11');});
reject('daily-sample-gaps-B',d=>{d.warehouses[0].inventoryDailySamples.sampleCount=1;});
});
test('synthetic report explicitly marks provider evidence and elapsed expiration as unverified without adding a wait blocker',async()=>{
const f=syntheticFixture();const result=await runAcceptance({...options,account:'lingxing-primary',warehouse:'SELFOP-TEST-A',otherWarehouse:'SELFOP-TEST-B'},
{env:{CHONGCHONG_ENV:'TEST',CHONGCHONG_ADMIN_API_BASE_URL:env.CHONGCHONG_ADMIN_API_BASE_URL},syntheticNotes:f.notes,
fetchImpl:async()=>({status:401,headers:new Headers(),json:async()=>({code:'A0401'})})});
assert.equal(result.synthetic,true);assert.equal(result.evidenceType,'SYNTHETIC_QUERY_FIXTURE_NOT_PROVIDER_EVIDENCE');
assert.ok(result.uncovered.some(item=>item.includes('30 elapsed minutes')));
assert.ok(!result.cases.some(c=>c.id==='context.expired'));
assert.equal(parseArguments(['--environment','TEST','--synthetic-fixture','notes.json']).syntheticFixture,'notes.json');
});
test('optional nullable fields survive NON_NULL wire serialization while zero-filling stays rejected',()=>{
const f=syntheticFixture();
const omitNulls=value=>Array.isArray(value)?value.map(omitNulls):value&&typeof value==='object'?
Object.fromEntries(Object.entries(value).filter(([,v])=>v!==null).map(([k,v])=>[k,omitNulls(v)])):value;
for(const c of f.cases)assert.equal(c.verify(omitNulls(f.payloads.get(c.id))),true,c.id);
for(const id of ['synthetic.today-partial','synthetic.pre-existence-is-unknown','synthetic.inventory-zero-unknown-and-age']){
const c=f.cases.find(c=>c.id===id),data=omitNulls(f.payloads.get(id));
if(id.endsWith('today-partial'))data.records[0].outboundQuantity=0;
else if(id.endsWith('pre-existence-is-unknown'))data.points[0].observedQuantity=0;
else data.records[3].availableQuantity=0;
assert.equal(c.verify(data),false,id);
}
});
@@ -1,42 +1,6 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Objects;
/** Called by actual incoming materialization, never as a migration or historical data repair. */
@Service
public class AppointmentSourceLinker {
private final LastlegOrderMapper orders;
private final InboundFbaAppointmentItemMapper items;
private final AppointmentExternalSourceGateway bookings;
public AppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;}
@Transactional(propagation=Propagation.MANDATORY)
public void materialized(String customerId,String bookingNo,Long orderId) {
var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo);
bookings.lockForAppointment(List.of(ref));
var matched=bookings.resolve(List.of(ref));
if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对");
var order=orderId==null?null:orders.selectById(orderId);
if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物");
var rows=items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo)
.eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE"));
for(var row:rows) {
if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑");
if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue;
try {items.update(null,new LambdaUpdateWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true)
.set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));}
catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");}
}
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
/** Associates a newly materialized source with its receiving appointment in the caller's transaction. */
public interface AppointmentSourceLinker {
void materialized(String customerId, String bookingNo, Long orderId);
}
@@ -9,6 +9,7 @@ import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.http.clients")
public class LastlegRestClientProperties {
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts cargoDiscardPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
@@ -18,6 +19,8 @@ public class LastlegRestClientProperties {
public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
public ClientTimeouts getCargoDiscardPhotoFile() { return cargoDiscardPhotoFile; }
public void setCargoDiscardPhotoFile(ClientTimeouts value) { cargoDiscardPhotoFile = required(value, "cargo-discard-photo-file"); }
public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; }
public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; }
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.delivery.fleet;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.baiyue.lastleg.fleet.business.FleetPoolDeliveryEvidence;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashSet;
/** Same-transaction delivery proof; no persistence model escapes into fleet. */
@Component
public class FleetPoolDeliveryAdapter implements FleetPoolDeliveryEvidence {
private final DeliverySourceEvidenceMapper evidence;
public FleetPoolDeliveryAdapter(DeliverySourceEvidenceMapper evidence){this.evidence=evidence;}
@Override public boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages) {
var dispatchIds=evidence.dispatchIds(customerId,orderId);
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
for(Long dispatchId:dispatchIds){
if(dispatchId==null||dispatchId<=0)return false;
var d=evidence.deliveries(customerId,dispatchId);var p=evidence.picks(customerId,dispatchId);
var r=evidence.returns(customerId,dispatchId);
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
}
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(incomingNo,orderId,expectedPackages),
deliveries,picks,returns,true).fullySigned();
}
}
@@ -1,4 +1,5 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.SignedDelivery;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
@@ -115,7 +116,9 @@ public class DeliveryDocumentService {
orders.updateById(order);
if (autoCompleted) {
var completion = completeRelatedOrders(order, operatorId, now);
fleetPool.signed(order, completion.sourcePackages(), operatorId, now);
fleetPool.signed(new SignedDelivery(
order.getCustomerId(),order.getDispatchNo(),order.getDeliveryNo(),"DELIVERED".equals(order.getStatus()),order.getSignedAt()),
completion.sourcePackages(),operatorId,now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
customerVisits.ensureForCompletedDelivery(order, now);
}
@@ -486,7 +486,6 @@ public class DeliveryTransferService {
public List<DeliveryResponses.ReturnOutboundCandidate> returnOutboundCandidates(String customerId, String warehouseId) {
if (!StringUtils.hasText(warehouseId)) return List.of();
return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream()
.filter(carton -> !"PARCEL".equals(carton.getSourceUnitType()))
.map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(),
c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(),
value(c.getTotalQuantity()), c.getStatus())).toList();
@@ -499,23 +498,16 @@ public class DeliveryTransferService {
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
String warehouseId = request.overseasWarehouseId().trim();
boolean itemMode = request.items() != null;
boolean skuMode = itemMode || StringUtils.hasText(request.skuCode());
Map<String, DeliveryRequests.ReturnSkuOutboundItem> requestedItems = new LinkedHashMap<>();
if (itemMode) {
if (request.items().isEmpty() || StringUtils.hasText(request.skuCode()) || request.quantity() != null || "PALLET".equals(request.unitType()))
throw conflict("散件明细出库不可同时提交统一SKU、数量或整托方式");
for (var line : request.items()) {
if (line == null || !StringUtils.hasText(line.cartonMark()) || !StringUtils.hasText(line.skuCode()) || line.quantity() == null || line.quantity() <= 0
|| requestedItems.putIfAbsent(line.cartonMark().trim(), line) != null) throw conflict("散件出库明细不能为空、数量必须大于0且来源不可重复");
}
}
if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0
|| "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库");
// A return outbound is always a physical package/carton operation. SKU and
// quantity are selection fields for the packing workflow, never a direct
// outbound command. Partial package/carton quantities must be materialized
// into a new labelled carton before they can be picked.
boolean itemMode = request.items() != null && !request.items().isEmpty();
boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()) || request.quantity() != null;
if (skuMode)
throw conflict("退货出库最小单位为包裹/箱;部分数量请先合箱并打印新箱标签后再出库");
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList();
if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复");
if (itemMode && (marks.size() != requestedItems.size() || !requestedItems.keySet().containsAll(marks)))
throw conflict("散件出库明细与所选来源不一致");
LocalDateTime now = LocalDateTime.now();
List<ReturnInventoryCarton> locked = new ArrayList<>();
for (String mark : marks.stream().sorted().toList()) {
@@ -529,37 +521,18 @@ public class DeliveryTransferService {
existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
for (ReturnInventoryCarton carton : locked) {
if (skuMode && !"PARCEL".equals(carton.getSourceUnitType()))
throw conflict("单SKU出库只允许散件库存;箱内库存请按箱或托盘出库");
if ("PARCEL".equals(carton.getSourceUnitType()) && !skuMode)
throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库");
if ("PACKING_RESERVED".equals(carton.getStatus()))
throw conflict("该包裹/箱正在合箱,请先完成或取消合箱");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark());
var contents = returnCartonItems.lockCarton(carton.getId());
if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark());
String selectedSku = itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode());
if (skuMode && (contents.size() != 1
|| !selectedSku.equals(contents.get(0).getSkuCode())))
throw conflict("所选散件来源必须仅包含指定SKU");
if (itemMode && requestedItems.get(carton.getCartonMark()).quantity() > value(contents.get(0).getQuantity()))
throw conflict("出库件数超过所选散件来源可用数量:" + carton.getCartonMark());
if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode()))
throw conflict("整托出库必须存在托盘绑定");
}
Map<String, Long> skuQuantities = new LinkedHashMap<>();
if (itemMode) requestedItems.forEach((mark, line) -> skuQuantities.put(mark, line.quantity()));
else if (StringUtils.hasText(request.skuCode())) {
long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum();
long remaining = request.quantity() == null ? available : request.quantity();
if (remaining > available) throw conflict("出库件数超过所选来源库存数量");
for (ReturnInventoryCarton carton : locked) {
long selected = Math.min(remaining, value(carton.getTotalQuantity()));
if (selected > 0) skuQuantities.put(carton.getCartonMark(), selected);
remaining -= selected;
}
locked = locked.stream().filter(c -> skuQuantities.containsKey(c.getCartonMark())).toList();
}
// No quantity allocation is performed here. The selected physical units
// are shipped whole; partials arrive only as newly-created merge cartons.
if ("PALLET".equals(request.unitType())) {
String pallet = locked.get(0).getPalletCode();
if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘");
@@ -580,9 +553,8 @@ public class DeliveryTransferService {
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId);
item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo());
// Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning.
item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()) : null);
item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark()));
// Packages and cartons are picked by their physical barcode.
item.setSkuCode(null); item.setReturnSkuQuantity(null);
item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null);
item.setCreatedAt(now); outboundPallets.insert(item);
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
@@ -52,6 +52,16 @@ public class CargoDiscardController {
return ApiResponse.success(service.detail(scope(user, customerId), discardNo));
}
@GetMapping("/{discardNo}/photos/files/{fileId}/download")
@org.springframework.security.access.prepost.PreAuthorize("hasAuthority('platform:overseas-warehouse:discard:list')")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String discardNo, @PathVariable long fileId) {
var file = service.downloadPhoto(scope(user, customerId), discardNo, fileId);
return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{discardNo}/review")
public ApiResponse<CargoDiscardResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo,
@@ -36,6 +36,16 @@ public class MerchantCargoDiscardController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
@GetMapping("/{discardNo}/photos/files/{fileId}/download")
@org.springframework.security.access.prepost.PreAuthorize("hasAuthority('merchant:overseas-warehouse:discard:list')")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo, @PathVariable long fileId) {
var file = service.downloadPhoto(customer(user), discardNo, fileId);
return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{discardNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@@ -25,7 +25,10 @@ public final class CargoDiscardResponses {
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record Photo(Long fileId, String palletNo, String fileName, String contentType, Long sizeBytes,
String operatorId, LocalDateTime createdAt, LocalDateTime submittedAt) {}
public record Detail(Summary summary, BigDecimal feePerPackage, String remark,
String reviewedBy, String reviewRemark, List<Item> items,
List<History> histories) {}
List<History> histories, List<Photo> photos) {}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_photo")
public class CargoDiscardPhoto {
@TableId private Long id;
private String customerId;
private Long applicationId;
private String palletNo;
private Long fileId;
private String fileName;
private String contentType;
private Long sizeBytes;
private String operatorId;
private LocalDateTime createdAt;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.discard.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface CargoDiscardPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
record DownloadedFile(byte[] content, String fileName, String contentType) { }
}
@@ -0,0 +1,135 @@
package com.baiyue.lastleg.discard.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpCargoDiscardPhotoFileGateway implements CargoDiscardPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_CARGO_DISCARD_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getCargoDiscardPhotoFile());
}
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
long tenantId = tenantId(customerId);
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) {
throw new IllegalArgumentException("Cannot read cargo-discard photo", exception);
}
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId, BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken)
.header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Cargo discard photo upload failed");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Cargo discard photo upload failed");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"File service returned no cargo-discard photo metadata");
}
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0L : data.sizeBytes());
}
@Override
public void validate(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Cargo discard photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Cargo discard photo does not exist or its ownership is invalid");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType())
|| !String.valueOf(taskId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Cargo discard photo metadata does not match the PDA task");
}
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
validate(customerId, taskId, fileId);
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
tenantId(customerId), BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Cargo discard photo download failed");
}).toEntity(byte[].class);
String fileName = response.getHeaders().getContentDisposition().getFilename();
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
fileName == null ? "cargo-discard-photo" : fileName, contentType);
}
private static long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Customer ID must be numeric", exception);
}
}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -19,4 +19,7 @@ public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardAp
@Select("SELECT * FROM ll_cargo_discard_application WHERE discard_no=#{discardNo} AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
CargoDiscardApplication findNoByWarehouse(@Param("discardNo") String discardNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_cargo_discard_application WHERE discard_no=#{discardNo} AND overseas_warehouse_id=#{warehouseId} LIMIT 1")
CargoDiscardApplication readNoByWarehouse(@Param("discardNo") String discardNo, @Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardPhoto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CargoDiscardPhotoMapper extends BaseMapper<CargoDiscardPhoto> {
@Select("SELECT * FROM ll_cargo_discard_photo WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id")
List<CargoDiscardPhoto> listApplication(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
}
@@ -24,6 +24,27 @@ public class PdaCargoDiscardController {
@Valid @RequestBody PdaCargoDiscardDtos.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo()));
}
@PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo> uploadPhoto(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo,
@RequestParam("file") org.springframework.web.multipart.MultipartFile file) {
return ApiResponse.success(service.uploadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo,
user.userId() == null ? 0L : user.userId(), file));
}
@GetMapping("/{discardNo}/pallets/{palletNo}/photos/files")
public ApiResponse<List<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo>> photos(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo) {
return ApiResponse.success(service.photos(PdaWarehouseIdentity.require(user), discardNo, palletNo));
}
@GetMapping("/{discardNo}/pallets/{palletNo}/photos/files/{fileId}/download")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo, @PathVariable String palletNo, @PathVariable long fileId) {
var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo, fileId);
return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{discardNo}/pallets/{palletNo}/confirm")
public ApiResponse<PdaCargoDiscardDtos.PalletTask> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo, @PathVariable String palletNo, @Valid @RequestBody PdaCargoDiscardDtos.Confirm request) {
@@ -12,7 +12,8 @@ public final class PdaCargoDiscardDtos {
private PdaCargoDiscardDtos() { }
public record Scan(@NotBlank @Size(max = 128) String palletNo) { }
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds) { }
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
@@ -23,6 +23,7 @@ import java.util.stream.Collectors;
@Service
public class PdaCargoDiscardService {
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@@ -78,6 +79,8 @@ public class PdaCargoDiscardService {
|| !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId())))
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
photoService.confirm(application, task.palletNo(), request.photoFileIds(),
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
// A completed pallet is an immutable result, including the final pallet after application completion.
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
if (terminal) throw conflict("废弃终态不能执行未完成托盘");
@@ -109,6 +112,36 @@ public class PdaCargoDiscardService {
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
}
@Transactional
public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo,
long operatorUserId, org.springframework.web.multipart.MultipartFile file) {
CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true);
var task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst().orElseThrow();
if (!CargoDiscardService.APPROVED.equals(application.getStatus()) || "COMPLETED".equals(task.status()))
throw conflict("已完成或未审核的废弃托盘不能上传照片");
return photoService.upload(application, task.palletNo(), identity.operatorKey(), operatorUserId, file);
}
@Transactional(readOnly = true)
public List<CargoDiscardResponses.Photo> photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) {
return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false);
}
@Transactional(readOnly = true)
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(
PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) {
return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false);
}
private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) {
var warehouseId = String.valueOf(identity.overseasWarehouseId());
var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId);
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim())))
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单");
return application;
}
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
return items.listApplication(application.getCustomerId(), application.getId()).stream()
.collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList()))
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardPhoto;
import com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway;
import com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
@Service
public class CargoDiscardPhotoService {
@Resource private CargoDiscardPhotoMapper photos;
@Resource private CargoDiscardPhotoFileGateway files;
public List<CargoDiscardResponses.Photo> list(CargoDiscardApplication application, String palletNo, boolean submittedOnly) {
return rows(application, palletNo).stream().filter(p -> !submittedOnly || p.getSubmittedAt() != null)
.map(this::view).toList();
}
public CargoDiscardResponses.Photo upload(CargoDiscardApplication application, String palletNo,
String operatorId, long operatorUserId, MultipartFile file) {
if (rows(application, palletNo).size() >= 20) throw conflict("每个托盘最多上传 20 张废弃照片");
var uploaded = files.upload(application.getCustomerId(), application.getId(), operatorUserId, file);
CargoDiscardPhoto photo = new CargoDiscardPhoto();
photo.setCustomerId(application.getCustomerId()); photo.setApplicationId(application.getId());
photo.setPalletNo(palletNo); photo.setFileId(uploaded.fileId()); photo.setFileName(uploaded.fileName());
photo.setContentType(uploaded.contentType()); photo.setSizeBytes(uploaded.sizeBytes());
photo.setOperatorId(operatorId); photo.setCreatedAt(LocalDateTime.now()); photos.insert(photo);
return view(photo);
}
// Called under the application row lock, in the same transaction as inventory deduction.
public void confirm(CargoDiscardApplication application, String palletNo, List<Long> fileIds, boolean completed) {
if (fileIds == null || fileIds.isEmpty() || fileIds.size() > 20
|| fileIds.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(fileIds).size() != fileIds.size())
throw conflict("每个托盘须提交 1–20 张不重复的废弃照片");
var rows = rows(application, palletNo);
var submitted = rows.stream().filter(p -> p.getSubmittedAt() != null).map(CargoDiscardPhoto::getFileId).toList();
if (completed) {
if (!new HashSet<>(submitted).equals(new HashSet<>(fileIds))) throw conflict("已完成托盘的废弃照片不能修改");
return;
}
var selected = rows.stream().filter(p -> fileIds.contains(p.getFileId())).toList();
if (selected.size() != fileIds.size()) throw conflict("照片不属于当前废弃单或托盘");
selected.forEach(p -> files.validate(application.getCustomerId(), application.getId(), p.getFileId()));
var now = LocalDateTime.now();
selected.forEach(p -> { p.setSubmittedAt(now); photos.updateById(p); });
}
public CargoDiscardPhotoFileGateway.DownloadedFile download(CargoDiscardApplication application, String palletNo,
long fileId, boolean submittedOnly) {
var photo = rows(application, palletNo).stream().filter(p -> Objects.equals(p.getFileId(), fileId))
.filter(p -> !submittedOnly || p.getSubmittedAt() != null).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃照片不存在或尚未提交"));
return files.download(application.getCustomerId(), application.getId(), photo.getFileId());
}
private List<CargoDiscardPhoto> rows(CargoDiscardApplication application, String palletNo) {
return photos.listApplication(application.getCustomerId(), application.getId()).stream()
.filter(p -> palletNo == null || palletNo.equals(p.getPalletNo())).toList();
}
private CargoDiscardResponses.Photo view(CargoDiscardPhoto photo) {
return new CargoDiscardResponses.Photo(photo.getFileId(), photo.getPalletNo(), photo.getFileName(),
photo.getContentType(), photo.getSizeBytes(), photo.getOperatorId(), photo.getCreatedAt(), photo.getSubmittedAt());
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}

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