Merge latest test into dev preserving initialization fixes [skip ci]
This commit is contained in:
@@ -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/` |
|
||||
|
||||
|
||||
@@ -218,6 +218,12 @@ services:
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9210
|
||||
LINGXING_WMS_ENABLED: ${LINGXING_WMS_ENABLED:-false}
|
||||
LINGXING_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
|
||||
LINGXING_ENVIRONMENT_LABEL: ${DEPLOY_ENV:-test}
|
||||
LINGXING_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
|
||||
LINGXING_OMS_APP_KEY: ${LINGXING_OMS_APP_KEY:-}
|
||||
LINGXING_OMS_APP_SECRET: ${LINGXING_OMS_APP_SECRET:-}
|
||||
BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false}
|
||||
BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-}
|
||||
BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-}
|
||||
@@ -282,6 +288,12 @@ services:
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9303
|
||||
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
|
||||
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
|
||||
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
|
||||
SELFOP_DAILY_REQUEST_BUDGET: ${SELFOP_DAILY_REQUEST_BUDGET:-0}
|
||||
SELFOP_MIN_REQUEST_INTERVAL_MS: ${SELFOP_MIN_REQUEST_INTERVAL_MS:-5000}
|
||||
SELFOP_SALES_PUBLICATION_ENABLED: ${SELFOP_SALES_PUBLICATION_ENABLED:-false}
|
||||
EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000}
|
||||
EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000}
|
||||
EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60}
|
||||
|
||||
Vendored
+11
@@ -21,6 +21,17 @@ IMAGE_TAG=
|
||||
FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
INTERNAL_SERVICE_TOKEN=
|
||||
# 领星唯一账号;凭据仅注入 integration-service。
|
||||
LINGXING_OMS_APP_KEY=
|
||||
LINGXING_OMS_APP_SECRET=
|
||||
LINGXING_WMS_ENABLED=false
|
||||
SELFOP_COLLECTION_MODE=OFF
|
||||
SELFOP_SOURCE_ACCOUNT=lingxing-primary
|
||||
# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。
|
||||
SELFOP_DAILY_REQUEST_BUDGET=0
|
||||
SELFOP_MIN_REQUEST_INTERVAL_MS=5000
|
||||
SELFOP_EXECUTION_ENABLED=false
|
||||
SELFOP_SALES_PUBLICATION_ENABLED=false
|
||||
REDIS_PASSWORD=
|
||||
AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
|
||||
AUTH_DB_USERNAME=
|
||||
|
||||
+11
-3
@@ -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;
|
||||
+44
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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) {}
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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) { }
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+37
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-8
@@ -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");
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+3
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copy to .env.local for local WMS connectivity verification.
|
||||
LINGXING_OMS_APP_KEY=
|
||||
LINGXING_OMS_APP_SECRET=
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
- 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java`
|
||||
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
|
||||
- 领星 WMS 自研系统出库只读查询:`controller/LingxingInternalController.java`、`provider/lingxing/`、`dto/lingxing/`、`config/LingxingWmsProperties.java`;内部契约 `openapi/lingxing-wms-v1.yaml` 位于 `src/main/resources/`。内部提供出库单页摘要、独立按单定位、出库详情、仓库目录及单仓产品库存单页查询;统一账号节流、有限退避和严格分页校验,保留未知数量与源时间原值。不编排定时采集、多页库存同步或业务持久化;本地隔离启动与环境变量入口见 `README.md`,真实调用默认关闭。
|
||||
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
|
||||
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
|
||||
- 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md`
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Integration 本地领星 WMS 查询
|
||||
|
||||
本入口用于在现有 Java 服务内验证 WMS 只读调用。仅返回单页出库摘要,不启动月度采集、库存全量同步、定时任务或数据库写入。业务方案见[领星一件代发数据统计一期方案](../../docs/自营业务/领星一件代发数据统计一期方案.md)。
|
||||
|
||||
后续开发交接见[接口联调手册](../../docs/自营业务/领星WMS接口联调手册.md):已另行用只读 HTTP 验证仓库发现、一件代发详情和按仓产品库存全分页。此处现有 Java 接口仍只有出库摘要;库存内部接口、分仓任务和持久化尚待开发,不能以外部直连成功推定 Java 功能已经具备。
|
||||
|
||||
## 凭据与开关
|
||||
|
||||
从 WMS 管理后台的“个人中心 → API 信息 → 自研系统”获取 `appKey` 和 `appSecret`,填写本目录 Git 忽略的 `.env.local`,格式参考 [.env.example](.env.example)。这两个值分别加载为 `LINGXING_OMS_APP_KEY` 和 `LINGXING_OMS_APP_SECRET` 进程环境变量,不提交真实值。Spring 本身不会自动读取 `.env.local`,应在启动 Java 的同一终端加载。该凭据属于 OMP 服务商内部系统对接,不使用 OMS 货主或 WMS 智能设备协议。
|
||||
|
||||
正式应用配置默认 `LINGXING_WMS_ENABLED=false`。下面的本地命令显式启用适配器,为该本地实例生成内部调用令牌。不要复用共享环境令牌。
|
||||
|
||||
测试部署通过 `deploy/docker-compose.yml` 将 `LINGXING_OMS_APP_KEY`、`LINGXING_OMS_APP_SECRET` 传入 Integration 容器。现有 Jenkins 下游部署任务读取 `firstleg-test-env` Secret File;可将这两项加入该文件,或在实际执行部署的下游服务任务中以凭据绑定注入同名环境变量。只在上游总控任务设置变量不会自动传给下游任务。变量命名采用用户指定的 `OMS`,接口协议仍为当前 WMS 自研系统(OMP);真实凭据是否有该接口权限仍待联调核验。不要将密钥作为普通构建参数或打印完整 Compose 配置。
|
||||
|
||||
2026-10-12 用户确认唯一账号、所有源时间按北京时间、数量按 SKU 件数。Lastleg 应用配置使用 `source-account=lingxing-primary`(内部稳定标识,不是 appKey)、`source-query-timezone=Asia/Shanghai`、`quantity-unit=PCS`,分仓配置的源时区默认 `Asia/Shanghai`;数量仍保留十进制精度。仓库编码须在获得真实仓库目录后配置到 `baiyue.self-operated-analytics.warehouses`,不预填虚构仓库。`SELFOP_EXECUTION_ENABLED` 和 `SELFOP_SALES_PUBLICATION_ENABLED` 均默认关闭。
|
||||
|
||||
## 本地启动
|
||||
|
||||
先在后端仓库根目录执行构建,包含相关模块的编译与测试:
|
||||
|
||||
```powershell
|
||||
mvn -pl integration-service -am package
|
||||
```
|
||||
|
||||
然后在同一目录的 PowerShell 中执行。只加载明确允许的两个名称,不执行环境文件内的代码:
|
||||
|
||||
```powershell
|
||||
$credentialNames = @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET')
|
||||
foreach ($line in [IO.File]::ReadAllLines((Join-Path (Get-Location) 'integration-service/.env.local'))) {
|
||||
if ($line -match '^\s*(LINGXING_OMS_APP_KEY|LINGXING_OMS_APP_SECRET)\s*=(.*)$') {
|
||||
$credentialName = $matches[1]
|
||||
$credentialValue = $matches[2].Trim()
|
||||
if ($credentialValue.Length -ge 2 -and
|
||||
(($credentialValue.StartsWith('"') -and $credentialValue.EndsWith('"')) -or
|
||||
($credentialValue.StartsWith("'") -and $credentialValue.EndsWith("'")))) {
|
||||
$credentialValue = $credentialValue.Substring(1, $credentialValue.Length - 2)
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($credentialName, $credentialValue, 'Process')
|
||||
}
|
||||
}
|
||||
foreach ($credentialName in $credentialNames) {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($credentialName, 'Process'))) {
|
||||
throw "Missing credential: $credentialName"
|
||||
}
|
||||
}
|
||||
$env:INTERNAL_SERVICE_TOKEN = [guid]::NewGuid().ToString()
|
||||
$localInternalToken = $env:INTERNAL_SERVICE_TOKEN
|
||||
$env:INTEGRATION_LOCAL_PORT = '19210'
|
||||
$buildJar = (Resolve-Path 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar').Path
|
||||
$localJava = Start-Process -FilePath (Get-Command java).Source -WindowStyle Hidden -PassThru `
|
||||
-ArgumentList @('-jar', ('"' + $buildJar + '"'),
|
||||
'--spring.config.location=classpath:/application-lingxing-local.yml',
|
||||
'--baiyue.integration.lingxing-wms.enabled=true') `
|
||||
-RedirectStandardOutput 'integration-service/target/lingxing-local.stdout.log' `
|
||||
-RedirectStandardError 'integration-service/target/lingxing-local.stderr.log'
|
||||
```
|
||||
|
||||
该配置仅监听 `127.0.0.1`,关闭 Nacos Config、Discovery、服务注册及 Spring 定时任务,不导入共享环境配置。若端口占用,选择其他本地端口并同步替换后续 URL,不停止未知进程。确认 `http://127.0.0.1:19210/actuator/health` 返回 `UP` 后再查询。
|
||||
|
||||
## 读取一条出库单
|
||||
|
||||
在保留上述变量的同一 PowerShell 中执行一次:
|
||||
|
||||
```powershell
|
||||
$businessZone = [TimeZoneInfo]::FindSystemTimeZoneById('China Standard Time')
|
||||
$queryEnd = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $businessZone)
|
||||
$query = @{
|
||||
startTime = $queryEnd.AddMonths(-1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
endTime = $queryEnd.ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
current = 1
|
||||
size = 1
|
||||
} | ConvertTo-Json -Compress
|
||||
$result = Invoke-RestMethod -Method Post `
|
||||
-Uri 'http://127.0.0.1:19210/internal/integrations/lingxing/wms/outbound-orders/query' `
|
||||
-Headers @{'X-Internal-Service-Token' = $localInternalToken} `
|
||||
-ContentType 'application/json' -Body $query
|
||||
$result | ConvertTo-Json -Depth 6
|
||||
```
|
||||
|
||||
返回摘要只包含出库单号、来源单号、货主编码、仓库编码、状态、创建时间和出库时间,不返回收件人或地址。业务 `code="200"` 且 `records` 包含一条才表示已读到样本;空页表示调用成功但未取得样本;`B0001`/`S0503` 表示明确失败,保留 `errorCode`、来源数值错误码及请求标识排查,不能当作零销量。
|
||||
|
||||
当前调用 OMP `POST /openapi/v2/order/delivery/page`,固定 `timeType=outboundTime`、`status=100` 已出库,窗口最多向前一个自然月。该本地保护不代表供应商历史上限;单页样本也不代表整月采集完成。订单详情、全量库存、增量任务和统计落库仍按业务方案后续实现。
|
||||
|
||||
测试完毕,在同一终端停止本次启动的进程,并清理当前终端注入的变量:
|
||||
|
||||
```powershell
|
||||
if (-not $localJava.HasExited) { Stop-Process -Id $localJava.Id }
|
||||
foreach ($credentialName in @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET', 'INTERNAL_SERVICE_TOKEN')) {
|
||||
[Environment]::SetEnvironmentVariable($credentialName, $null, 'Process')
|
||||
}
|
||||
$localInternalToken = $null
|
||||
$credentialValue = $null
|
||||
```
|
||||
|
||||
## 接口与验证
|
||||
|
||||
- 2026-10-12 已通过本地 Java 实例调用 OMP WMS 出库分页,按最近 1 个月、`current=1`、`size=1` 成功返回 1 条已出库摘要;无内部令牌请求被拒绝。验证后本地进程已停止。该结果仅证明当前只读链路可用,不代表全量采集或库存同步完成。
|
||||
- 实测成功响应为数值 `code=200`,但缺少官方示例及 Schema 中的 `success`。客户端以成功码和有效分页结构共同判定;`success` 出现时仍须为布尔 `true`。此差异已加入回归测试,失败响应不转换为空页。
|
||||
- 内部前缀为 `POST /internal/integrations/lingxing/wms`,提供 `/outbound-orders/query`、`/outbound-orders/locate`、`/outbound-orders/detail`、`/warehouses/query`、`/product-inventory/query`;仅接受 `X-Internal-Service-Token`,不经公网 Gateway。各请求只返回一页或一单,多页与业务版本归 Lastleg 编排。定位不传时间/状态;产品库存必须指定仓库、保留全部品质和零库存。源时间原值与 BigDecimal 数量未知值保留,不推断时区或单位。
|
||||
- [OpenAPI](src/main/resources/openapi/lingxing-wms-v1.yaml)定义输入、分页摘要和失败结构。
|
||||
- 从后端根执行 `mvn -pl integration-service -am test` 与 `node --test scripts/gateway-route-contract.test.mjs`。
|
||||
- 本地模拟 HTTP 单测验证签名、请求体、敏感信息隔离和错误处理;不能代替真实领星样本验证。官方入口为[服务商内部系统对接](https://apidoc-omp.xlwms.com/)与[一件代发出库单 WMS 分页查询](https://apidoc-omp.xlwms.com/reference/post_openapi-v2-order-delivery-page-1)。
|
||||
+37
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
+6
@@ -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) {}
|
||||
+46
@@ -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() + "]";
|
||||
}
|
||||
}
|
||||
+32
@@ -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()));
|
||||
}
|
||||
}
|
||||
+118
@@ -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) { }
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.integration.dto.lingxing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Control identities are supplied by the collector and revalidated by its durable authority. */
|
||||
public final class LingxingEgressDtos {
|
||||
private LingxingEgressDtos() { }
|
||||
public enum CollectionMode { OFF, VALIDATION, BUSINESS }
|
||||
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY }
|
||||
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
|
||||
String sessionId, String attemptId, String exclusiveGrantId) {
|
||||
public boolean wellFormed() {
|
||||
return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS)
|
||||
&& identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128)
|
||||
&& identifier(attemptId, 128) && identifier(exclusiveGrantId, 128);
|
||||
}
|
||||
private static boolean identifier(String value, int maximum) {
|
||||
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
|
||||
}
|
||||
}
|
||||
public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { }
|
||||
public record AttemptPermit(EgressContext context, Operation operation, String requestHash,
|
||||
int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed,
|
||||
boolean budgetConsumed) { }
|
||||
public record CompletionRequest(EgressContext context, Operation operation, String requestHash,
|
||||
int actualHttpAttempts, String errorCode, Integer upstreamStatus,
|
||||
Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { }
|
||||
public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused,
|
||||
Instant cooldownUntil) { }
|
||||
public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { }
|
||||
}
|
||||
+9
@@ -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) { }
|
||||
+56
@@ -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); }
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
|
||||
/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */
|
||||
public interface LingxingBudgetAuthority {
|
||||
AttemptPermit authorize(AuthorizationRequest request);
|
||||
void complete(CompletionRequest request);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
|
||||
|
||||
/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */
|
||||
public final class LingxingBudgetDeniedException extends RuntimeException {
|
||||
private final AuthorizationDenial denial;
|
||||
public LingxingBudgetDeniedException(AuthorizationDenial denial) {
|
||||
super("领星持久预算授权拒绝"); this.denial = denial;
|
||||
}
|
||||
public AuthorizationDenial denial() { return denial; }
|
||||
}
|
||||
+170
@@ -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); }
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.net.http.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */
|
||||
@Component
|
||||
public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority {
|
||||
private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/";
|
||||
private final LingxingEgressProperties properties;
|
||||
private final ObjectMapper mapper;
|
||||
private final String internalToken;
|
||||
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper,
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
|
||||
this.properties = properties; this.mapper = mapper; this.internalToken = internalToken;
|
||||
}
|
||||
@Override public AttemptPermit authorize(AuthorizationRequest request) {
|
||||
JsonNode data = post("authorize", request);
|
||||
if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber()
|
||||
|| data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean()
|
||||
|| !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean()
|
||||
|| !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject()
|
||||
|| !data.path("expiresAt").isTextual()) throw unavailable();
|
||||
try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); }
|
||||
catch (Exception ignored) { throw unavailable(); }
|
||||
}
|
||||
@Override public void complete(CompletionRequest request) {
|
||||
JsonNode data = post("complete", request);
|
||||
if (!data.isBoolean() || !data.booleanValue()) throw unavailable();
|
||||
}
|
||||
private JsonNode post(String suffix, Object payload) {
|
||||
if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank()
|
||||
|| internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable();
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix))
|
||||
.timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() != 200) throw unavailable();
|
||||
JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body());
|
||||
if (root == null || !root.isObject() || !root.path("code").isTextual()
|
||||
|| !root.hasNonNull("data")) throw unavailable();
|
||||
if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) {
|
||||
JsonNode data = root.get("data");
|
||||
if (!data.isObject() || !data.path("errorCode").isTextual()
|
||||
|| !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")
|
||||
|| !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable();
|
||||
AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class)
|
||||
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper));
|
||||
throw new LingxingBudgetDeniedException(denial);
|
||||
}
|
||||
if (!"200".equals(root.path("code").textValue())) throw unavailable();
|
||||
return root.get("data");
|
||||
} catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision;
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt(); throw unavailable();
|
||||
} catch (Exception ignored) { throw unavailable(); }
|
||||
}
|
||||
private static LingxingWmsException unavailable() {
|
||||
return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null);
|
||||
}
|
||||
}
|
||||
+122
@@ -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){}
|
||||
}
|
||||
+17
@@ -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);}
|
||||
}
|
||||
+50
@@ -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());}
|
||||
}
|
||||
}
|
||||
+442
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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; }
|
||||
}
|
||||
+74
@@ -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'}
|
||||
+71
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
+163
@@ -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());
|
||||
}
|
||||
}
|
||||
+384
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** Loopback authority only; the persisted collector ledger is tested by its own service. */
|
||||
class LingxingHttpBudgetAuthorityTest {
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
private final AtomicReference<String> response = new AtomicReference<>();
|
||||
private final AtomicInteger status = new AtomicInteger(200);
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final AtomicReference<String> path = new AtomicReference<>(), token = new AtomicReference<>(), body = new AtomicReference<>();
|
||||
private HttpServer server;
|
||||
private static final String TOKEN = "synthetic-internal-token-0123456789";
|
||||
private final AuthorizationRequest request = new AuthorizationRequest(LingxingEgressGateTest.context("attempt"), Operation.WAREHOUSES, LingxingEgressGateTest.HASH);
|
||||
@BeforeEach void start() throws IOException {
|
||||
response.set("{\"code\":\"200\",\"data\":true}");
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
|
||||
token.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
|
||||
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
exchange.getResponseHeaders().set("Location", "/redirect-must-not-follow");
|
||||
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status.get(), bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
@AfterEach void stop() { server.stop(0); }
|
||||
private LingxingHttpBudgetAuthority authority(String serviceToken) {
|
||||
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
|
||||
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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();}}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+313
@@ -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();
|
||||
}
|
||||
}
|
||||
+90
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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` 不得丢失中文备注。
|
||||
|
||||
@@ -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&¬es.fixtureId==='SYNTHETIC-SELFOP-20261012'&¬es.sourceAccount==='lingxing-primary'&&
|
||||
options.account===notes.sourceAccount&&options.warehouse==='SELFOP-TEST-A'&&options.otherWarehouse==='SELFOP-TEST-B'&&
|
||||
notes.currentVersions?.A?.salesRunId&¬es.currentVersions?.B?.inventoryRunId&¬es.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);
|
||||
}
|
||||
});
|
||||
+3
-39
@@ -1,42 +1,6 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Called by actual incoming materialization, never as a migration or historical data repair. */
|
||||
@Service
|
||||
public class AppointmentSourceLinker {
|
||||
private final LastlegOrderMapper orders;
|
||||
private final InboundFbaAppointmentItemMapper items;
|
||||
private final AppointmentExternalSourceGateway bookings;
|
||||
public AppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;}
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void materialized(String customerId,String bookingNo,Long orderId) {
|
||||
var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo);
|
||||
bookings.lockForAppointment(List.of(ref));
|
||||
var matched=bookings.resolve(List.of(ref));
|
||||
if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对");
|
||||
var order=orderId==null?null:orders.selectById(orderId);
|
||||
if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物");
|
||||
var rows=items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo)
|
||||
.eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE"));
|
||||
for(var row:rows) {
|
||||
if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑");
|
||||
if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue;
|
||||
try {items.update(null,new LambdaUpdateWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true)
|
||||
.set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));}
|
||||
catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");}
|
||||
}
|
||||
}
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
/** Associates a newly materialized source with its receiving appointment in the caller's transaction. */
|
||||
public interface AppointmentSourceLinker {
|
||||
void materialized(String customerId, String bookingNo, Long orderId);
|
||||
}
|
||||
|
||||
+3
@@ -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; }
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.delivery.fleet;
|
||||
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolDeliveryEvidence;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
|
||||
/** Same-transaction delivery proof; no persistence model escapes into fleet. */
|
||||
@Component
|
||||
public class FleetPoolDeliveryAdapter implements FleetPoolDeliveryEvidence {
|
||||
private final DeliverySourceEvidenceMapper evidence;
|
||||
public FleetPoolDeliveryAdapter(DeliverySourceEvidenceMapper evidence){this.evidence=evidence;}
|
||||
@Override public boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages) {
|
||||
var dispatchIds=evidence.dispatchIds(customerId,orderId);
|
||||
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
|
||||
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
|
||||
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
|
||||
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
|
||||
for(Long dispatchId:dispatchIds){
|
||||
if(dispatchId==null||dispatchId<=0)return false;
|
||||
var d=evidence.deliveries(customerId,dispatchId);var p=evidence.picks(customerId,dispatchId);
|
||||
var r=evidence.returns(customerId,dispatchId);
|
||||
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
|
||||
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
|
||||
}
|
||||
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(incomingNo,orderId,expectedPackages),
|
||||
deliveries,picks,returns,true).fullySigned();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.SignedDelivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
@@ -115,7 +116,9 @@ public class DeliveryDocumentService {
|
||||
orders.updateById(order);
|
||||
if (autoCompleted) {
|
||||
var completion = completeRelatedOrders(order, operatorId, now);
|
||||
fleetPool.signed(order, completion.sourcePackages(), operatorId, now);
|
||||
fleetPool.signed(new SignedDelivery(
|
||||
order.getCustomerId(),order.getDispatchNo(),order.getDeliveryNo(),"DELIVERED".equals(order.getStatus()),order.getSignedAt()),
|
||||
completion.sourcePackages(),operatorId,now);
|
||||
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
|
||||
customerVisits.ensureForCompletedDelivery(order, now);
|
||||
}
|
||||
|
||||
+14
-42
@@ -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);
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+4
-1
@@ -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) {}
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+12
@@ -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) { }
|
||||
}
|
||||
+135
@@ -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) { }
|
||||
}
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
+21
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+33
@@ -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()))
|
||||
|
||||
+75
@@ -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
Reference in New Issue
Block a user