Compare commits

...

2 Commits

Author SHA1 Message Date
Neo
c3749474c6 迁移代码到Git 2025-11-18 02:32:00 +08:00
Neo
7f87421678 迁移代码到Git 2025-11-18 02:31:52 +08:00
86 changed files with 185516 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# 虚拟环境
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# 日志和导出
*.log
*.jsonl
export/
logs/
# 环境变量
.env
.env.local
# 测试
.pytest_cache/
.coverage
htmlcov/

0
.gitkeep Normal file
View File

38
etl_billiards/.env Normal file
View File

@@ -0,0 +1,38 @@
# 数据库配置
PG_DSN=postgresql://local-Python:Neo-local-1991125@localhost:5432/LLZQ
# PG_HOST=localhost
# PG_PORT=5432
# PG_NAME=LLZQ
# PG_USER=local-Python
# PG_PASSWORD=your_password_here
# API配置(抓取时,非球的一些配置)
API_BASE=https://api.example.com # 非球URL前缀
API_TOKEN=your_token_here # 登录Token
# API_TIMEOUT=20
# API_PAGE_SIZE=200
# API_RETRY_MAX=3
# 应用配置
STORE_ID=2790685415443269
# TIMEZONE=Asia/Taipei
# SCHEMA_OLTP=billiards
# SCHEMA_ETL=etl_admin
# 路径配置
EXPORT_ROOT=r"D:\LLZQ\DB\export",
LOG_ROOT=r"D:\LLZQ\DB\logs",
# ETL配置
OVERLAP_SECONDS=120 # 为了防止边界遗漏,会往前“回拨”一点的冗余秒数
WINDOW_BUSY_MIN=30
WINDOW_IDLE_MIN=180
IDLE_START=04:00
IDLE_END=16:00
ALLOW_EMPTY_ADVANCE=true
# 清洗配置
LOG_UNKNOWN_FIELDS=true
HASH_ALGO=sha1
STRICT_NUMERIC=true
ROUND_MONEY_SCALE=2

View File

@@ -0,0 +1,38 @@
# 数据库配置
PG_DSN=postgresql://user:password@localhost:5432/LLZQ
PG_HOST=localhost
PG_PORT=5432
PG_NAME=LLZQ
PG_USER=local-Python
PG_PASSWORD=your_password_here
# API配置
API_BASE=https://api.example.com
API_TOKEN=your_token_here
API_TIMEOUT=20
API_PAGE_SIZE=200
API_RETRY_MAX=3
# 应用配置
STORE_ID=2790685415443269
TIMEZONE=Asia/Taipei
SCHEMA_OLTP=billiards
SCHEMA_ETL=etl_admin
# 路径配置
EXPORT_ROOT=/path/to/export
LOG_ROOT=/path/to/logs
# ETL配置
OVERLAP_SECONDS=120
WINDOW_BUSY_MIN=30
WINDOW_IDLE_MIN=180
IDLE_START=04:00
IDLE_END=16:00
ALLOW_EMPTY_ADVANCE=true
# 清洗配置
LOG_UNKNOWN_FIELDS=true
HASH_ALGO=sha1
STRICT_NUMERIC=true
ROUND_MONEY_SCALE=2

7
etl_billiards/0.py Normal file
View File

@@ -0,0 +1,7 @@
# -*- coding: UTF-8 -*-
# Filename : helloworld.py
# author by : www.runoob.com
# 该实例输出 Hello World!
print('Hello World!')

615
etl_billiards/README.md Normal file
View File

@@ -0,0 +1,615 @@
# 台球场 ETL 系统(模块化版本)合并文档
本文为原多份文档(如 `INDEX.md``QUICK_START.md``ARCHITECTURE.md``MIGRATION_GUIDE.md``PROJECT_STRUCTURE.md``README.md` 等)的合并版,只保留与**当前项目本身**相关的内容:项目说明、目录结构、架构设计、数据与控制流程、迁移与扩展指南等,不包含修改历史和重构过程描述。
---
## 1. 项目概述
台球场 ETL 系统是一个面向门店业务的专业 ETL 工程项目,用于从外部业务 API 拉取订单、支付、会员等数据经过解析、校验、SCD2 处理、质量检查后写入 PostgreSQL 数据库,并支持增量同步和任务运行追踪。
系统采用模块化、分层架构设计,核心特性包括:
- 模块化目录结构配置、数据库、API、模型、加载器、SCD2、质量检查、编排、任务、CLI、工具、测试等分层清晰
- 完整的配置管理:默认值 + 环境变量 + CLI 参数多层覆盖。
- 可复用的数据库访问层(连接管理、批量 Upsert 封装)。
- 支持重试与分页的 API 客户端。
- 类型安全的数据解析与校验模块。
- SCD2 维度历史管理。
- 数据质量检查(例如余额一致性检查)。
- 任务编排层统一调度、游标管理与运行追踪。
- 命令行入口统一管理任务执行支持筛选任务、Dry-run 等模式。
---
## 2. 快速开始
### 2.1 环境准备
- Python 版本:建议 3.10+
- 数据库PostgreSQL
- 操作系统Windows / Linux / macOS 均可
```bash
# 克隆/下载代码后进入项目目录
cd etl_billiards/
ls -la
```
你会看到下述目录结构的顶层部分(详细见第 4 章):
- `config/` - 配置管理
- `database/` - 数据库访问
- `api/` - API 客户端
- `tasks/` - ETL 任务实现
- `cli/` - 命令行入口
- `docs/` - 技术文档
### 2.2 安装依赖
```bash
pip install -r requirements.txt
```
主要依赖示例(按实际 `requirements.txt` 为准):
- `psycopg2-binary`PostgreSQL 驱动
- `requests`HTTP 客户端
- `python-dateutil`:时间处理
- `tzdata`:时区数据
### 2.3 配置环境变量
复制并修改环境变量模板:
```bash
cp .env.example .env
# 使用你习惯的编辑器修改 .env
```
`.env` 示例(最小配置):
```bash
# 数据库
PG_DSN=postgresql://user:password@localhost:5432/LLZQ
# API
API_BASE=https://api.example.com
API_TOKEN=your_token_here
# 门店/应用
STORE_ID=2790685415443269
TIMEZONE=Asia/Taipei
# 目录
EXPORT_ROOT=/path/to/export
LOG_ROOT=/path/to/logs
```
> 所有配置项的默认值见 `config/defaults.py`,最终生效配置由「默认值 + 环境变量 + CLI 参数」三层叠加。
### 2.4 运行第一个任务
通过 CLI 入口运行:
```bash
# 运行所有任务
python -m cli.main
# 仅运行订单任务
python -m cli.main --tasks ORDERS
# 运行订单 + 支付
python -m cli.main --tasks ORDERS,PAYMENTS
# Windows 使用脚本
run_etl.bat --tasks ORDERS
# Linux / macOS 使用脚本
./run_etl.sh --tasks ORDERS
```
### 2.5 查看结果
- 日志目录:使用 `LOG_ROOT` 指定,例如
```bash
ls -la D:\LLZQ\DB\logs/
```
- 导出目录:使用 `EXPORT_ROOT` 指定,例如
```bash
ls -la D:\LLZQ\DB\export/
```
---
## 3. 常用命令与开发工具
### 3.1 CLI 常用命令
```bash
# 运行所有任务
python -m cli.main
# 运行指定任务
python -m cli.main --tasks ORDERS,PAYMENTS,MEMBERS
# 使用自定义数据库
python -m cli.main --pg-dsn "postgresql://user:password@host:5432/db"
# 使用自定义 API 端点
python -m cli.main --api-base "https://api.example.com" --api-token "..."
# 试运行(不写入数据库)
python -m cli.main --dry-run --tasks ORDERS
```
### 3.2 IDE / 代码质量工具示例VSCode
`.vscode/settings.json` 示例:
```json
{
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.formatting.provider": "black",
"python.testing.pytestEnabled": true
}
```
代码格式化与检查:
```bash
pip install black isort pylint
black .
isort .
pylint etl_billiards/
```
### 3.3 测试
```bash
# 安装测试依赖(按需)
pip install pytest pytest-cov
# 运行全部测试
pytest
# 仅运行单元测试
pytest tests/unit/
# 生成覆盖率报告
pytest --cov=. --cov-report=html
```
测试示例(按实际项目为准):
- `tests/unit/test_config.py` 配置管理单元测试
- `tests/unit/test_parsers.py` 解析器单元测试
- `tests/integration/test_database.py` 数据库集成测试
---
## 4. 项目结构与文件说明
### 4.1 总体目录结构(树状图)
```text
etl_billiards/
├── README.md # 项目总览和使用说明
├── MIGRATION_GUIDE.md # 从旧版本迁移指南
├── requirements.txt # Python 依赖列表
├── setup.py # 项目安装配置
├── .env.example # 环境变量配置模板
├── .gitignore # Git 忽略文件配置
├── run_etl.sh # Linux/Mac 运行脚本
├── run_etl.bat # Windows 运行脚本
├── config/ # 配置管理模块
│ ├── __init__.py
│ ├── defaults.py # 默认配置值定义
│ ├── env_parser.py # 环境变量解析器
│ └── settings.py # 配置管理主类
├── database/ # 数据库访问层
│ ├── __init__.py
│ ├── connection.py # 数据库连接管理
│ └── operations.py # 批量操作封装
├── api/ # HTTP API 客户端
│ ├── __init__.py
│ └── client.py # API 客户端(重试 + 分页)
├── models/ # 数据模型层
│ ├── __init__.py
│ ├── parsers.py # 类型解析器
│ └── validators.py # 数据验证器
├── loaders/ # 数据加载器层
│ ├── __init__.py
│ ├── base_loader.py # 加载器基类
│ ├── dimensions/ # 维度表加载器
│ │ ├── __init__.py
│ │ └── member.py # 会员维度加载器
│ └── facts/ # 事实表加载器
│ ├── __init__.py
│ ├── order.py # 订单事实表加载器
│ └── payment.py # 支付记录加载器
├── scd/ # SCD2 处理层
│ ├── __init__.py
│ └── scd2_handler.py # SCD2 历史记录处理器
├── quality/ # 数据质量检查层
│ ├── __init__.py
│ ├── base_checker.py # 质量检查器基类
│ └── balance_checker.py # 余额一致性检查器
├── orchestration/ # ETL 编排层
│ ├── __init__.py
│ ├── scheduler.py # ETL 调度器
│ ├── task_registry.py # 任务注册表(工厂模式)
│ ├── cursor_manager.py # 游标管理器
│ └── run_tracker.py # 运行记录追踪器
├── tasks/ # ETL 任务层
│ ├── __init__.py
│ ├── base_task.py # 任务基类(模板方法)
│ ├── orders_task.py # 订单 ETL 任务
│ ├── payments_task.py # 支付 ETL 任务
│ └── members_task.py # 会员 ETL 任务
├── cli/ # 命令行接口层
│ ├── __init__.py
│ └── main.py # CLI 主入口
├── utils/ # 工具函数
│ ├── __init__.py
│ └── helpers.py # 通用工具函数
├── tests/ # 测试代码
│ ├── __init__.py
│ ├── unit/ # 单元测试
│ │ ├── __init__.py
│ │ ├── test_config.py
│ │ └── test_parsers.py
│ └── integration/ # 集成测试
│ ├── __init__.py
│ └── test_database.py
└── docs/ # 文档
└── ARCHITECTURE.md # 架构设计文档
```
### 4.2 各模块职责概览
- **config/**
- 统一配置入口,支持默认值、环境变量、命令行参数三层覆盖。
- **database/**
- 封装 PostgreSQL 连接与批量操作插入、更新、Upsert 等)。
- **api/**
- 对上游业务 API 的 HTTP 调用进行统一封装,支持重试、分页与超时控制。
- **models/**
- 提供类型解析器(时间戳、金额、整数等)与业务级数据校验器。
- **loaders/**
- 提供事实表与维度表的加载逻辑(包含批量 Upsert、统计写入结果等
- **scd/**
- 维度型数据的 SCD2 历史管理(有效期、版本标记等)。
- **quality/**
- 质量检查策略,例如余额一致性、记录数量对齐等。
- **orchestration/**
- 任务调度、任务注册、游标管理(增量窗口)、运行记录追踪。
- **tasks/**
- 具体业务任务(订单、支付、会员等),封装了从“取数 → 处理 → 写库 → 记录结果”的完整流程。
- **cli/**
- 命令行入口,解析参数并启动调度流程。
- **utils/**
- 杂项工具函数。
- **tests/**
- 单元测试与集成测试代码。
---
## 5. 架构设计与流程说明
### 5.1 分层架构图
```text
┌─────────────────────────────────────┐
│ CLI 命令行接口 │ <- cli/main.py
└─────────────┬───────────────────────┘
┌─────────────▼───────────────────────┐
│ Orchestration 编排层 │ <- orchestration/
│ (Scheduler, TaskRegistry, ...) │
└─────────────┬───────────────────────┘
┌─────────────▼───────────────────────┐
│ Tasks 任务层 │ <- tasks/
│ (OrdersTask, PaymentsTask, ...) │
└───┬─────────┬─────────┬─────────────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌─────┐ ┌──────────┐
│Loaders │ │ SCD │ │ Quality │ <- loaders/, scd/, quality/
└────────┘ └─────┘ └──────────┘
┌───────▼────────┐
│ Models 模型 │ <- models/
└───────┬────────┘
┌───────▼────────┐
│ API 客户端 │ <- api/
└───────┬────────┘
┌───────▼────────┐
│ Database 访问 │ <- database/
└───────┬────────┘
┌───────▼────────┐
│ Config 配置 │ <- config/
└────────────────┘
```
### 5.2 各层职责(当前设计)
- **CLI 层 (`cli/`)**
- 解析命令行参数指定任务列表、Dry-run、覆盖配置项等
- 初始化配置与日志后交由编排层执行。
- **编排层 (`orchestration/`)**
- `scheduler.py`:根据配置与 CLI 参数选择需要执行的任务,控制执行顺序和并行策略。
- `task_registry.py`:提供任务注册表,按任务代码创建任务实例(工厂模式)。
- `cursor_manager.py`:管理增量游标(时间窗口 / ID 游标)。
- `run_tracker.py`:记录每次任务运行的状态、统计信息和错误信息。
- **任务层 (`tasks/`)**
- `base_task.py`:定义任务执行模板流程(模板方法模式),包括获取窗口、调用上游、解析 / 校验、写库、更新游标等。
- `orders_task.py` / `payments_task.py` / `members_task.py`:实现具体任务逻辑(订单、支付、会员)。
- **加载器 / SCD / 质量层**
- `loaders/`:根据目标表封装 Upsert / Insert / Update 逻辑。
- `scd/scd2_handler.py`:为维度表提供 SCD2 历史管理能力。
- `quality/`:执行数据质量检查,如余额对账。
- **模型层 (`models/`)**
- `parsers.py`:负责数据类型转换(字符串 → 时间戳、Decimal、int 等)。
- `validators.py`:执行字段级和记录级的数据校验。
- **API 层 (`api/client.py`)**
- 封装 HTTP 调用,处理重试、超时及分页。
- **数据库层 (`database/`)**
- 管理数据库连接及上下文。
- 提供批量插入 / 更新 / Upsert 操作接口。
- **配置层 (`config/`)**
- 定义配置项默认值。
- 解析环境变量并进行类型转换。
- 对外提供统一配置对象。
### 5.3 设计模式(当前使用)
- 工厂模式:任务注册 / 创建(`TaskRegistry`)。
- 模板方法模式:任务执行流程(`BaseTask`)。
- 策略模式:不同 Loader / Checker 实现不同策略。
- 依赖注入:通过构造函数向任务传入 `db`、`api`、`config` 等依赖。
### 5.4 数据与控制流程
整体流程:
1. CLI 解析参数并加载配置。
2. Scheduler 构建数据库连接、API 客户端等依赖。
3. Scheduler 遍历任务配置,从 `TaskRegistry` 获取任务类并实例化。
4. 每个任务按统一模板执行:
- 读取游标 / 时间窗口。
- 调用 API 拉取数据(可分页)。
- 解析、验证数据。
- 通过 Loader 写入数据库(事实表 / 维度表 / SCD2
- 执行质量检查。
- 更新游标与运行记录。
5. 所有任务执行完成后,释放连接并退出进程。
### 5.5 错误处理策略
- 单个任务失败不影响其他任务执行。
- 数据库操作异常自动回滚当前事务。
- API 请求失败时按配置进行重试,超过重试次数记录错误并终止该任务。
- 所有错误被记录到日志和运行追踪表,便于事后排查。
---
## 6. 迁移指南(从旧脚本到当前项目)
本节用于说明如何从旧的单文件脚本(如 `task_merged.py`)迁移到当前模块化项目,属于当前项目的使用说明,不涉及历史对比细节。
### 6.1 核心功能映射示意
| 旧版本函数 / 类 | 新版本位置 | 说明 |
|---------------------------|--------------------------------------------------------|----------------|
| `DEFAULTS` 字典 | `config/defaults.py` | 配置默认值 |
| `build_config()` | `config/settings.py::AppConfig.load()` | 配置加载 |
| `Pg` 类 | `database/connection.py::DatabaseConnection` | 数据库连接 |
| `http_get_json()` | `api/client.py::APIClient.get()` | API 请求 |
| `paged_get()` | `api/client.py::APIClient.get_paginated()` | 分页请求 |
| `parse_ts()` | `models/parsers.py::TypeParser.parse_timestamp()` | 时间解析 |
| `upsert_fact_order()` | `loaders/facts/order.py::OrderLoader.upsert_orders()` | 订单加载 |
| `scd2_upsert()` | `scd/scd2_handler.py::SCD2Handler.upsert()` | SCD2 处理 |
| `run_task_orders()` | `tasks/orders_task.py::OrdersTask.execute()` | 订单任务 |
| `main()` | `cli/main.py::main()` | 主入口 |
### 6.2 典型迁移步骤
1. **配置迁移**
- 原来在 `DEFAULTS` 或脚本内硬编码的配置,迁移到 `.env` 与 `config/defaults.py`。
- 使用 `AppConfig.load()` 统一获取配置。
2. **并行运行验证**
```bash
# 旧脚本
python task_merged.py --tasks ORDERS
# 新项目
python -m cli.main --tasks ORDERS
```
对比新旧版本导出的数据表和日志,确认一致性。
3. **自定义逻辑迁移**
- 原脚本中的自定义清洗逻辑 → 放入相应 `loaders/` 或任务类中。
- 自定义任务 → 在 `tasks/` 中实现并在 `task_registry` 中注册。
- 自定义 API 调用 → 扩展 `api/client.py` 或单独封装服务类。
4. **逐步切换**
- 先在测试环境并行运行。
- 再逐步切换生产任务到新版本。
---
## 7. 开发与扩展指南(当前项目)
### 7.1 添加新任务
1. 在 `tasks/` 目录创建任务类:
```python
from .base_task import BaseTask
class MyTask(BaseTask):
def get_task_code(self) -> str:
return "MY_TASK"
def execute(self) -> dict:
# 1. 获取时间窗口
window_start, window_end, _ = self._get_time_window()
# 2. 调用 API 获取数据
records, _ = self.api.get_paginated(...)
# 3. 解析 / 校验
parsed = [self._parse(r) for r in records]
# 4. 加载数据
loader = MyLoader(self.db)
inserted, updated, _ = loader.upsert(parsed)
# 5. 提交并返回结果
self.db.commit()
return self._build_result("SUCCESS", {
"inserted": inserted,
"updated": updated,
})
```
2. 在 `orchestration/task_registry.py` 中注册:
```python
from tasks.my_task import MyTask
default_registry.register("MY_TASK", MyTask)
```
3. 在任务配置表中启用(示例):
```sql
INSERT INTO etl_admin.etl_task (task_code, store_id, enabled)
VALUES ('MY_TASK', 123456, TRUE);
```
### 7.2 添加新加载器
```python
from loaders.base_loader import BaseLoader
class MyLoader(BaseLoader):
def upsert(self, records: list) -> tuple:
sql = "INSERT INTO table_name (...) VALUES (...) ON CONFLICT (...) DO UPDATE SET ... RETURNING (xmax = 0) AS inserted"
inserted, updated = self.db.batch_upsert_with_returning(
sql, records, page_size=self._batch_size()
)
return (inserted, updated, 0)
```
### 7.3 添加新质量检查器
1. 在 `quality/` 中实现检查器,继承 `base_checker.py`。
2. 在任务或调度流程中调用该检查器,在写库后进行验证。
### 7.4 类型解析与校验扩展
- 在 `models/parsers.py` 中添加新类型解析方法。
- 在 `models/validators.py` 中添加新规则(如枚举校验、跨字段校验等)。
---
## 8. 常见问题排查
### 8.1 数据库连接失败
```text
错误: could not connect to server
```
排查要点:
- 检查 `PG_DSN` 或相关数据库配置是否正确。
- 确认数据库服务是否启动、网络是否可达。
### 8.2 API 请求超时
```text
错误: requests.exceptions.Timeout
```
排查要点:
- 检查 `API_BASE` 地址与网络连通性。
- 适当提高超时与重试次数(在配置中调整)。
### 8.3 模块导入错误
```text
错误: ModuleNotFoundError
```
排查要点:
- 确认在项目根目录下运行(包含 `etl_billiards/` 包)。
- 或通过 `pip install -e .` 以可编辑模式安装项目。
### 8.4 权限相关问题
```text
错误: Permission denied
```
排查要点:
- 脚本无执行权限:`chmod +x run_etl.sh`。
- Windows 需要以管理员身份运行,或修改日志 / 导出目录权限。
---
## 9. 使用前检查清单
在正式运行前建议确认:
- [ ] 已安装 Python 3.10+。
- [ ] 已执行 `pip install -r requirements.txt`。
- [ ] `.env` 已配置正确数据库、API、门店 ID、路径等
- [ ] PostgreSQL 数据库可连接。
- [ ] API 服务可访问且凭证有效。
- [ ] `LOG_ROOT`、`EXPORT_ROOT` 目录存在且拥有写权限。
---
## 10. 参考说明
- 本文已合并原有的快速开始、项目结构、架构说明、迁移指南等内容,可作为当前项目的统一说明文档。
- 如需在此基础上拆分多份文档,可按章节拆出,例如「快速开始」「架构设计」「迁移指南」「开发扩展」等。

View File

View File

View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""API客户端"""
import requests
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter
class APIClient:
"""HTTP API客户端"""
def __init__(self, base_url: str, token: str = None, timeout: int = 20,
retry_max: int = 3, headers_extra: dict = None):
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout = timeout
self.retry_max = retry_max
self.headers_extra = headers_extra or {}
self._session = None
def _get_session(self):
"""获取或创建会话"""
if self._session is None:
self._session = requests.Session()
retries = max(0, int(self.retry_max) - 1)
retry = Retry(
total=None,
connect=retries,
read=retries,
status=retries,
allowed_methods=frozenset(["GET"]),
status_forcelist=(429, 500, 502, 503, 504),
backoff_factor=1.0,
respect_retry_after_header=True,
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
self._session.mount("http://", adapter)
self._session.mount("https://", adapter)
if self.headers_extra:
self._session.headers.update(self.headers_extra)
return self._session
def get(self, endpoint: str, params: dict = None) -> dict:
"""执行GET请求"""
url = f"{self.base_url}/{endpoint.lstrip('/')}"
headers = {"Authorization": self.token} if self.token else {}
headers.update(self.headers_extra)
sess = self._get_session()
resp = sess.get(url, headers=headers, params=params, timeout=self.timeout)
resp.raise_for_status()
return resp.json()
def get_paginated(self, endpoint: str, params: dict, page_size: int = 200,
page_field: str = "pageIndex", size_field: str = "pageSize",
data_path: tuple = ("data",), list_key: str = None) -> tuple:
"""分页获取数据"""
records, pages_meta = [], []
page = 1
while True:
p = dict(params)
p[page_field] = page
p[size_field] = page_size
obj = self.get(endpoint, p)
# 解析数据路径
cur = obj
for k in data_path:
if isinstance(cur, dict) and k in cur:
cur = cur[k]
if list_key:
cur = (cur or {}).get(list_key, [])
if not isinstance(cur, list):
cur = []
records.extend(cur)
if len(cur) == 0:
break
pages_meta.append({"page": page, "request": p, "response": obj})
if len(cur) < page_size:
break
page += 1
return records, pages_meta

View File

126
etl_billiards/cli/main.py Normal file
View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""CLI主入口"""
import sys
import argparse
import logging
from pathlib import Path
from config.settings import AppConfig
from orchestration.scheduler import ETLScheduler
def setup_logging():
"""设置日志"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
return logging.getLogger("etl_billiards")
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="台球场ETL系统")
# 基本参数
parser.add_argument("--store-id", type=int, help="门店ID")
parser.add_argument("--tasks", help="任务列表,逗号分隔")
parser.add_argument("--dry-run", action="store_true", help="试运行(不提交)")
# 数据库参数
parser.add_argument("--pg-dsn", help="PostgreSQL DSN")
parser.add_argument("--pg-host", help="PostgreSQL主机")
parser.add_argument("--pg-port", type=int, help="PostgreSQL端口")
parser.add_argument("--pg-name", help="PostgreSQL数据库名")
parser.add_argument("--pg-user", help="PostgreSQL用户名")
parser.add_argument("--pg-password", help="PostgreSQL密码")
# API参数
parser.add_argument("--api-base", help="API基础URL")
parser.add_argument("--api-token", help="API令牌")
parser.add_argument("--api-timeout", type=int, help="API超时(秒)")
parser.add_argument("--api-page-size", type=int, help="分页大小")
# 目录参数
parser.add_argument("--export-root", help="导出根目录")
parser.add_argument("--log-root", help="日志根目录")
return parser.parse_args()
def build_cli_overrides(args) -> dict:
"""从命令行参数构建配置覆盖"""
overrides = {}
# 基本信息
if args.store_id is not None:
overrides.setdefault("app", {})["store_id"] = args.store_id
# 数据库
if args.pg_dsn:
overrides.setdefault("db", {})["dsn"] = args.pg_dsn
if args.pg_host:
overrides.setdefault("db", {})["host"] = args.pg_host
if args.pg_port:
overrides.setdefault("db", {})["port"] = args.pg_port
if args.pg_name:
overrides.setdefault("db", {})["name"] = args.pg_name
if args.pg_user:
overrides.setdefault("db", {})["user"] = args.pg_user
if args.pg_password:
overrides.setdefault("db", {})["password"] = args.pg_password
# API
if args.api_base:
overrides.setdefault("api", {})["base_url"] = args.api_base
if args.api_token:
overrides.setdefault("api", {})["token"] = args.api_token
if args.api_timeout:
overrides.setdefault("api", {})["timeout_sec"] = args.api_timeout
if args.api_page_size:
overrides.setdefault("api", {})["page_size"] = args.api_page_size
# 目录
if args.export_root:
overrides.setdefault("io", {})["export_root"] = args.export_root
if args.log_root:
overrides.setdefault("io", {})["log_root"] = args.log_root
# 任务
if args.tasks:
tasks = [t.strip().upper() for t in args.tasks.split(",") if t.strip()]
overrides.setdefault("run", {})["tasks"] = tasks
return overrides
def main():
"""主函数"""
logger = setup_logging()
args = parse_args()
try:
# 加载配置
cli_overrides = build_cli_overrides(args)
config = AppConfig.load(cli_overrides)
logger.info("配置加载完成")
logger.info(f"门店ID: {config.get('app.store_id')}")
logger.info(f"任务列表: {config.get('run.tasks')}")
# 创建调度器
scheduler = ETLScheduler(config, logger)
# 运行任务
task_codes = config.get("run.tasks")
scheduler.run_tasks(task_codes)
# 关闭连接
scheduler.close()
logger.info("ETL运行完成")
return 0
except Exception as e:
logger.error(f"ETL运行失败: {e}", exc_info=True)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""配置默认值"""
DEFAULTS = {
"app": {
"timezone": "Asia/Taipei",
"store_id": "",
"schema_oltp": "billiards",
"schema_etl": "etl_admin",
},
"db": {
"dsn": "",
"host": "",
"port": "",
"name": "",
"user": "",
"password": "",
"connect_timeout_sec": 5,
"batch_size": 1000,
"session": {
"timezone": "Asia/Taipei",
"statement_timeout_ms": 30000,
"lock_timeout_ms": 5000,
"idle_in_tx_timeout_ms": 600000
},
},
"api": {
"base_url": None,
"token": None,
"timeout_sec": 20,
"page_size": 200,
"retries": {
"max_attempts": 3,
"backoff_sec": [1, 2, 4],
},
"headers_extra": {},
},
"run": {
"tasks": [
"PRODUCTS", "TABLES", "MEMBERS", "ASSISTANTS", "PACKAGES_DEF",
"ORDERS", "PAYMENTS", "REFUNDS", "COUPON_USAGE", "INVENTORY_CHANGE",
"TOPUPS", "TABLE_DISCOUNT", "ASSISTANT_ABOLISH",
"LEDGER",
],
"window_minutes": {
"default_busy": 30,
"default_idle": 180,
},
"overlap_seconds": 120,
"idle_window": {
"start": "04:00",
"end": "16:00",
},
"allow_empty_result_advance": True,
},
"io": {
"export_root": r"D:\LLZQ\DB\export",
"log_root": r"D:\LLZQ\DB\logs",
"manifest_name": "manifest.json",
"ingest_report_name": "ingest_report.json",
"write_pretty_json": False,
"max_file_bytes": 50 * 1024 * 1024,
},
"clean": {
"log_unknown_fields": True,
"unknown_fields_limit": 50,
"hash_key": {
"algo": "sha1",
"salt": "",
},
"strict_numeric": True,
"round_money_scale": 2,
},
"security": {
"redact_in_logs": True,
"redact_keys": ["token", "password", "Authorization"],
"echo_token_in_logs": False,
},
}
# 任务代码常量
TASK_ORDERS = "ORDERS"
TASK_PAYMENTS = "PAYMENTS"
TASK_REFUNDS = "REFUNDS"
TASK_INVENTORY_CHANGE = "INVENTORY_CHANGE"
TASK_COUPON_USAGE = "COUPON_USAGE"
TASK_MEMBERS = "MEMBERS"
TASK_ASSISTANTS = "ASSISTANTS"
TASK_PRODUCTS = "PRODUCTS"
TASK_TABLES = "TABLES"
TASK_PACKAGES_DEF = "PACKAGES_DEF"
TASK_TOPUPS = "TOPUPS"
TASK_TABLE_DISCOUNT = "TABLE_DISCOUNT"
TASK_ASSISTANT_ABOLISH = "ASSISTANT_ABOLISH"
TASK_LEDGER = "LEDGER"

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""环境变量解析"""
import os
import json
from copy import deepcopy
ENV_MAP = {
"TIMEZONE": ("app.timezone",),
"STORE_ID": ("app.store_id",),
"SCHEMA_OLTP": ("app.schema_oltp",),
"SCHEMA_ETL": ("app.schema_etl",),
"PG_DSN": ("db.dsn",),
"PG_HOST": ("db.host",),
"PG_PORT": ("db.port",),
"PG_NAME": ("db.name",),
"PG_USER": ("db.user",),
"PG_PASSWORD": ("db.password",),
"API_BASE": ("api.base_url",),
"API_TOKEN": ("api.token",),
"API_TIMEOUT": ("api.timeout_sec",),
"API_PAGE_SIZE": ("api.page_size",),
"EXPORT_ROOT": ("io.export_root",),
"LOG_ROOT": ("io.log_root",),
"OVERLAP_SECONDS": ("run.overlap_seconds",),
"WINDOW_BUSY_MIN": ("run.window_minutes.default_busy",),
"WINDOW_IDLE_MIN": ("run.window_minutes.default_idle",),
}
def _deep_set(d, dotted_keys, value):
cur = d
for k in dotted_keys[:-1]:
cur = cur.setdefault(k, {})
cur[dotted_keys[-1]] = value
def _coerce_env(v: str):
if v is None:
return None
s = v.strip()
if s.lower() in ("true", "false"):
return s.lower() == "true"
try:
if s.isdigit() or (s.startswith("-") and s[1:].isdigit()):
return int(s)
except Exception:
pass
if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
try:
return json.loads(s)
except Exception:
return s
return s
def load_env_overrides(defaults: dict) -> dict:
cfg = deepcopy(defaults)
for env_key, dotted in ENV_MAP.items():
val = os.environ.get(env_key)
if val is None:
continue
v2 = _coerce_env(val)
for path in dotted:
_deep_set(cfg, path.split("."), v2)
return cfg

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""配置管理主类"""
from copy import deepcopy
from .defaults import DEFAULTS
from .env_parser import load_env_overrides
class AppConfig:
"""应用配置管理器"""
def __init__(self, config_dict: dict):
self.config = config_dict
@classmethod
def load(cls, cli_overrides: dict = None):
"""加载配置: DEFAULTS < ENV < CLI"""
cfg = load_env_overrides(DEFAULTS)
if cli_overrides:
cls._deep_merge(cfg, cli_overrides)
# 规范化
cls._normalize(cfg)
cls._validate(cfg)
return cls(cfg)
@staticmethod
def _deep_merge(dst, src):
"""深度合并字典"""
for k, v in src.items():
if isinstance(v, dict) and isinstance(dst.get(k), dict):
AppConfig._deep_merge(dst[k], v)
else:
dst[k] = v
@staticmethod
def _normalize(cfg):
"""规范化配置"""
# 转换 store_id 为整数
try:
cfg["app"]["store_id"] = int(str(cfg["app"]["store_id"]).strip())
except Exception:
raise SystemExit("app.store_id 必须为整数")
# DSN 组装
if not cfg["db"]["dsn"]:
cfg["db"]["dsn"] = (
f"postgresql://{cfg['db']['user']}:{cfg['db']['password']}"
f"@{cfg['db']['host']}:{cfg['db']['port']}/{cfg['db']['name']}"
)
# 会话参数
cfg["db"].setdefault("session", {})
sess = cfg["db"]["session"]
sess.setdefault("timezone", cfg["app"]["timezone"])
for k in ("statement_timeout_ms", "lock_timeout_ms", "idle_in_tx_timeout_ms"):
if k in sess and sess[k] is not None:
try:
sess[k] = int(sess[k])
except Exception:
raise SystemExit(f"db.session.{k} 需为整数毫秒")
@staticmethod
def _validate(cfg):
"""验证必填配置"""
missing = []
if not cfg["app"]["store_id"]:
missing.append("app.store_id")
if missing:
raise SystemExit("缺少必需配置: " + ", ".join(missing))
def get(self, key: str, default=None):
"""获取配置值(支持点号路径)"""
keys = key.split(".")
val = self.config
for k in keys:
if isinstance(val, dict):
val = val.get(k)
else:
return default
return val if val is not None else default
def __getitem__(self, key):
return self.config[key]

View File

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""
数据库操作批量、RETURNING支持
"""
import re
from typing import List, Dict, Tuple
import psycopg2.extras
from .connection import DatabaseConnection
class DatabaseOperations(DatabaseConnection):
"""扩展数据库操作包含批量upsert和returning支持"""
def batch_execute(self, sql: str, rows: List[Dict], page_size: int = 1000):
"""批量执行SQL不带RETURNING"""
if not rows:
return
with self.conn.cursor() as c:
psycopg2.extras.execute_batch(c, sql, rows, page_size=page_size)
def batch_upsert_with_returning(self, sql: str, rows: List[Dict], page_size: int = 1000) -> Tuple[int, int]:
"""
批量 UPSERT 并统计插入/更新数
Args:
sql: 包含RETURNING子句的SQL
rows: 数据行列表
page_size: 批次大小
Returns:
(inserted_count, updated_count) 元组
"""
if not rows:
return (0, 0)
use_returning = "RETURNING" in sql.upper()
with self.conn.cursor() as c:
if not use_returning:
psycopg2.extras.execute_batch(c, sql, rows, page_size=page_size)
return (0, 0)
# 优先尝试向量化执行
try:
inserted, updated = self._execute_with_returning_vectorized(c, sql, rows, page_size)
return (inserted, updated)
except Exception:
# 回退到逐行执行
return self._execute_with_returning_row_by_row(c, sql, rows)
def _execute_with_returning_vectorized(self, cursor, sql: str, rows: List[Dict], page_size: int) -> Tuple[int, int]:
"""向量化执行使用execute_values"""
# 解析VALUES子句
m = re.search(r"VALUES\s*\((.*?)\)", sql, flags=re.IGNORECASE | re.DOTALL)
if not m:
raise ValueError("Cannot parse VALUES clause")
tpl = "(" + m.group(1) + ")"
base_sql = sql[:m.start()] + "VALUES %s" + sql[m.end():]
ret = psycopg2.extras.execute_values(
cursor, base_sql, rows, template=tpl, page_size=page_size, fetch=True
)
if not ret:
return (0, 0)
inserted = 0
for rec in ret:
flag = self._extract_inserted_flag(rec)
if flag:
inserted += 1
return (inserted, len(ret) - inserted)
def _execute_with_returning_row_by_row(self, cursor, sql: str, rows: List[Dict]) -> Tuple[int, int]:
"""逐行执行(回退方案)"""
inserted = 0
updated = 0
for r in rows:
cursor.execute(sql, r)
try:
rec = cursor.fetchone()
except Exception:
rec = None
flag = self._extract_inserted_flag(rec) if rec else None
if flag:
inserted += 1
else:
updated += 1
return (inserted, updated)
@staticmethod
def _extract_inserted_flag(rec) -> bool:
"""从返回记录中提取inserted标志"""
if isinstance(rec, tuple):
return bool(rec[0])
elif isinstance(rec, dict):
return bool(rec.get("inserted"))
else:
try:
return bool(rec["inserted"])
except Exception:
return False
# 为了向后兼容提供Pg别名
Pg = DatabaseOperations

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""数据库连接管理"""
import psycopg2
import psycopg2.extras
class DatabaseConnection:
"""数据库连接管理器"""
def __init__(self, dsn: str, session: dict = None, connect_timeout: int = None):
self.conn = psycopg2.connect(dsn, connect_timeout=(connect_timeout or 5))
self.conn.autocommit = False
# 设置会话参数
if session:
with self.conn.cursor() as c:
if session.get("timezone"):
c.execute("SET TIME ZONE %s", (session["timezone"],))
if session.get("statement_timeout_ms") is not None:
c.execute("SET statement_timeout = %s", (int(session["statement_timeout_ms"]),))
if session.get("lock_timeout_ms") is not None:
c.execute("SET lock_timeout = %s", (int(session["lock_timeout_ms"]),))
if session.get("idle_in_tx_timeout_ms") is not None:
c.execute("SET idle_in_transaction_session_timeout = %s",
(int(session["idle_in_tx_timeout_ms"]),))
def query(self, sql: str, args=None):
"""执行查询并返回结果"""
with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as c:
c.execute(sql, args)
return c.fetchall()
def execute(self, sql: str, args=None):
"""执行SQL语句"""
with self.conn.cursor() as c:
c.execute(sql, args)
def commit(self):
"""提交事务"""
self.conn.commit()
def rollback(self):
"""回滚事务"""
self.conn.rollback()
def close(self):
"""关闭连接"""
try:
self.conn.close()
except Exception:
pass

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""数据库批量操作"""
import psycopg2.extras
import re
class DatabaseOperations:
"""数据库批量操作封装"""
def __init__(self, connection):
self.conn = connection.conn
def batch_execute(self, sql: str, rows: list, page_size: int = 1000):
"""批量执行SQL"""
if not rows:
return
with self.conn.cursor() as c:
psycopg2.extras.execute_batch(c, sql, rows, page_size=page_size)
def batch_upsert_with_returning(self, sql: str, rows: list,
page_size: int = 1000) -> tuple:
"""批量UPSERT并返回插入/更新计数"""
if not rows:
return (0, 0)
use_returning = "RETURNING" in sql.upper()
with self.conn.cursor() as c:
if not use_returning:
psycopg2.extras.execute_batch(c, sql, rows, page_size=page_size)
return (0, 0)
# 尝试向量化执行
try:
m = re.search(r"VALUES\s*\((.*?)\)", sql, flags=re.IGNORECASE | re.DOTALL)
if m:
tpl = "(" + m.group(1) + ")"
base_sql = sql[:m.start()] + "VALUES %s" + sql[m.end():]
ret = psycopg2.extras.execute_values(
c, base_sql, rows, template=tpl, page_size=page_size, fetch=True
)
if not ret:
return (0, 0)
inserted = sum(1 for rec in ret if self._is_inserted(rec))
return (inserted, len(ret) - inserted)
except Exception:
pass
# 回退:逐行执行
inserted = 0
updated = 0
for r in rows:
c.execute(sql, r)
try:
rec = c.fetchone()
except Exception:
rec = None
if self._is_inserted(rec):
inserted += 1
else:
updated += 1
return (inserted, updated)
@staticmethod
def _is_inserted(rec) -> bool:
"""判断是否为插入操作"""
if rec is None:
return False
if isinstance(rec, tuple):
return bool(rec[0])
if isinstance(rec, dict):
return bool(rec.get("inserted"))
return False

View File

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""数据加载器基类"""
class BaseLoader:
"""数据加载器基类"""
def __init__(self, db_ops):
self.db = db_ops
def upsert(self, records: list) -> tuple:
"""
执行UPSERT操作
返回: (inserted_count, updated_count, skipped_count)
"""
raise NotImplementedError("子类需实现 upsert 方法")
def _batch_size(self) -> int:
"""批次大小"""
return 1000

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""会员维度表加载器"""
from ..base_loader import BaseLoader
class MemberLoader(BaseLoader):
"""会员维度加载器"""
def upsert_members(self, records: list, store_id: int) -> tuple:
"""加载会员数据"""
if not records:
return (0, 0, 0)
sql = """
INSERT INTO billiards.dim_member (
store_id, member_id, member_name, phone, balance,
status, register_time, raw_data
)
VALUES (
%(store_id)s, %(member_id)s, %(member_name)s, %(phone)s, %(balance)s,
%(status)s, %(register_time)s, %(raw_data)s
)
ON CONFLICT (store_id, member_id) DO UPDATE SET
member_name = EXCLUDED.member_name,
phone = EXCLUDED.phone,
balance = EXCLUDED.balance,
status = EXCLUDED.status,
register_time = EXCLUDED.register_time,
raw_data = EXCLUDED.raw_data,
updated_at = now()
RETURNING (xmax = 0) AS inserted
"""
inserted, updated = self.db.batch_upsert_with_returning(sql, records, page_size=self._batch_size())
return (inserted, updated, 0)

View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
"""商品维度 + 价格SCD2 加载器"""
from ..base_loader import BaseLoader
from scd.scd2_handler import SCD2Handler
class ProductLoader(BaseLoader):
"""商品维度加载器dim_product + dim_product_price_scd"""
def __init__(self, db_ops):
super().__init__(db_ops)
# SCD2 处理器,复用通用逻辑
self.scd_handler = SCD2Handler(db_ops)
def upsert_products(self, records: list, store_id: int) -> tuple:
"""
加载商品维度及价格SCD
返回: (inserted_count, updated_count, skipped_count)
"""
if not records:
return (0, 0, 0)
# 1) 维度主表billiards.dim_product
sql_base = """
INSERT INTO billiards.dim_product (
store_id,
product_id,
site_product_id,
product_name,
category_id,
category_name,
second_category_id,
unit,
cost_price,
sale_price,
allow_discount,
status,
supplier_id,
barcode,
is_combo,
created_time,
updated_time,
raw_data
)
VALUES (
%(store_id)s,
%(product_id)s,
%(site_product_id)s,
%(product_name)s,
%(category_id)s,
%(category_name)s,
%(second_category_id)s,
%(unit)s,
%(cost_price)s,
%(sale_price)s,
%(allow_discount)s,
%(status)s,
%(supplier_id)s,
%(barcode)s,
%(is_combo)s,
%(created_time)s,
%(updated_time)s,
%(raw_data)s
)
ON CONFLICT (store_id, product_id) DO UPDATE SET
site_product_id = EXCLUDED.site_product_id,
product_name = EXCLUDED.product_name,
category_id = EXCLUDED.category_id,
category_name = EXCLUDED.category_name,
second_category_id = EXCLUDED.second_category_id,
unit = EXCLUDED.unit,
cost_price = EXCLUDED.cost_price,
sale_price = EXCLUDED.sale_price,
allow_discount = EXCLUDED.allow_discount,
status = EXCLUDED.status,
supplier_id = EXCLUDED.supplier_id,
barcode = EXCLUDED.barcode,
is_combo = EXCLUDED.is_combo,
updated_time = COALESCE(EXCLUDED.updated_time, now()),
raw_data = EXCLUDED.raw_data
RETURNING (xmax = 0) AS inserted
"""
inserted, updated = self.db.batch_upsert_with_returning(
sql_base,
records,
page_size=self._batch_size(),
)
# 2) 价格 SCD2billiards.dim_product_price_scd
# 只追踪 price + 类目 + 名称等字段的历史
tracked_fields = [
"product_name",
"category_id",
"category_name",
"second_category_id",
"cost_price",
"sale_price",
"allow_discount",
"status",
]
natural_key = ["store_id", "product_id"]
for rec in records:
effective_date = rec.get("updated_time") or rec.get("created_time")
scd_record = {
"store_id": rec["store_id"],
"product_id": rec["product_id"],
"product_name": rec.get("product_name"),
"category_id": rec.get("category_id"),
"category_name": rec.get("category_name"),
"second_category_id": rec.get("second_category_id"),
"cost_price": rec.get("cost_price"),
"sale_price": rec.get("sale_price"),
"allow_discount": rec.get("allow_discount"),
"status": rec.get("status"),
# 原表中有 raw_data jsonb 字段,这里直接复用 task 传入的 raw_data
"raw_data": rec.get("raw_data"),
}
# 这里我们不强行区分 INSERT/UPDATE/SKIP对 ETL 统计来说意义不大
self.scd_handler.upsert(
table_name="billiards.dim_product_price_scd",
natural_key=natural_key,
tracked_fields=tracked_fields,
record=scd_record,
effective_date=effective_date,
)
# skipped_count 统一按 0 返回(真正被丢弃的记录在 Task 端已经过滤)
return (inserted, updated, 0)

View File

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""订单事实表加载器"""
from ..base_loader import BaseLoader
class OrderLoader(BaseLoader):
"""订单数据加载器"""
def upsert_orders(self, records: list, store_id: int) -> tuple:
"""加载订单数据"""
if not records:
return (0, 0, 0)
sql = """
INSERT INTO billiards.fact_order (
store_id, order_id, order_no, member_id, table_id,
order_time, end_time, total_amount, discount_amount,
final_amount, pay_status, order_status, remark, raw_data
)
VALUES (
%(store_id)s, %(order_id)s, %(order_no)s, %(member_id)s, %(table_id)s,
%(order_time)s, %(end_time)s, %(total_amount)s, %(discount_amount)s,
%(final_amount)s, %(pay_status)s, %(order_status)s, %(remark)s, %(raw_data)s
)
ON CONFLICT (store_id, order_id) DO UPDATE SET
order_no = EXCLUDED.order_no,
member_id = EXCLUDED.member_id,
table_id = EXCLUDED.table_id,
order_time = EXCLUDED.order_time,
end_time = EXCLUDED.end_time,
total_amount = EXCLUDED.total_amount,
discount_amount = EXCLUDED.discount_amount,
final_amount = EXCLUDED.final_amount,
pay_status = EXCLUDED.pay_status,
order_status = EXCLUDED.order_status,
remark = EXCLUDED.remark,
raw_data = EXCLUDED.raw_data,
updated_at = now()
RETURNING (xmax = 0) AS inserted
"""
inserted, updated = self.db.batch_upsert_with_returning(sql, records, page_size=self._batch_size())
return (inserted, updated, 0)

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""支付记录加载器"""
from ..base_loader import BaseLoader
class PaymentLoader(BaseLoader):
"""支付记录加载器"""
def upsert_payments(self, records: list, store_id: int) -> tuple:
"""加载支付记录"""
if not records:
return (0, 0, 0)
sql = """
INSERT INTO billiards.fact_payment (
store_id, pay_id, order_id, pay_time, pay_amount,
pay_type, pay_status, remark, raw_data
)
VALUES (
%(store_id)s, %(pay_id)s, %(order_id)s, %(pay_time)s, %(pay_amount)s,
%(pay_type)s, %(pay_status)s, %(remark)s, %(raw_data)s
)
ON CONFLICT (store_id, pay_id) DO UPDATE SET
order_id = EXCLUDED.order_id,
pay_time = EXCLUDED.pay_time,
pay_amount = EXCLUDED.pay_amount,
pay_type = EXCLUDED.pay_type,
pay_status = EXCLUDED.pay_status,
remark = EXCLUDED.remark,
raw_data = EXCLUDED.raw_data,
updated_at = now()
RETURNING (xmax = 0) AS inserted
"""
inserted, updated = self.db.batch_upsert_with_returning(sql, records, page_size=self._batch_size())
return (inserted, updated, 0)

View File

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""数据类型解析器"""
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from dateutil import parser as dtparser
from zoneinfo import ZoneInfo
class TypeParser:
"""类型解析工具"""
@staticmethod
def parse_timestamp(s: str, tz: ZoneInfo) -> datetime | None:
"""解析时间戳"""
if not s:
return None
try:
dt = dtparser.parse(s)
if dt.tzinfo is None:
return dt.replace(tzinfo=tz)
return dt.astimezone(tz)
except Exception:
return None
@staticmethod
def parse_decimal(value, scale: int = 2) -> Decimal | None:
"""解析金额"""
if value is None:
return None
try:
d = Decimal(str(value))
return d.quantize(Decimal(10) ** -scale, rounding=ROUND_HALF_UP)
except Exception:
return None
@staticmethod
def parse_int(value) -> int | None:
"""解析整数"""
if value is None:
return None
try:
return int(value)
except Exception:
return None
@staticmethod
def format_timestamp(dt: datetime | None, tz: ZoneInfo) -> str | None:
"""格式化时间戳"""
if not dt:
return None
return dt.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S")

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""数据验证器"""
from decimal import Decimal
class DataValidator:
"""数据验证工具"""
@staticmethod
def validate_positive_amount(value: Decimal | None, field_name: str = "amount"):
"""验证金额为正数"""
if value is not None and value < 0:
raise ValueError(f"{field_name} 不能为负数: {value}")
@staticmethod
def validate_required(value, field_name: str):
"""验证必填字段"""
if value is None or value == "":
raise ValueError(f"{field_name} 是必填字段")
@staticmethod
def validate_range(value, min_val, max_val, field_name: str):
"""验证值范围"""
if value is not None:
if value < min_val or value > max_val:
raise ValueError(f"{field_name} 必须在 {min_val}{max_val} 之间")

View File

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""游标管理器"""
from datetime import datetime
class CursorManager:
"""ETL游标管理"""
def __init__(self, db_connection):
self.db = db_connection
def get_or_create(self, task_id: int, store_id: int) -> dict:
"""获取或创建游标"""
rows = self.db.query(
"SELECT * FROM etl_admin.etl_cursor WHERE task_id=%s AND store_id=%s",
(task_id, store_id)
)
if rows:
return rows[0]
# 创建新游标
self.db.execute(
"""
INSERT INTO etl_admin.etl_cursor(task_id, store_id, last_start, last_end, last_id, extra)
VALUES(%s, %s, NULL, NULL, NULL, '{}'::jsonb)
""",
(task_id, store_id)
)
self.db.commit()
rows = self.db.query(
"SELECT * FROM etl_admin.etl_cursor WHERE task_id=%s AND store_id=%s",
(task_id, store_id)
)
return rows[0] if rows else None
def advance(self, task_id: int, store_id: int, window_start: datetime,
window_end: datetime, run_id: int, last_id: int = None):
"""推进游标"""
if last_id is not None:
sql = """
UPDATE etl_admin.etl_cursor
SET last_start = %s,
last_end = %s,
last_id = GREATEST(COALESCE(last_id, 0), %s),
last_run_id = %s,
updated_at = now()
WHERE task_id = %s AND store_id = %s
"""
self.db.execute(sql, (window_start, window_end, last_id, run_id, task_id, store_id))
else:
sql = """
UPDATE etl_admin.etl_cursor
SET last_start = %s,
last_end = %s,
last_run_id = %s,
updated_at = now()
WHERE task_id = %s AND store_id = %s
"""
self.db.execute(sql, (window_start, window_end, run_id, task_id, store_id))
self.db.commit()

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""运行记录追踪器"""
import json
from datetime import datetime
class RunTracker:
"""ETL运行记录管理"""
def __init__(self, db_connection):
self.db = db_connection
def create_run(self, task_id: int, store_id: int, run_uuid: str,
export_dir: str, log_path: str, status: str,
window_start: datetime = None, window_end: datetime = None,
window_minutes: int = None, overlap_seconds: int = None,
request_params: dict = None) -> int:
"""创建运行记录"""
sql = """
INSERT INTO etl_admin.etl_run(
run_uuid, task_id, store_id, status, started_at, window_start, window_end,
window_minutes, overlap_seconds, fetched_count, loaded_count, updated_count,
skipped_count, error_count, unknown_fields, export_dir, log_path,
request_params, manifest, error_message, extra
) VALUES (
%s, %s, %s, %s, now(), %s, %s, %s, %s, 0, 0, 0, 0, 0, 0, %s, %s, %s,
'{}'::jsonb, NULL, '{}'::jsonb
)
RETURNING run_id
"""
result = self.db.query(
sql,
(run_uuid, task_id, store_id, status, window_start, window_end,
window_minutes, overlap_seconds, export_dir, log_path,
json.dumps(request_params or {}, ensure_ascii=False))
)
run_id = result[0]["run_id"]
self.db.commit()
return run_id
def update_run(self, run_id: int, counts: dict, status: str,
ended_at: datetime = None, manifest: dict = None,
error_message: str = None):
"""更新运行记录"""
sql = """
UPDATE etl_admin.etl_run
SET fetched_count = %s,
loaded_count = %s,
updated_count = %s,
skipped_count = %s,
error_count = %s,
unknown_fields = %s,
status = %s,
ended_at = %s,
manifest = %s,
error_message = %s
WHERE run_id = %s
"""
self.db.execute(
sql,
(counts.get("fetched", 0), counts.get("inserted", 0),
counts.get("updated", 0), counts.get("skipped", 0),
counts.get("errors", 0), counts.get("unknown_fields", 0),
status, ended_at,
json.dumps(manifest or {}, ensure_ascii=False),
error_message, run_id)
)
self.db.commit()

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""ETL调度器"""
import uuid
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from database.connection import DatabaseConnection
from database.operations import DatabaseOperations
from api.client import APIClient
from orchestration.cursor_manager import CursorManager
from orchestration.run_tracker import RunTracker
from orchestration.task_registry import default_registry
class ETLScheduler:
"""ETL任务调度器"""
def __init__(self, config, logger):
self.config = config
self.logger = logger
self.tz = ZoneInfo(config.get("app.timezone", "Asia/Taipei"))
# 初始化组件
self.db_conn = DatabaseConnection(
dsn=config["db"]["dsn"],
session=config["db"].get("session"),
connect_timeout=config["db"].get("connect_timeout_sec")
)
self.db_ops = DatabaseOperations(self.db_conn)
self.api_client = APIClient(
base_url=config["api"]["base_url"],
token=config["api"]["token"],
timeout=config["api"]["timeout_sec"],
retry_max=config["api"]["retries"]["max_attempts"],
headers_extra=config["api"].get("headers_extra")
)
self.cursor_mgr = CursorManager(self.db_conn)
self.run_tracker = RunTracker(self.db_conn)
self.task_registry = default_registry
def run_tasks(self, task_codes: list = None):
"""运行任务列表"""
run_uuid = uuid.uuid4().hex
store_id = self.config.get("app.store_id")
if not task_codes:
task_codes = self.config.get("run.tasks", [])
self.logger.info(f"开始运行任务: {task_codes}, run_uuid={run_uuid}")
for task_code in task_codes:
try:
self._run_single_task(task_code, run_uuid, store_id)
except Exception as e:
self.logger.error(f"任务 {task_code} 失败: {e}", exc_info=True)
continue
self.logger.info("所有任务执行完成")
def _run_single_task(self, task_code: str, run_uuid: str, store_id: int):
"""运行单个任务"""
# 创建任务实例
task = self.task_registry.create_task(
task_code, self.config, self.db_ops, self.api_client, self.logger
)
# 获取任务配置(从数据库)
task_cfg = self._load_task_config(task_code, store_id)
if not task_cfg:
self.logger.warning(f"任务 {task_code} 未启用或不存在")
return
task_id = task_cfg["task_id"]
# 创建运行记录
export_dir = Path(self.config["io"]["export_root"]) / datetime.now(self.tz).strftime("%Y%m%d")
log_path = str(Path(self.config["io"]["log_root"]) / f"{run_uuid}.log")
run_id = self.run_tracker.create_run(
task_id=task_id,
store_id=store_id,
run_uuid=run_uuid,
export_dir=str(export_dir),
log_path=log_path,
status="RUNNING"
)
# 执行任务
try:
result = task.execute()
# 更新运行记录
self.run_tracker.update_run(
run_id=run_id,
counts=result["counts"],
status=result["status"],
ended_at=datetime.now(self.tz)
)
# 推进游标
if result["status"] == "SUCCESS":
# TODO: 从任务结果中获取窗口信息
pass
except Exception as e:
self.run_tracker.update_run(
run_id=run_id,
counts={},
status="FAIL",
ended_at=datetime.now(self.tz),
error_message=str(e)
)
raise
def _load_task_config(self, task_code: str, store_id: int) -> dict:
"""从数据库加载任务配置"""
sql = """
SELECT task_id, task_code, store_id, enabled, cursor_field,
window_minutes_default, overlap_seconds, page_size, retry_max, params
FROM etl_admin.etl_task
WHERE store_id = %s AND task_code = %s AND enabled = TRUE
"""
rows = self.db_conn.query(sql, (store_id, task_code))
return rows[0] if rows else None
def close(self):
"""关闭连接"""
self.db_conn.close()

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""任务注册表"""
from tasks.orders_task import OrdersTask
from tasks.payments_task import PaymentsTask
from tasks.members_task import MembersTask
class TaskRegistry:
"""任务注册和工厂"""
def __init__(self):
self._tasks = {}
def register(self, task_code: str, task_class):
"""注册任务类"""
self._tasks[task_code.upper()] = task_class
def create_task(self, task_code: str, config, db_connection, api_client, logger):
"""创建任务实例"""
task_code = task_code.upper()
if task_code not in self._tasks:
raise ValueError(f"未知的任务类型: {task_code}")
task_class = self._tasks[task_code]
return task_class(config, db_connection, api_client, logger)
def get_all_task_codes(self) -> list:
"""获取所有已注册的任务代码"""
return list(self._tasks.keys())
# 默认注册表
default_registry = TaskRegistry()
default_registry.register("ORDERS", OrdersTask)
default_registry.register("PAYMENTS", PaymentsTask)
default_registry.register("MEMBERS", MembersTask)
# 可以继续注册其他任务...

View File

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""余额一致性检查器"""
from .base_checker import BaseDataQualityChecker
class BalanceChecker(BaseDataQualityChecker):
"""检查订单、支付、退款的金额一致性"""
def check(self, store_id: int, start_date: str, end_date: str) -> dict:
"""
检查指定时间范围内的余额一致性
验证: 订单总额 = 支付总额 - 退款总额
"""
checks = []
# 查询订单总额
sql_orders = """
SELECT COALESCE(SUM(final_amount), 0) AS total
FROM billiards.fact_order
WHERE store_id = %s
AND order_time >= %s
AND order_time < %s
AND order_status = 'COMPLETED'
"""
order_total = self.db.query(sql_orders, (store_id, start_date, end_date))[0]["total"]
# 查询支付总额
sql_payments = """
SELECT COALESCE(SUM(pay_amount), 0) AS total
FROM billiards.fact_payment
WHERE store_id = %s
AND pay_time >= %s
AND pay_time < %s
AND pay_status = 'SUCCESS'
"""
payment_total = self.db.query(sql_payments, (store_id, start_date, end_date))[0]["total"]
# 查询退款总额
sql_refunds = """
SELECT COALESCE(SUM(refund_amount), 0) AS total
FROM billiards.fact_refund
WHERE store_id = %s
AND refund_time >= %s
AND refund_time < %s
AND refund_status = 'SUCCESS'
"""
refund_total = self.db.query(sql_refunds, (store_id, start_date, end_date))[0]["total"]
# 验证余额
expected_total = payment_total - refund_total
diff = abs(float(order_total) - float(expected_total))
threshold = 0.01 # 1分钱的容差
passed = diff < threshold
checks.append({
"name": "balance_consistency",
"passed": passed,
"message": f"订单总额: {order_total}, 支付-退款: {expected_total}, 差异: {diff}",
"details": {
"order_total": float(order_total),
"payment_total": float(payment_total),
"refund_total": float(refund_total),
"diff": diff
}
})
all_passed = all(c["passed"] for c in checks)
return {
"passed": all_passed,
"checks": checks
}

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""数据质量检查器基类"""
class BaseDataQualityChecker:
"""数据质量检查器基类"""
def __init__(self, db_connection, logger):
self.db = db_connection
self.logger = logger
def check(self) -> dict:
"""
执行质量检查
返回: {
"passed": bool,
"checks": [{"name": str, "passed": bool, "message": str}]
}
"""
raise NotImplementedError("子类需实现 check 方法")

View File

@@ -0,0 +1,5 @@
# Python依赖包
psycopg2-binary>=2.9.0
requests>=2.28.0
python-dateutil>=2.8.0
tzdata>=2023.0

View File

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""SCD2 (Slowly Changing Dimension Type 2) 处理器"""
from datetime import datetime
class SCD2Handler:
"""SCD2历史记录处理器"""
def __init__(self, db_ops):
self.db = db_ops
def upsert(self, table_name: str, natural_key: list, tracked_fields: list,
record: dict, effective_date: datetime = None) -> str:
"""
处理SCD2更新
Args:
table_name: 表名
natural_key: 自然键字段列表
tracked_fields: 需要跟踪变化的字段列表
record: 记录数据
effective_date: 生效日期
Returns:
操作类型: 'INSERT', 'UPDATE', 'UNCHANGED'
"""
effective_date = effective_date or datetime.now()
# 查找当前有效记录
where_clause = " AND ".join([f"{k} = %({k})s" for k in natural_key])
sql_select = f"""
SELECT * FROM {table_name}
WHERE {where_clause}
AND valid_to IS NULL
"""
# 使用 db 的 connection
current = self.db.conn.cursor()
current.execute(sql_select, record)
existing = current.fetchone()
if not existing:
# 新记录:直接插入
record["valid_from"] = effective_date
record["valid_to"] = None
record["is_current"] = True
fields = list(record.keys())
placeholders = ", ".join([f"%({f})s" for f in fields])
sql_insert = f"""
INSERT INTO {table_name} ({', '.join(fields)})
VALUES ({placeholders})
"""
current.execute(sql_insert, record)
return 'INSERT'
# 检查是否有变化
has_changes = any(
existing.get(field) != record.get(field)
for field in tracked_fields
)
if not has_changes:
return 'UNCHANGED'
# 有变化:关闭旧记录,插入新记录
update_where = " AND ".join([f"{k} = %({k})s" for k in natural_key])
sql_close = f"""
UPDATE {table_name}
SET valid_to = %(effective_date)s,
is_current = FALSE
WHERE {update_where}
AND valid_to IS NULL
"""
record["effective_date"] = effective_date
current.execute(sql_close, record)
# 插入新记录
record["valid_from"] = effective_date
record["valid_to"] = None
record["is_current"] = True
fields = list(record.keys())
if "effective_date" in fields:
fields.remove("effective_date")
placeholders = ", ".join([f"%({f})s" for f in fields])
sql_insert = f"""
INSERT INTO {table_name} ({', '.join(fields)})
VALUES ({placeholders})
"""
current.execute(sql_insert, record)
return 'UPDATE'

30
etl_billiards/setup.py Normal file
View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""
Setup script for ETL Billiards
"""
from setuptools import setup, find_packages
with open("requirements.txt") as f:
requirements = f.read().splitlines()
setup(
name="etl-billiards",
version="2.0.0",
description="Modular ETL system for billiards business data",
author="Data Platform Team",
author_email="data-platform@example.com",
packages=find_packages(),
install_requires=requirements,
python_requires=">=3.10",
entry_points={
"console_scripts": [
"etl-billiards=cli.main:main",
],
},
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
)

View File

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""ETL任务基类"""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
class BaseTask:
"""ETL任务基类"""
def __init__(self, config, db_connection, api_client, logger):
self.config = config
self.db = db_connection
self.api = api_client
self.logger = logger
self.tz = ZoneInfo(config.get("app.timezone", "Asia/Taipei"))
def get_task_code(self) -> str:
"""获取任务代码"""
raise NotImplementedError("子类需实现 get_task_code 方法")
def execute(self) -> dict:
"""执行任务"""
raise NotImplementedError("子类需实现 execute 方法")
def _get_time_window(self, cursor_data: dict = None) -> tuple:
"""计算时间窗口"""
now = datetime.now(self.tz)
# 判断是否在闲时窗口
idle_start = self.config.get("run.idle_window.start", "04:00")
idle_end = self.config.get("run.idle_window.end", "16:00")
is_idle = self._is_in_idle_window(now, idle_start, idle_end)
# 获取窗口大小
if is_idle:
window_minutes = self.config.get("run.window_minutes.default_idle", 180)
else:
window_minutes = self.config.get("run.window_minutes.default_busy", 30)
# 计算窗口
overlap_seconds = self.config.get("run.overlap_seconds", 120)
if cursor_data and cursor_data.get("last_end"):
window_start = cursor_data["last_end"] - timedelta(seconds=overlap_seconds)
else:
window_start = now - timedelta(minutes=window_minutes)
window_end = now
return window_start, window_end, window_minutes
def _is_in_idle_window(self, dt: datetime, start_time: str, end_time: str) -> bool:
"""判断是否在闲时窗口"""
current_time = dt.strftime("%H:%M")
return start_time <= current_time <= end_time
def _build_result(self, status: str, counts: dict) -> dict:
"""构建结果字典"""
return {
"status": status,
"counts": counts
}

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""会员ETL任务"""
import json
from .base_task import BaseTask
from loaders.dimensions.member import MemberLoader
from models.parsers import TypeParser
class MembersTask(BaseTask):
"""会员ETL任务"""
def get_task_code(self) -> str:
return "MEMBERS"
def execute(self) -> dict:
"""执行会员ETL"""
self.logger.info(f"开始执行 {self.get_task_code()} 任务")
params = {
"storeId": self.config.get("app.store_id"),
}
try:
records, pages_meta = self.api.get_paginated(
endpoint="/MemberProfile/GetTenantMemberList",
params=params,
page_size=self.config.get("api.page_size", 200),
data_path=("data",)
)
parsed_records = []
for rec in records:
parsed = self._parse_member(rec)
if parsed:
parsed_records.append(parsed)
loader = MemberLoader(self.db)
store_id = self.config.get("app.store_id")
inserted, updated, skipped = loader.upsert_members(parsed_records, store_id)
self.db.commit()
counts = {
"fetched": len(records),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"errors": 0
}
self.logger.info(f"{self.get_task_code()} 完成: {counts}")
return self._build_result("SUCCESS", counts)
except Exception as e:
self.db.rollback()
self.logger.error(f"{self.get_task_code()} 失败", exc_info=True)
raise
def _parse_member(self, raw: dict) -> dict:
"""解析会员记录"""
try:
return {
"store_id": self.config.get("app.store_id"),
"member_id": TypeParser.parse_int(raw.get("memberId")),
"member_name": raw.get("memberName"),
"phone": raw.get("phone"),
"balance": TypeParser.parse_decimal(raw.get("balance")),
"status": raw.get("status"),
"register_time": TypeParser.parse_timestamp(raw.get("registerTime"), self.tz),
"raw_data": json.dumps(raw, ensure_ascii=False)
}
except Exception as e:
self.logger.warning(f"解析会员记录失败: {e}, 原始数据: {raw}")
return None

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""订单ETL任务"""
import json
from .base_task import BaseTask
from loaders.facts.order import OrderLoader
from models.parsers import TypeParser
class OrdersTask(BaseTask):
"""订单数据ETL任务"""
def get_task_code(self) -> str:
return "ORDERS"
def execute(self) -> dict:
"""执行订单数据ETL"""
self.logger.info(f"开始执行 {self.get_task_code()} 任务")
# 1. 获取时间窗口
window_start, window_end, window_minutes = self._get_time_window()
# 2. 调用API获取数据
params = {
"storeId": self.config.get("app.store_id"),
"startTime": TypeParser.format_timestamp(window_start, self.tz),
"endTime": TypeParser.format_timestamp(window_end, self.tz),
}
try:
records, pages_meta = self.api.get_paginated(
endpoint="/order/list",
params=params,
page_size=self.config.get("api.page_size", 200),
data_path=("data",)
)
# 3. 解析并清洗数据
parsed_records = []
for rec in records:
parsed = self._parse_order(rec)
if parsed:
parsed_records.append(parsed)
# 4. 加载数据
loader = OrderLoader(self.db)
store_id = self.config.get("app.store_id")
inserted, updated, skipped = loader.upsert_orders(
parsed_records,
store_id
)
# 5. 提交事务
self.db.commit()
counts = {
"fetched": len(records),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"errors": 0
}
self.logger.info(
f"{self.get_task_code()} 完成: {counts}"
)
return self._build_result("SUCCESS", counts)
except Exception as e:
self.db.rollback()
self.logger.error(f"{self.get_task_code()} 失败", exc_info=True)
raise
def _parse_order(self, raw: dict) -> dict:
"""解析单条订单记录"""
try:
return {
"store_id": self.config.get("app.store_id"),
"order_id": TypeParser.parse_int(raw.get("orderId")),
"order_no": raw.get("orderNo"),
"member_id": TypeParser.parse_int(raw.get("memberId")),
"table_id": TypeParser.parse_int(raw.get("tableId")),
"order_time": TypeParser.parse_timestamp(raw.get("orderTime"), self.tz),
"end_time": TypeParser.parse_timestamp(raw.get("endTime"), self.tz),
"total_amount": TypeParser.parse_decimal(raw.get("totalAmount")),
"discount_amount": TypeParser.parse_decimal(raw.get("discountAmount")),
"final_amount": TypeParser.parse_decimal(raw.get("finalAmount")),
"pay_status": raw.get("payStatus"),
"order_status": raw.get("orderStatus"),
"remark": raw.get("remark"),
"raw_data": json.dumps(raw, ensure_ascii=False)
}
except Exception as e:
self.logger.warning(f"解析订单失败: {e}, 原始数据: {raw}")
return None

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""支付记录ETL任务"""
import json
from .base_task import BaseTask
from loaders.facts.payment import PaymentLoader
from models.parsers import TypeParser
class PaymentsTask(BaseTask):
"""支付记录ETL任务"""
def get_task_code(self) -> str:
return "PAYMENTS"
def execute(self) -> dict:
"""执行支付记录ETL"""
self.logger.info(f"开始执行 {self.get_task_code()} 任务")
window_start, window_end, window_minutes = self._get_time_window()
params = {
"storeId": self.config.get("app.store_id"),
"startTime": TypeParser.format_timestamp(window_start, self.tz),
"endTime": TypeParser.format_timestamp(window_end, self.tz),
}
try:
records, pages_meta = self.api.get_paginated(
endpoint="/pay/records",
params=params,
page_size=self.config.get("api.page_size", 200),
data_path=("data",)
)
parsed_records = []
for rec in records:
parsed = self._parse_payment(rec)
if parsed:
parsed_records.append(parsed)
loader = PaymentLoader(self.db)
store_id = self.config.get("app.store_id")
inserted, updated, skipped = loader.upsert_payments(parsed_records, store_id)
self.db.commit()
counts = {
"fetched": len(records),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"errors": 0
}
self.logger.info(f"{self.get_task_code()} 完成: {counts}")
return self._build_result("SUCCESS", counts)
except Exception as e:
self.db.rollback()
self.logger.error(f"{self.get_task_code()} 失败", exc_info=True)
raise
def _parse_payment(self, raw: dict) -> dict:
"""解析支付记录"""
try:
return {
"store_id": self.config.get("app.store_id"),
"pay_id": TypeParser.parse_int(raw.get("payId")),
"order_id": TypeParser.parse_int(raw.get("orderId")),
"pay_time": TypeParser.parse_timestamp(raw.get("payTime"), self.tz),
"pay_amount": TypeParser.parse_decimal(raw.get("payAmount")),
"pay_type": raw.get("payType"),
"pay_status": raw.get("payStatus"),
"remark": raw.get("remark"),
"raw_data": json.dumps(raw, ensure_ascii=False)
}
except Exception as e:
self.logger.warning(f"解析支付记录失败: {e}, 原始数据: {raw}")
return None

View File

@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""商品档案PRODUCTSETL任务"""
import json
from .base_task import BaseTask
from loaders.dimensions.product import ProductLoader
from models.parsers import TypeParser
class ProductsTask(BaseTask):
"""商品维度 ETL 任务"""
def get_task_code(self) -> str:
"""任务代码,应与 etl_admin.etl_task.task_code 一致"""
return "PRODUCTS"
def execute(self) -> dict:
"""
执行商品档案 ETL
流程:
1. 调用上游 /TenantGoods/QueryTenantGoods 分页拉取商品列表
2. 解析/清洗字段
3. 通过 ProductLoader 写入 dim_product 和 dim_product_price_scd
"""
self.logger.info(f"开始执行 {self.get_task_code()} 任务")
params = {
"storeId": self.config.get("app.store_id"),
}
try:
# 1. 分页拉取数据
records, pages_meta = self.api.get_paginated(
endpoint="/TenantGoods/QueryTenantGoods",
params=params,
page_size=self.config.get("api.page_size", 200),
data_path=("data",),
)
# 2. 解析/清洗
parsed_records = []
for raw in records:
parsed = self._parse_product(raw)
if parsed:
parsed_records.append(parsed)
# 3. 加载入库(维度主表 + 价格SCD2
loader = ProductLoader(self.db)
store_id = self.config.get("app.store_id")
inserted, updated, skipped = loader.upsert_products(
parsed_records, store_id
)
# 4. 提交事务
self.db.commit()
counts = {
"fetched": len(records),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"errors": 0,
}
self.logger.info(f"{self.get_task_code()} 完成: {counts}")
return self._build_result("SUCCESS", counts)
except Exception:
# 明确回滚,避免部分成功
self.db.rollback()
self.logger.error(f"{self.get_task_code()} 失败", exc_info=True)
raise
def _parse_product(self, raw: dict) -> dict | None:
"""
解析单条商品记录,字段映射参考旧版 upsert_dim_product_and_price_scd
上游字段示例:
- siteGoodsId / tenantGoodsId / productId
- goodsName / productName
- tenantGoodsCategoryId / goodsCategoryId / categoryName / goodsCategorySecondId
- goodsUnit
- costPrice / goodsPrice / salePrice
- goodsState / status
- supplierId / barcode / isCombo
- createTime / updateTime
"""
try:
product_id = (
TypeParser.parse_int(
raw.get("siteGoodsId")
or raw.get("tenantGoodsId")
or raw.get("productId")
)
)
if not product_id:
# 主键缺失,直接跳过
return None
return {
"store_id": self.config.get("app.store_id"),
"product_id": product_id,
"site_product_id": TypeParser.parse_int(raw.get("siteGoodsId")),
"product_name": raw.get("goodsName") or raw.get("productName"),
"category_id": TypeParser.parse_int(
raw.get("tenantGoodsCategoryId") or raw.get("goodsCategoryId")
),
"category_name": raw.get("categoryName"),
"second_category_id": TypeParser.parse_int(
raw.get("goodsCategorySecondId")
),
"unit": raw.get("goodsUnit"),
"cost_price": TypeParser.parse_decimal(raw.get("costPrice")),
"sale_price": TypeParser.parse_decimal(
raw.get("goodsPrice") or raw.get("salePrice")
),
# 旧版这里就是 None如后面有明确字段可以再补
"allow_discount": None,
"status": raw.get("goodsState") or raw.get("status"),
"supplier_id": TypeParser.parse_int(raw.get("supplierId"))
if raw.get("supplierId")
else None,
"barcode": raw.get("barcode"),
"is_combo": bool(raw.get("isCombo"))
if raw.get("isCombo") is not None
else None,
"created_time": TypeParser.parse_timestamp(
raw.get("createTime"), self.tz
),
"updated_time": TypeParser.parse_timestamp(
raw.get("updateTime"), self.tz
),
"raw_data": json.dumps(raw, ensure_ascii=False),
}
except Exception as e:
self.logger.warning(f"解析商品记录失败: {e}, 原始数据: {raw}")
return None

View File

@@ -0,0 +1,4 @@
class TablesTask(BaseTask):
def get_task_code(self) -> str: # 返回 "TABLES"
def execute(self) -> dict: # 拉取 /Table/GetSiteTables
def _parse_table(self, raw: dict) -> dict | None:

View File

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""数据库集成测试"""
import pytest
from database.connection import DatabaseConnection
from database.operations import DatabaseOperations
# 注意:这些测试需要实际的数据库连接
# 在CI/CD环境中应使用测试数据库
@pytest.fixture
def db_connection():
"""数据库连接fixture"""
# 从环境变量获取测试数据库DSN
import os
dsn = os.environ.get("TEST_DB_DSN")
if not dsn:
pytest.skip("未配置测试数据库")
conn = DatabaseConnection(dsn)
yield conn
conn.close()
def test_database_query(db_connection):
"""测试数据库查询"""
result = db_connection.query("SELECT 1 AS test")
assert len(result) == 1
assert result[0]["test"] == 1
def test_database_operations(db_connection):
"""测试数据库操作"""
ops = DatabaseOperations(db_connection)
# 添加实际的测试用例
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,647 @@
[
{
"data": {
"total": 15,
"abolitionAssistants": [
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-11-09 19:23:29",
"id": 2957675849518789,
"siteId": 2790685415443269,
"tableAreaId": 2791963816579205,
"tableId": 2793016660660357,
"tableArea": "C区",
"tableName": "C1",
"assistantOn": "27",
"assistantName": "泡芙",
"pdChargeMinutes": 214,
"assistantAbolishAmount": 5.83,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-11-06 17:42:09",
"id": 2953329501898373,
"siteId": 2790685415443269,
"tableAreaId": 2802006170324037,
"tableId": 2851642357976581,
"tableArea": "补时长",
"tableName": "补时长5",
"assistantOn": "23",
"assistantName": "婉婉",
"pdChargeMinutes": 10800,
"assistantAbolishAmount": 570.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-11-06 17:42:09",
"id": 2953329502357125,
"siteId": 2790685415443269,
"tableAreaId": 2802006170324037,
"tableId": 2851642357976581,
"tableArea": "补时长",
"tableName": "补时长5",
"assistantOn": "52",
"assistantName": "小柔",
"pdChargeMinutes": 10800,
"assistantAbolishAmount": 570.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-30 01:17:22",
"id": 2942452375932869,
"siteId": 2790685415443269,
"tableAreaId": 2791963825803397,
"tableId": 2793018776604805,
"tableArea": "VIP包厢",
"tableName": "VIP1",
"assistantOn": "2",
"assistantName": "佳怡",
"pdChargeMinutes": 0,
"assistantAbolishAmount": 0.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-29 06:57:22",
"id": 2941371032964997,
"siteId": 2790685415443269,
"tableAreaId": 2791963848527941,
"tableId": 2793021451292741,
"tableArea": "666",
"tableName": "董事办",
"assistantOn": "4",
"assistantName": "璇子",
"pdChargeMinutes": 0,
"assistantAbolishAmount": 0.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-28 02:13:18",
"id": 2939676194180229,
"siteId": 2790685415443269,
"tableAreaId": 2791963887030341,
"tableId": 2793023960551493,
"tableArea": "麻将房",
"tableName": "1",
"assistantOn": "2",
"assistantName": "佳怡",
"pdChargeMinutes": 3602,
"assistantAbolishAmount": 108.06,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-26 21:06:37",
"id": 2937959143262725,
"siteId": 2790685415443269,
"tableAreaId": 2791963855982661,
"tableId": 2793022145302597,
"tableArea": "K包",
"tableName": "888",
"assistantOn": "16",
"assistantName": "周周",
"pdChargeMinutes": 0,
"assistantAbolishAmount": 0.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-25 21:36:22",
"id": 2936572806285765,
"siteId": 2790685415443269,
"tableAreaId": 2791963816579205,
"tableId": 2793017278451845,
"tableArea": "C区",
"tableName": "C2",
"assistantOn": "4",
"assistantName": "璇子",
"pdChargeMinutes": 0,
"assistantAbolishAmount": 0.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-23 19:05:48",
"id": 2933593641256581,
"siteId": 2790685415443269,
"tableAreaId": 2791963807682693,
"tableId": 2793012902318213,
"tableArea": "B区",
"tableName": "B9",
"assistantOn": "16",
"assistantName": "周周",
"pdChargeMinutes": 3600,
"assistantAbolishAmount": 190.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-18 20:25:50",
"id": 2926594431305093,
"siteId": 2790685415443269,
"tableAreaId": 2791963794329671,
"tableId": 2793001904918661,
"tableArea": "A区",
"tableName": "A4",
"assistantOn": "15",
"assistantName": "七七",
"pdChargeMinutes": 2379,
"assistantAbolishAmount": 71.37,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-14 14:20:32",
"id": 2920573007709573,
"siteId": 2790685415443269,
"tableAreaId": 2791963855982661,
"tableId": 2793022145302597,
"tableArea": "K包",
"tableName": "888",
"assistantOn": "9",
"assistantName": "球球",
"pdChargeMinutes": 14400,
"assistantAbolishAmount": 392.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-03 01:21:59",
"id": 2904236313234373,
"siteId": 2790685415443269,
"tableAreaId": 2791963848527941,
"tableId": 2793020955840645,
"tableArea": "666",
"tableName": "666",
"assistantOn": "9",
"assistantName": "球球",
"pdChargeMinutes": 0,
"assistantAbolishAmount": 0.0,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-01 00:27:29",
"id": 2901351579143365,
"siteId": 2790685415443269,
"tableAreaId": 2791963855982661,
"tableId": 2793022145302597,
"tableArea": "K包",
"tableName": "888",
"assistantOn": "99",
"assistantName": "Amy",
"pdChargeMinutes": 10605,
"assistantAbolishAmount": 465.44,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-01 00:27:29",
"id": 2901351578864837,
"siteId": 2790685415443269,
"tableAreaId": 2791963855982661,
"tableId": 2793022145302597,
"tableArea": "K包",
"tableName": "888",
"assistantOn": "4",
"assistantName": "璇子",
"pdChargeMinutes": 10608,
"assistantAbolishAmount": 318.24,
"trashReason": ""
},
{
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 1,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"createTime": "2025-10-01 00:27:29",
"id": 2901351578602693,
"siteId": 2790685415443269,
"tableAreaId": 2791963855982661,
"tableId": 2793022145302597,
"tableArea": "K包",
"tableName": "888",
"assistantOn": "2",
"assistantName": "佳怡",
"pdChargeMinutes": 10611,
"assistantAbolishAmount": 318.33,
"trashReason": ""
}
]
},
"code": 0
},
{
"data": {
"total": 15,
"abolitionAssistants": []
},
"code": 0
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,673 @@
[
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2955202296416389,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -5000.0,
"pay_status": 2,
"pay_time": "2025-11-08 01:27:16",
"create_time": "2025-11-08 01:27:16",
"relate_type": 5,
"relate_id": 2955078219057349,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2955171790194821,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -10000.0,
"pay_status": 2,
"pay_time": "2025-11-08 00:56:14",
"create_time": "2025-11-08 00:56:14",
"relate_type": 5,
"relate_id": 2955153380001861,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2951883030513413,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -12.0,
"pay_status": 2,
"pay_time": "2025-11-05 17:10:44",
"create_time": "2025-11-05 17:10:44",
"relate_type": 2,
"relate_id": 2951881496577861,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2948959062542597,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -65.0,
"pay_status": 2,
"pay_time": "2025-11-03 15:36:19",
"create_time": "2025-11-03 15:36:19",
"relate_type": 2,
"relate_id": 2948934289967557,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2948630468005509,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -88.33,
"pay_status": 2,
"pay_time": "2025-11-03 10:02:03",
"create_time": "2025-11-03 10:02:03",
"relate_type": 2,
"relate_id": 2948246513454661,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2948269239095045,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -0.67,
"pay_status": 2,
"pay_time": "2025-11-03 03:54:36",
"create_time": "2025-11-03 03:54:36",
"relate_type": 2,
"relate_id": 2948246513454661,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2944743812581445,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -44000.0,
"pay_status": 2,
"pay_time": "2025-10-31 16:08:21",
"create_time": "2025-10-31 16:08:21",
"relate_type": 5,
"relate_id": 2944743413958789,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2931109065131653,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -10.0,
"pay_status": 2,
"pay_time": "2025-10-22 00:58:22",
"create_time": "2025-10-22 00:58:22",
"relate_type": 2,
"relate_id": 2931108522378885,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2921195994465669,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -2.0,
"pay_status": 2,
"pay_time": "2025-10-15 00:54:16",
"create_time": "2025-10-15 00:54:16",
"relate_type": 2,
"relate_id": 2920440691344901,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2919690732146181,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -3000.0,
"pay_status": 2,
"pay_time": "2025-10-13 23:23:02",
"create_time": "2025-10-13 23:23:02",
"relate_type": 5,
"relate_id": 2919519811440261,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
},
{
"tenantName": "朗朗桌球",
"siteProfile": {
"id": 2790685415443269,
"org_id": 2790684179467077,
"shop_name": "朗朗桌球",
"avatar": "https://oss.ficoo.vip/admin/hXcE4E_1752495052016.jpg",
"business_tel": "13316068642",
"full_address": "广东省广州市天河区丽阳街12号",
"address": "广东省广州市天河区天园街道朗朗桌球",
"longitude": 113.360321,
"latitude": 23.133629,
"tenant_site_region_id": 156440100,
"tenant_id": 2790683160709957,
"auto_light": 1,
"attendance_distance": 0,
"wifi_name": "",
"wifi_password": "",
"customer_service_qrcode": "",
"customer_service_wechat": "",
"fixed_pay_qrCode": "",
"prod_env": 1,
"light_status": 2,
"light_type": 0,
"site_type": 1,
"light_token": "",
"site_label": "A",
"attendance_enabled": 1,
"shop_status": 1
},
"id": 2914039374956165,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"pay_sn": 0,
"pay_amount": -8.0,
"pay_status": 2,
"pay_time": "2025-10-09 23:34:11",
"create_time": "2025-10-09 23:34:11",
"relate_type": 2,
"relate_id": 2914030720124357,
"is_revoke": 0,
"is_delete": 0,
"online_pay_channel": 0,
"payment_method": 4,
"balance_frozen_amount": 0.0,
"card_frozen_amount": 0.0,
"member_id": 0,
"member_card_id": 0,
"round_amount": 0.0,
"online_pay_type": 0,
"action_type": 2,
"refund_amount": 0.0,
"cashier_point_id": 0,
"operator_id": 0,
"pay_terminal": 1,
"pay_config_id": 0,
"channel_payer_id": "",
"channel_pay_no": "",
"check_status": 1,
"channel_fee": 0.0
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,712 @@
[
{
"data": {
"total": 0,
"goodsCategoryList": [
{
"id": 2790683528350533,
"tenant_id": 2790683160709957,
"category_name": "槟榔",
"alias_name": "",
"pid": 0,
"business_name": "槟榔",
"tenant_goods_business_id": 2790683528317766,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2790683528350534,
"tenant_id": 2790683160709957,
"category_name": "槟榔",
"alias_name": "",
"pid": 2790683528350533,
"business_name": "槟榔",
"tenant_goods_business_id": 2790683528317766,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 1,
"is_warehousing": 1
},
{
"id": 2790683528350535,
"tenant_id": 2790683160709957,
"category_name": "器材",
"alias_name": "",
"pid": 0,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2790683528350536,
"tenant_id": 2790683160709957,
"category_name": "皮头",
"alias_name": "",
"pid": 2790683528350535,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350537,
"tenant_id": 2790683160709957,
"category_name": "球杆",
"alias_name": "",
"pid": 2790683528350535,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350538,
"tenant_id": 2790683160709957,
"category_name": "其他",
"alias_name": "",
"pid": 2790683528350535,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350539,
"tenant_id": 2790683160709957,
"category_name": "酒水",
"alias_name": "",
"pid": 0,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2790683528350540,
"tenant_id": 2790683160709957,
"category_name": "饮料",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350541,
"tenant_id": 2790683160709957,
"category_name": "酒水",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350542,
"tenant_id": 2790683160709957,
"category_name": "茶水",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350543,
"tenant_id": 2790683160709957,
"category_name": "咖啡",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350544,
"tenant_id": 2790683160709957,
"category_name": "加料",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2793221553489733,
"tenant_id": 2790683160709957,
"category_name": "洋酒",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350545,
"tenant_id": 2790683160709957,
"category_name": "果盘",
"alias_name": "",
"pid": 0,
"business_name": "水果",
"tenant_goods_business_id": 2790683528317769,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2792050275864453,
"tenant_id": 2790683160709957,
"category_name": "果盘",
"alias_name": "",
"pid": 2790683528350545,
"business_name": "水果",
"tenant_goods_business_id": 2790683528317769,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2791941988405125,
"tenant_id": 2790683160709957,
"category_name": "零食",
"alias_name": "",
"pid": 0,
"business_name": "零食",
"tenant_goods_business_id": 2791932037238661,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2791948300259205,
"tenant_id": 2790683160709957,
"category_name": "零食",
"alias_name": "",
"pid": 2791941988405125,
"business_name": "零食",
"tenant_goods_business_id": 2791932037238661,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2793236829620037,
"tenant_id": 2790683160709957,
"category_name": "面",
"alias_name": "",
"pid": 2791941988405125,
"business_name": "零食",
"tenant_goods_business_id": 2791932037238661,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2791942087561093,
"tenant_id": 2790683160709957,
"category_name": "雪糕",
"alias_name": "",
"pid": 0,
"business_name": "雪糕",
"tenant_goods_business_id": 2791931866402693,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2792035069284229,
"tenant_id": 2790683160709957,
"category_name": "雪糕",
"alias_name": "",
"pid": 2791942087561093,
"business_name": "雪糕",
"tenant_goods_business_id": 2791931866402693,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2792062778003333,
"tenant_id": 2790683160709957,
"category_name": "香烟",
"alias_name": "",
"pid": 0,
"business_name": "香烟",
"tenant_goods_business_id": 2790683528317765,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2792063209623429,
"tenant_id": 2790683160709957,
"category_name": "香烟",
"alias_name": "",
"pid": 2792062778003333,
"business_name": "香烟",
"tenant_goods_business_id": 2790683528317765,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 1,
"is_warehousing": 1
}
],
"sort": 1,
"is_warehousing": 1
},
{
"id": 2793217944864581,
"tenant_id": 2790683160709957,
"category_name": "其他",
"alias_name": "",
"pid": 0,
"business_name": "其他",
"tenant_goods_business_id": 2793217599407941,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2793218343257925,
"tenant_id": 2790683160709957,
"category_name": "其他2",
"alias_name": "",
"pid": 2793217944864581,
"business_name": "其他",
"tenant_goods_business_id": 2793217599407941,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2793220945250117,
"tenant_id": 2790683160709957,
"category_name": "小吃",
"alias_name": "",
"pid": 0,
"business_name": "小吃",
"tenant_goods_business_id": 2793220268902213,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2793221283104581,
"tenant_id": 2790683160709957,
"category_name": "小吃",
"alias_name": "",
"pid": 2793220945250117,
"business_name": "小吃",
"tenant_goods_business_id": 2793220268902213,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
}
]
},
"code": 0
},
{
"data": {
"total": 0,
"goodsCategoryList": [
{
"id": 2790683528350533,
"tenant_id": 2790683160709957,
"category_name": "槟榔",
"alias_name": "",
"pid": 0,
"business_name": "槟榔",
"tenant_goods_business_id": 2790683528317766,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2790683528350534,
"tenant_id": 2790683160709957,
"category_name": "槟榔",
"alias_name": "",
"pid": 2790683528350533,
"business_name": "槟榔",
"tenant_goods_business_id": 2790683528317766,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 1,
"is_warehousing": 1
},
{
"id": 2790683528350535,
"tenant_id": 2790683160709957,
"category_name": "器材",
"alias_name": "",
"pid": 0,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2790683528350536,
"tenant_id": 2790683160709957,
"category_name": "皮头",
"alias_name": "",
"pid": 2790683528350535,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350537,
"tenant_id": 2790683160709957,
"category_name": "球杆",
"alias_name": "",
"pid": 2790683528350535,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350538,
"tenant_id": 2790683160709957,
"category_name": "其他",
"alias_name": "",
"pid": 2790683528350535,
"business_name": "器材",
"tenant_goods_business_id": 2790683528317767,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350539,
"tenant_id": 2790683160709957,
"category_name": "酒水",
"alias_name": "",
"pid": 0,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2790683528350540,
"tenant_id": 2790683160709957,
"category_name": "饮料",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350541,
"tenant_id": 2790683160709957,
"category_name": "酒水",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350542,
"tenant_id": 2790683160709957,
"category_name": "茶水",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350543,
"tenant_id": 2790683160709957,
"category_name": "咖啡",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350544,
"tenant_id": 2790683160709957,
"category_name": "加料",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2793221553489733,
"tenant_id": 2790683160709957,
"category_name": "洋酒",
"alias_name": "",
"pid": 2790683528350539,
"business_name": "酒水",
"tenant_goods_business_id": 2790683528317768,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2790683528350545,
"tenant_id": 2790683160709957,
"category_name": "果盘",
"alias_name": "",
"pid": 0,
"business_name": "水果",
"tenant_goods_business_id": 2790683528317769,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2792050275864453,
"tenant_id": 2790683160709957,
"category_name": "果盘",
"alias_name": "",
"pid": 2790683528350545,
"business_name": "水果",
"tenant_goods_business_id": 2790683528317769,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2791941988405125,
"tenant_id": 2790683160709957,
"category_name": "零食",
"alias_name": "",
"pid": 0,
"business_name": "零食",
"tenant_goods_business_id": 2791932037238661,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2791948300259205,
"tenant_id": 2790683160709957,
"category_name": "零食",
"alias_name": "",
"pid": 2791941988405125,
"business_name": "零食",
"tenant_goods_business_id": 2791932037238661,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2793236829620037,
"tenant_id": 2790683160709957,
"category_name": "面",
"alias_name": "",
"pid": 2791941988405125,
"business_name": "零食",
"tenant_goods_business_id": 2791932037238661,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2791942087561093,
"tenant_id": 2790683160709957,
"category_name": "雪糕",
"alias_name": "",
"pid": 0,
"business_name": "雪糕",
"tenant_goods_business_id": 2791931866402693,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2792035069284229,
"tenant_id": 2790683160709957,
"category_name": "雪糕",
"alias_name": "",
"pid": 2791942087561093,
"business_name": "雪糕",
"tenant_goods_business_id": 2791931866402693,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2792062778003333,
"tenant_id": 2790683160709957,
"category_name": "香烟",
"alias_name": "",
"pid": 0,
"business_name": "香烟",
"tenant_goods_business_id": 2790683528317765,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2792063209623429,
"tenant_id": 2790683160709957,
"category_name": "香烟",
"alias_name": "",
"pid": 2792062778003333,
"business_name": "香烟",
"tenant_goods_business_id": 2790683528317765,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 1,
"is_warehousing": 1
}
],
"sort": 1,
"is_warehousing": 1
},
{
"id": 2793217944864581,
"tenant_id": 2790683160709957,
"category_name": "其他",
"alias_name": "",
"pid": 0,
"business_name": "其他",
"tenant_goods_business_id": 2793217599407941,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2793218343257925,
"tenant_id": 2790683160709957,
"category_name": "其他2",
"alias_name": "",
"pid": 2793217944864581,
"business_name": "其他",
"tenant_goods_business_id": 2793217599407941,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
},
{
"id": 2793220945250117,
"tenant_id": 2790683160709957,
"category_name": "小吃",
"alias_name": "",
"pid": 0,
"business_name": "小吃",
"tenant_goods_business_id": 2793220268902213,
"open_salesman": 2,
"categoryBoxes": [
{
"id": 2793221283104581,
"tenant_id": 2790683160709957,
"category_name": "小吃",
"alias_name": "",
"pid": 2793220945250117,
"business_name": "小吃",
"tenant_goods_business_id": 2793220268902213,
"open_salesman": 2,
"categoryBoxes": [],
"sort": 0,
"is_warehousing": 1
}
],
"sort": 0,
"is_warehousing": 1
}
]
},
"code": 0
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,646 @@
[
{
"data": {
"total": 17,
"packageCouponList": [
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2939215004469573,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "早场特惠一小时",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-10-27 00:00:00",
"end_time": "2026-10-28 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 2,
"package_id": 1814707240811572,
"usable_count": 9999999,
"create_time": "2025-10-27 18:24:09",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2861343275830405,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "B区桌球一小时",
"table_area_id": "0",
"table_area_name": "B区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-09-02 00:00:00",
"end_time": "2026-09-03 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1370841337,
"usable_count": 9999999,
"create_time": "2025-09-02 18:08:56",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960521691013",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 3,
"id": 2836713896429317,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "午夜一小时",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-08-16 00:00:00",
"end_time": "2026-08-17 00:00:00",
"is_enabled": 2,
"is_delete": 0,
"type": 1,
"package_id": 1370841337,
"usable_count": 9999999,
"create_time": "2025-08-16 08:34:38",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2801876691340293,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "中八、斯诺克包厢两小时",
"table_area_id": "0",
"table_area_name": "VIP包厢",
"selling_price": 0.0,
"duration": 7200,
"start_time": "2025-07-22 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1126976372,
"usable_count": 9999999,
"create_time": "2025-07-22 17:56:24",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791961060364165",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 3,
"id": 2801875268668357,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "中八、斯诺克包厢两小时",
"table_area_id": "0",
"table_area_name": "VIP包厢",
"selling_price": 0.0,
"duration": 7200,
"start_time": "2025-07-22 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 2,
"is_delete": 0,
"type": 1,
"package_id": 1126976372,
"usable_count": 9999999,
"create_time": "2025-07-22 17:54:57",
"creator_name": "管理员:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791961060364165",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "0",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 3,
"id": 2800772613934149,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "午夜场一小时A区",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 2,
"is_delete": 0,
"type": 1,
"package_id": 1370841337,
"usable_count": 9999999,
"create_time": "2025-07-21 23:13:16",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798905767676933,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "中八、斯诺克包厢两小时",
"table_area_id": "0",
"table_area_name": "VIP包厢",
"selling_price": 0.0,
"duration": 7200,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 2,
"package_id": 1812429097416714,
"usable_count": 9999999,
"create_time": "2025-07-20 15:34:13",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791961060364165",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 3,
"id": 2798901295615045,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "新人特惠A区中八一小时",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 2,
"is_delete": 0,
"type": 2,
"package_id": 1814707240811572,
"usable_count": 9999999,
"create_time": "2025-07-20 15:29:40",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798898826300485,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "斯诺克两小时",
"table_area_id": "0",
"table_area_name": "斯诺克区",
"selling_price": 0.0,
"duration": 7200,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 2,
"package_id": 1814983609169019,
"usable_count": 9999999,
"create_time": "2025-07-20 15:27:09",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791961347968901",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798734170983493,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "助理教练竞技教学两小时",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 7200,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1173128804,
"usable_count": 9999999,
"create_time": "2025-07-20 12:39:39",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798732571167749,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "全天斯诺克一小时",
"table_area_id": "0",
"table_area_name": "斯诺克区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-30 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1147633733,
"usable_count": 9999999,
"create_time": "2025-07-20 12:38:02",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791961347968901",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798731703045189,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "KTV欢唱四小时",
"table_area_id": "0",
"table_area_name": "888",
"selling_price": 0.0,
"duration": 14400,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1137882866,
"usable_count": 9999999,
"create_time": "2025-07-20 12:37:09",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791961709907845",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "10:00:00",
"add_end_clock": "18:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798729978514501,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "全天A区中八两小时",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 7200,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1130465371,
"usable_count": 9999999,
"create_time": "2025-07-20 12:35:24",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798728823213061,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "全天B区中八两小时",
"table_area_id": "0",
"table_area_name": "B区",
"selling_price": 0.0,
"duration": 7200,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1137872168,
"usable_count": 9999999,
"create_time": "2025-07-20 12:34:13",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960521691013",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798727423528005,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "全天A区中八一小时",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1128411555,
"usable_count": 9999999,
"create_time": "2025-07-20 12:32:48",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798723640069125,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "中八A区新人特惠一小时",
"table_area_id": "0",
"table_area_name": "A区",
"selling_price": 0.0,
"duration": 3600,
"start_time": "2025-07-20 00:00:00",
"end_time": "2025-12-31 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1203035334,
"usable_count": 9999999,
"create_time": "2025-07-20 12:28:57",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791960001957765",
"start_clock": "00:00:00",
"end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"add_end_clock": "1.00:00:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
},
{
"site_name": "朗朗桌球",
"effective_status": 1,
"id": 2798713926290437,
"site_id": 2790685415443269,
"tenant_id": 2790683160709957,
"package_name": "麻将 、掼蛋包厢四小时",
"table_area_id": "0",
"table_area_name": "麻将房",
"selling_price": 0.0,
"duration": 14400,
"start_time": "2025-07-21 00:00:00",
"end_time": "2025-12-30 00:00:00",
"is_enabled": 1,
"is_delete": 0,
"type": 1,
"package_id": 1134269810,
"usable_count": 9999999,
"create_time": "2025-07-20 12:19:04",
"creator_name": "店长:郑丽珊",
"tenant_table_area_id": "0",
"table_area_id_list": "",
"tenant_table_area_id_list": "2791962314215301",
"start_clock": "10:00:00",
"end_clock": "1.02:00:00",
"add_start_clock": "10:00:00",
"add_end_clock": "23:59:00",
"date_info": "",
"date_type": 1,
"group_type": 1,
"usable_range": "",
"coupon_money": 0.0,
"area_tag_type": 1,
"system_group_type": 1,
"max_selectable_categories": 0,
"card_type_ids": "0"
}
]
},
"code": 0
},
{
"data": {
"total": 17,
"packageCouponList": []
},
"code": 0
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
[
{
"data": {
"goodsStockA": 0,
"goodsStockB": 6252,
"goodsSaleNum": 210.29,
"stockSumMoney": 1461.28
},
"code": 0
},
{
"data": {
"goodsStockA": 0,
"goodsStockB": 6252,
"goodsSaleNum": 210.29,
"stockSumMoney": 1461.28
},
"code": 0
}
]

View File

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
"""配置管理测试"""
import pytest
from config.settings import AppConfig
from config.defaults import DEFAULTS
def test_config_load():
"""测试配置加载"""
config = AppConfig.load()
assert config.get("app.timezone") == DEFAULTS["app"]["timezone"]
def test_config_override():
"""测试配置覆盖"""
overrides = {
"app": {"store_id": 12345}
}
config = AppConfig.load(overrides)
assert config.get("app.store_id") == 12345
def test_config_get_nested():
"""测试嵌套配置获取"""
config = AppConfig.load()
assert config.get("db.batch_size") == 1000
assert config.get("nonexistent.key", "default") == "default"

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""解析器测试"""
import pytest
from decimal import Decimal
from datetime import datetime
from zoneinfo import ZoneInfo
from models.parsers import TypeParser
def test_parse_decimal():
"""测试金额解析"""
assert TypeParser.parse_decimal("100.555", 2) == Decimal("100.56")
assert TypeParser.parse_decimal(None) is None
assert TypeParser.parse_decimal("invalid") is None
def test_parse_int():
"""测试整数解析"""
assert TypeParser.parse_int("123") == 123
assert TypeParser.parse_int(456) == 456
assert TypeParser.parse_int(None) is None
assert TypeParser.parse_int("abc") is None
def test_parse_timestamp():
"""测试时间戳解析"""
tz = ZoneInfo("Asia/Taipei")
dt = TypeParser.parse_timestamp("2025-01-15 10:30:00", tz)
assert dt is not None
assert dt.year == 2025
assert dt.month == 1
assert dt.day == 15

View File

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""通用工具函数"""
import hashlib
from datetime import datetime
from pathlib import Path
def ensure_dir(path: Path):
"""确保目录存在"""
path.mkdir(parents=True, exist_ok=True)
def make_surrogate_key(*parts) -> int:
"""
生成代理键
将多个字段值拼接后计算SHA1取前8字节转为无符号64位整数
"""
raw = "|".join("" if p is None else str(p) for p in parts)
h = hashlib.sha1(raw.encode("utf-8")).digest()[:8]
return int.from_bytes(h, byteorder="big", signed=False)
def now_local(tz) -> datetime:
"""获取本地当前时间"""
return datetime.now(tz)

5
run_etl.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
REM ETL运行脚本 (Windows)
REM 运行ETL
python -m cli.main %*

10
run_etl.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# ETL运行脚本
# 加载环境变量
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
fi
# 运行ETL
python -m cli.main "$@"

13
开发笔记/记录.md Normal file
View File

@@ -0,0 +1,13 @@
原来在 task_merged.py 里配置的 14 个任务中,有 11 个目前还没有在新项目里实现,对应的 loader / task 类也不存在。
原脚本里“导出请求/响应 JSON 到本地目录、生成 manifest.json / ingest_report.json 并支持 offline 模式”的那一块逻辑,在新代码里还没有真正落地,只保留了配置字段和数据库字段,但没有实际写文件和离线装载的实现。
丰富Pytest进行分模块.分任务测试
质量检查目前没有被“接入主流程”,内容也待完善,入库等问题?