合并
This commit is contained in:
@@ -1,53 +1,132 @@
|
||||
# 数据库配置(真实库)
|
||||
# ==============================================================================
|
||||
# ETL 系统配置文件
|
||||
# ==============================================================================
|
||||
# 配置优先级:DEFAULTS < .env < CLI 参数
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据库配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 完整 DSN(优先使用,如果设置了则忽略下面的 host/port/name/user/password)
|
||||
PG_DSN=postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/LLZQ-test
|
||||
|
||||
# 分离式配置(如果不使用 DSN,可以单独配置以下参数)
|
||||
# PG_HOST=localhost
|
||||
# PG_PORT=5432
|
||||
# PG_NAME=your_database
|
||||
# PG_USER=your_user
|
||||
# PG_PASSWORD=your_password
|
||||
|
||||
# 连接超时(秒,范围 1-20)
|
||||
PG_CONNECT_TIMEOUT=10
|
||||
# 如需拆分配置:PG_HOST=... PG_PORT=... PG_NAME=... PG_USER=... PG_PASSWORD=...
|
||||
|
||||
# API配置(如需走真实接口再填写)
|
||||
API_BASE=https://api.example.com
|
||||
API_TOKEN=your_token_here
|
||||
# API_TIMEOUT=20
|
||||
# API_PAGE_SIZE=200
|
||||
# API_RETRY_MAX=3
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据库 Schema 配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# OLTP 业务数据 schema(默认 billiards)
|
||||
SCHEMA_OLTP=billiards
|
||||
|
||||
# 应用配置
|
||||
# ETL 管理数据 schema(默认 etl_admin)
|
||||
SCHEMA_ETL=etl_admin
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# API 配置
|
||||
# ------------------------------------------------------------------------------
|
||||
API_BASE=https://pc.ficoo.vip/apiprod/admin/v1/
|
||||
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6Iks1ZnBhYlRTNkFsR0FpMmN4WGYrMHdJVkk0L2UvTVQrSVBHM3V5VWRrSjg9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzEvMzEg5LiL5Y2IMTA6MTQ6NTEiLCJuZWVkQ2hlY2tUb2tlbiI6ImZhbHNlIiwiZXhwIjoxNzY5ODY4ODkxLCJpc3MiOiJ0ZXN0IiwiYXVkIjoiVXNlciJ9.BH3-iwwrBczb8aFfI__6kwe3AIsEPacN9TruaTrQ3nY
|
||||
|
||||
# API 请求超时(秒)
|
||||
API_TIMEOUT=20
|
||||
|
||||
# 分页大小
|
||||
API_PAGE_SIZE=200
|
||||
|
||||
# 最大重试次数
|
||||
API_RETRY_MAX=3
|
||||
|
||||
# 重试退避时间(JSON 数组格式,单位秒)
|
||||
# API_RETRY_BACKOFF=[1, 2, 4]
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 门店配置
|
||||
# ------------------------------------------------------------------------------
|
||||
STORE_ID=2790685415443269
|
||||
# TIMEZONE=Asia/Taipei
|
||||
# SCHEMA_OLTP=billiards
|
||||
# SCHEMA_ETL=etl_admin
|
||||
TIMEZONE=Asia/Taipei
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 路径配置
|
||||
EXPORT_ROOT=C:\dev\LLTQ\export\JSON
|
||||
LOG_ROOT=C:\dev\LLTQ\export\LOG
|
||||
FETCH_ROOT=
|
||||
INGEST_SOURCE_DIR=
|
||||
WRITE_PRETTY_JSON=false
|
||||
PGCLIENTENCODING=utf8
|
||||
# ------------------------------------------------------------------------------
|
||||
# 导出根目录
|
||||
EXPORT_ROOT=export/JSON
|
||||
|
||||
# ETL配置
|
||||
# 日志根目录
|
||||
LOG_ROOT=export/LOG
|
||||
|
||||
# 在线抓取 JSON 输出目录
|
||||
FETCH_ROOT=export/JSON
|
||||
|
||||
# 本地入库数据源目录(INGEST_ONLY 模式使用)
|
||||
INGEST_SOURCE_DIR=export/test-json-doc
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 流水线配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 运行模式:FULL(抓取+入库)、FETCH_ONLY(仅抓取)、INGEST_ONLY(仅入库)
|
||||
PIPELINE_FLOW=FULL
|
||||
|
||||
# JSON 美化输出(调试用,会增加文件大小)
|
||||
WRITE_PRETTY_JSON=false
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 时间窗口配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 冗余窗口(秒),向前多抓取的时间避免边界数据丢失
|
||||
OVERLAP_SECONDS=120
|
||||
|
||||
# 忙时窗口大小(分钟)
|
||||
WINDOW_BUSY_MIN=30
|
||||
|
||||
# 闲时窗口大小(分钟)
|
||||
WINDOW_IDLE_MIN=180
|
||||
|
||||
# 闲时窗口定义(HH:MM 格式)
|
||||
IDLE_START=04:00
|
||||
IDLE_END=16:00
|
||||
|
||||
# 窗口切分单位(month/none),用于长时间回溯任务按月切分
|
||||
WINDOW_SPLIT_UNIT=month
|
||||
|
||||
# 窗口前后补偿小时数,用于捕获边界数据
|
||||
WINDOW_COMPENSATION_HOURS=2
|
||||
|
||||
# 允许空结果推进窗口
|
||||
ALLOW_EMPTY_RESULT_ADVANCE=true
|
||||
|
||||
# 清洗配置
|
||||
LOG_UNKNOWN_FIELDS=true
|
||||
HASH_ALGO=sha1
|
||||
STRICT_NUMERIC=true
|
||||
ROUND_MONEY_SCALE=2
|
||||
# ------------------------------------------------------------------------------
|
||||
# 数据完整性检查配置
|
||||
# ------------------------------------------------------------------------------
|
||||
# 检查模式:history(历史全量)、recent(最近增量)
|
||||
INTEGRITY_MODE=history
|
||||
|
||||
# 测试/离线模式(真实库联调建议 ONLINE)
|
||||
TEST_MODE=ONLINE
|
||||
TEST_JSON_ARCHIVE_DIR=tests/source-data-doc
|
||||
TEST_JSON_TEMP_DIR=/tmp/etl_billiards_json_tmp
|
||||
# 历史检查起始日期(history 模式使用)
|
||||
INTEGRITY_HISTORY_START=2025-07-01
|
||||
|
||||
# 测试数据库
|
||||
TEST_DB_DSN=postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/LLZQ-test
|
||||
# 历史检查结束日期(留空表示到当前)
|
||||
INTEGRITY_HISTORY_END=
|
||||
|
||||
# ODS <20>ؽ<EFBFBD><D8BD>ű<EFBFBD><C5B1><EFBFBD><EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ã<EFBFBD>
|
||||
JSON_DOC_DIR=C:\dev\LLTQ\export\test-json-doc
|
||||
ODS_INCLUDE_FILES=
|
||||
ODS_DROP_SCHEMA_FIRST=true
|
||||
# 是否包含维度表校验
|
||||
INTEGRITY_INCLUDE_DIMENSIONS=true
|
||||
|
||||
# 发现丢失数据时是否自动补全
|
||||
INTEGRITY_AUTO_BACKFILL=true
|
||||
|
||||
# 自动执行完整性检查(ETL 完成后自动触发)
|
||||
INTEGRITY_AUTO_CHECK=false
|
||||
|
||||
# 指定要校验的 ODS 任务代码(逗号分隔,留空表示全部)
|
||||
# INTEGRITY_ODS_TASK_CODES=ODS_PAYMENT,ODS_MEMBER
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 默认任务列表(逗号分隔,可被 CLI --tasks 参数覆盖)
|
||||
# ------------------------------------------------------------------------------
|
||||
# RUN_TASKS=ODS_PAYMENT,ODS_MEMBER,ODS_SETTLEMENT_RECORDS,DWD_LOAD_FROM_ODS
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 数据库配置
|
||||
PG_DSN=postgresql://user:password@localhost:5432/....
|
||||
PG_HOST=localhost
|
||||
PG_PORT=5432
|
||||
PG_NAME=LLZQ
|
||||
PG_USER=local-Python
|
||||
PG_PASSWORD=your_password_here
|
||||
PG_CONNECT_TIMEOUT=10
|
||||
|
||||
# API配置
|
||||
API_BASE=https://api.example.com
|
||||
API_TOKEN=your_token_here
|
||||
API_TIMEOUT=20
|
||||
API_PAGE_SIZE=200
|
||||
API_RETRY_MAX=3
|
||||
API_RETRY_BACKOFF=[1,2,4]
|
||||
|
||||
# 应用配置
|
||||
STORE_ID=2790685415443269
|
||||
TIMEZONE=Asia/Taipei
|
||||
SCHEMA_OLTP=billiards
|
||||
SCHEMA_ETL=etl_admin
|
||||
|
||||
# 路径配置
|
||||
EXPORT_ROOT=/path/to/export
|
||||
LOG_ROOT=/path/to/logs
|
||||
FETCH_ROOT=/path/to/json_fetch
|
||||
INGEST_SOURCE_DIR=
|
||||
WRITE_PRETTY_JSON=false
|
||||
MANIFEST_NAME=manifest.json
|
||||
INGEST_REPORT_NAME=ingest_report.json
|
||||
|
||||
# ETL配置
|
||||
OVERLAP_SECONDS=120
|
||||
WINDOW_BUSY_MIN=30
|
||||
WINDOW_IDLE_MIN=180
|
||||
IDLE_START=04:00
|
||||
IDLE_END=16:00
|
||||
ALLOW_EMPTY_RESULT_ADVANCE=true
|
||||
|
||||
# 清洗配置
|
||||
LOG_UNKNOWN_FIELDS=true
|
||||
HASH_ALGO=sha1
|
||||
STRICT_NUMERIC=true
|
||||
ROUND_MONEY_SCALE=2
|
||||
|
||||
# 测试/离线模式
|
||||
TEST_MODE=ONLINE
|
||||
TEST_JSON_ARCHIVE_DIR=tests/source-data-doc
|
||||
TEST_JSON_TEMP_DIR=/tmp/etl_billiards_json_tmp
|
||||
|
||||
# 测试数据库(可选:若设置则单元测试连入此 DSN)
|
||||
TEST_DB_DSN=
|
||||
|
||||
# ODS <20>ؽ<EFBFBD><D8BD>ű<EFBFBD><C5B1><EFBFBD><EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ã<EFBFBD>
|
||||
JSON_DOC_DIR=C:\dev\LLTQ\export\test-json-doc
|
||||
ODS_INCLUDE_FILES=
|
||||
ODS_DROP_SCHEMA_FIRST=true
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Simple PostgreSQL connectivity smoke-checker."""
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2 import OperationalError
|
||||
|
||||
|
||||
DEFAULT_DSN = os.environ.get(
|
||||
"PG_DSN", "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/LLZQ-test"
|
||||
)
|
||||
DEFAULT_TIMEOUT = max(1, min(int(os.environ.get("PG_CONNECT_TIMEOUT", 10)), 20))
|
||||
|
||||
|
||||
def check_postgres_connection(dsn: str, timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
"""Return True if connection succeeds; print diagnostics otherwise."""
|
||||
try:
|
||||
conn = psycopg2.connect(dsn, connect_timeout=timeout)
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1;")
|
||||
_ = cur.fetchone()
|
||||
print(f"PostgreSQL 连接成功 (timeout={timeout}s)")
|
||||
return True
|
||||
except OperationalError as exc:
|
||||
print("PostgreSQL 连接失败(OperationalError):", exc)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print("PostgreSQL 连接失败(其他异常):", exc)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
dsn = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_DSN
|
||||
if not dsn:
|
||||
print("缺少 DSN,请传入参数或设置 PG_DSN 环境变量。")
|
||||
sys.exit(2)
|
||||
|
||||
ok = check_postgres_connection(dsn)
|
||||
if not ok:
|
||||
sys.exit(1)
|
||||
BIN
etl_billiards/ETL_Manager.exe - 快捷方式.lnk
Normal file
BIN
etl_billiards/ETL_Manager.exe - 快捷方式.lnk
Normal file
Binary file not shown.
44
etl_billiards/ETL_Manager.spec
Normal file
44
etl_billiards/ETL_Manager.spec
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\gui\\main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\gui\\resources', 'gui/resources'), ('C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\database\\schema_dwd_doc.sql', 'database'), ('C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\database\\schema_dws.sql', 'database'), ('C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\database\\schema_etl_admin.sql', 'database'), ('C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\database\\schema_ODS_doc.sql', 'database'), ('C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\database\\seed_ods_tasks.sql', 'database'), ('C:\\dev\\LLTQ\\ETL\\feiqiu-ETL\\etl_billiards\\database\\seed_scheduler_tasks.sql', 'database')],
|
||||
hiddenimports=['PySide6.QtCore', 'PySide6.QtGui', 'PySide6.QtWidgets', 'psycopg2', 'psycopg2.extras', 'psycopg2.extensions', 'gui.models.task_model', 'gui.models.schedule_model', 'gui.utils.cli_builder', 'gui.utils.config_helper', 'gui.utils.app_settings', 'gui.workers.task_worker', 'gui.workers.db_worker', 'gui.widgets.settings_dialog'],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=['matplotlib', 'numpy', 'pandas', 'scipy', 'PIL', 'cv2', 'tkinter'],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='ETL_Manager',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='ETL_Manager',
|
||||
)
|
||||
@@ -1,837 +0,0 @@
|
||||
# 台球场 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/....
|
||||
|
||||
# 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 C:\dev\LLTQ\export\LOG/
|
||||
```
|
||||
|
||||
- 导出目录:使用 `EXPORT_ROOT` 指定,例如
|
||||
|
||||
```bash
|
||||
ls -la C:\dev\LLTQ\export\JSON/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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` – 数据库集成测试
|
||||
|
||||
#### 3.3.1 测试模式(ONLINE / OFFLINE)
|
||||
|
||||
- `TEST_MODE=ONLINE`(默认)时,测试会模拟实时 API,完整执行 E/T/L。
|
||||
- `TEST_MODE=OFFLINE` 时,测试改为从 `TEST_JSON_ARCHIVE_DIR` 指定的归档 JSON 中读取数据,仅做 Transform + Load,适合验证本地归档数据是否仍可回放。
|
||||
- `TEST_JSON_ARCHIVE_DIR`:离线 JSON 归档目录(示例:`tests/source-data-doc` 或 CI 产出的快照)。
|
||||
- `TEST_JSON_TEMP_DIR`:测试生成的临时 JSON 输出目录,便于隔离每次运行的数据。
|
||||
- `TEST_DB_DSN`:可选,若设置则单元测试会连接到此 PostgreSQL DSN,实打实执行写库;留空时测试使用内存伪库,避免依赖数据库。
|
||||
|
||||
示例命令:
|
||||
|
||||
```bash
|
||||
# 在线模式覆盖所有任务
|
||||
TEST_MODE=ONLINE pytest tests/unit/test_etl_tasks_online.py
|
||||
|
||||
# 离线模式使用归档 JSON 覆盖所有任务
|
||||
TEST_MODE=OFFLINE TEST_JSON_ARCHIVE_DIR=tests/source-data-doc pytest tests/unit/test_etl_tasks_offline.py
|
||||
|
||||
# 使用脚本按需组合参数(示例:在线 + 仅订单用例)
|
||||
python scripts/run_tests.py --suite online --mode ONLINE --keyword ORDERS
|
||||
|
||||
# 使用脚本连接真实测试库并回放离线模式
|
||||
python scripts/run_tests.py --suite offline --mode OFFLINE --db-dsn postgresql://user:pwd@localhost:5432/testdb
|
||||
|
||||
# 使用“指令仓库”中的预置命令
|
||||
python scripts/run_tests.py --preset offline_realdb
|
||||
python scripts/run_tests.py --list-presets # 查看或自定义 scripts/test_presets.py
|
||||
```
|
||||
|
||||
#### 3.3.2 脚本化测试组合(`run_tests.py` / `test_presets.py`)
|
||||
|
||||
- `scripts/run_tests.py` 是 pytest 的统一入口:自动把项目根目录加入 `sys.path`,并提供 `--suite online/offline/integration`、`--tests`(自定义路径)、`--mode`、`--db-dsn`、`--json-archive`、`--json-temp`、`--keyword/-k`、`--pytest-args`、`--env KEY=VALUE` 等参数,可以像搭积木一样自由组合;
|
||||
- `--preset foo` 会读取 `scripts/test_presets.py` 内 `PRESETS["foo"]` 的配置,并叠加到当前命令;`--list-presets` 与 `--dry-run` 可用来审阅或仅打印命令;
|
||||
- 直接执行 `python scripts/test_presets.py` 可依次运行 `AUTO_RUN_PRESETS` 中列出的预置;传入 `--preset x --dry-run` 则只打印对应命令。
|
||||
|
||||
`test_presets.py` 充当“指令仓库”。每个预置都是一个字典,常用字段解释如下:
|
||||
|
||||
| 字段 | 作用 |
|
||||
| ---------------------------- | ------------------------------------------------------------------ |
|
||||
| `suite` | 复用 `run_tests.py` 内置套件(online/offline/integration,可多选) |
|
||||
| `tests` | 追加任意 pytest 路径,例如 `tests/unit/test_config.py` |
|
||||
| `mode` | 覆盖 `TEST_MODE`(ONLINE / OFFLINE) |
|
||||
| `db_dsn` | 覆盖 `TEST_DB_DSN`,用于连入真实测试库 |
|
||||
| `json_archive` / `json_temp` | 配置离线 JSON 归档与临时目录 |
|
||||
| `keyword` | 映射到 `pytest -k`,用于关键字过滤 |
|
||||
| `pytest_args` | 附加 pytest 参数,例 `-vv --maxfail=1` |
|
||||
| `env` | 额外环境变量列表,如 `["STORE_ID=123"]` |
|
||||
| `preset_meta` | 说明性文字,便于描述场景 |
|
||||
|
||||
示例:`offline_realdb` 预置会设置 `TEST_MODE=OFFLINE`、指定 `tests/source-data-doc` 为归档目录,并通过 `db_dsn` 连到测试库。执行 `python scripts/run_tests.py --preset offline_realdb` 或 `python scripts/test_presets.py --preset offline_realdb` 即可复用该组合,保证本地、CI 与生产回放脚本一致。
|
||||
|
||||
#### 3.3.3 数据库连通性快速检查
|
||||
|
||||
`python scripts/test_db_connection.py` 提供最轻量的 PostgreSQL 连通性检测:默认使用 `TEST_DB_DSN`(也可传 `--dsn`),尝试连接并执行 `SELECT 1 AS ok`(可通过 `--query` 自定义)。典型用途:
|
||||
|
||||
```bash
|
||||
# 读取 .env/环境变量中的 TEST_DB_DSN
|
||||
python scripts/test_db_connection.py
|
||||
|
||||
# 临时指定 DSN,并检查任务配置表
|
||||
python scripts/test_db_connection.py --dsn postgresql://user:pwd@host:5432/.... --query "SELECT count(*) FROM etl_admin.etl_task"
|
||||
```
|
||||
|
||||
脚本返回 0 代表连接与查询成功;若返回非 0,可结合第 8 章“常见问题排查”的数据库章节(网络、防火墙、账号权限等)先定位问题,再运行完整 ETL。
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
│ ├── testdata_json/ # 清洗入库用的测试Json文件
|
||||
│ │ └── XX.json
|
||||
│ └── 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 请求失败时按配置进行重试,超过重试次数记录错误并终止该任务。
|
||||
- 所有错误被记录到日志和运行追踪表,便于事后排查。
|
||||
|
||||
### 5.6 ODS + DWD 双阶段策略(新增)
|
||||
|
||||
为了支撑回溯/重放与后续 DWD 宽表构建,项目新增了 `billiards_ods` Schema 以及一组专门的 ODS 任务/Loader:
|
||||
|
||||
- **ODS 表**:`billiards_ods.ods_order_settle`、`ods_table_use_detail`、`ods_assistant_ledger`、`ods_assistant_abolish`、`ods_goods_ledger`、`ods_payment`、`ods_refund`、`ods_coupon_verify`、`ods_member`、`ods_member_card`、`ods_package_coupon`、`ods_inventory_stock`、`ods_inventory_change`。每条记录都会保存 `store_id + 源主键 + payload JSON + fetched_at + source_endpoint` 等信息。
|
||||
- **通用 Loader**:`loaders/ods/generic.py::GenericODSLoader` 统一封装了 `INSERT ... ON CONFLICT ...` 与批量写入逻辑,调用方只需提供列名与主键列即可。
|
||||
- **ODS 任务**:`tasks/ods_tasks.py` 内通过 `OdsTaskSpec` 定义了一组任务(`ODS_ORDER_SETTLE`、`ODS_PAYMENT`、`ODS_ASSISTANT_LEDGER` 等),并在 `TaskRegistry` 中自动注册,可直接通过 `python -m cli.main --tasks ODS_ORDER_SETTLE,ODS_PAYMENT` 执行。
|
||||
- **双阶段链路**:
|
||||
1. 阶段 1(ODS):调用 API/离线归档 JSON,将原始记录写入 ODS 表,保留分页、抓取时间、来源文件等元数据。
|
||||
2. 阶段 2(DWD/DIM):后续订单、支付、券等事实任务将改为从 ODS 读取 payload,经过解析/校验后写入 `billiards.fact_*`、`dim_*` 表,避免重复拉取上游接口。
|
||||
|
||||
> 新增的单元测试 `tests/unit/test_ods_tasks.py` 覆盖了 `ODS_ORDER_SETTLE`、`ODS_PAYMENT` 的入库路径,可作为扩展其他 ODS 任务的模板。
|
||||
|
||||
---
|
||||
|
||||
## 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. 参考说明
|
||||
|
||||
- 本文已合并原有的快速开始、项目结构、架构说明、迁移指南等内容,可作为当前项目的统一说明文档。
|
||||
- 如需在此基础上拆分多份文档,可按章节拆出,例如「快速开始」「架构设计」「迁移指南」「开发扩展」等。
|
||||
|
||||
## 11. 运行/调试模式说明
|
||||
|
||||
- 生产环境仅保留“任务模式”:通过调度/CLI 执行注册的任务(ETL/ODS),不使用调试脚本。
|
||||
- 开发/调试可使用的辅助脚本(上线前可删除或禁用):
|
||||
- `python -m etl_billiards.scripts.rebuild_ods_from_json`:从本地 JSON 目录重建 `billiards_ods`,用于离线初始化/验证。环境变量:`PG_DSN`(必填)、`JSON_DOC_DIR`(可选,默认 `C:\dev\LLTQ\export\test-json-doc`)、`INCLUDE_FILES`(逗号分隔文件名)、`DROP_SCHEMA_FIRST`(默认 true)。
|
||||
- 如需在生产环境保留脚本,请在运维手册中明确用途和禁用条件,避免误用。
|
||||
|
||||
## 12. ODS 任务上线指引
|
||||
|
||||
- 任务注册:`etl_billiards/database/seed_ods_tasks.sql` 列出了当前启用的 ODS 任务。将其中的 `store_id` 替换为实际门店后执行:
|
||||
```
|
||||
psql "$PG_DSN" -f etl_billiards/database/seed_ods_tasks.sql
|
||||
```
|
||||
`ON CONFLICT` 会保持 enabled=true,避免重复。
|
||||
- 调度:确认 `etl_admin.etl_task` 中已启用所需的 ODS 任务(任务代码见 seed 脚本),调度器或 CLI `--tasks` 即可调用。
|
||||
- 离线回灌:开发环境可用 `rebuild_ods_from_json` 以样例 JSON 初始化 ODS,生产慎用;默认按 `(source_file, record_index)` 去重。
|
||||
- 测试:`pytest etl_billiards/tests/unit/test_ods_tasks.py` 覆盖核心 ODS 任务;测试时可设置 `ETL_SKIP_DOTENV=1` 跳过本地 .env 读取。
|
||||
|
||||
## 13. ODS 表映射总览
|
||||
|
||||
| ODS 表名 | 接口 Path | 数据列表路径 |
|
||||
| ------------------------------------ | ---------------------------------------------------- | ----------------------------- |
|
||||
| `assistant_accounts_master` | `/PersonnelManagement/SearchAssistantInfo` | data.assistantInfos |
|
||||
| `assistant_service_records` | `/AssistantPerformance/GetOrderAssistantDetails` | data.orderAssistantDetails |
|
||||
| `assistant_cancellation_records` | `/AssistantPerformance/GetAbolitionAssistant` | data.abolitionAssistants |
|
||||
| `goods_stock_movements` | `/GoodsStockManage/QueryGoodsOutboundReceipt` | data.queryDeliveryRecordsList |
|
||||
| `goods_stock_summary` | `/TenantGoods/GetGoodsStockReport` | data |
|
||||
| `group_buy_packages` | `/PackageCoupon/QueryPackageCouponList` | data.packageCouponList |
|
||||
| `group_buy_redemption_records` | `/Site/GetSiteTableUseDetails` | data.siteTableUseDetailsList |
|
||||
| `member_profiles` | `/MemberProfile/GetTenantMemberList` | data.tenantMemberInfos |
|
||||
| `member_balance_changes` | `/MemberProfile/GetMemberCardBalanceChange` | data.tenantMemberCardLogs |
|
||||
| `member_stored_value_cards` | `/MemberProfile/GetTenantMemberCardList` | data.tenantMemberCards |
|
||||
| `payment_transactions` | `/PayLog/GetPayLogListPage` | data |
|
||||
| `platform_coupon_redemption_records` | `/Promotion/GetOfflineCouponConsumePageList` | data |
|
||||
| `recharge_settlements` | `/Site/GetRechargeSettleList` | data.settleList |
|
||||
| `refund_transactions` | `/Order/GetRefundPayLogList` | data |
|
||||
| `settlement_records` | `/Site/GetAllOrderSettleList` | data.settleList |
|
||||
| `settlement_ticket_details` | `/Order/GetOrderSettleTicketNew` | (整包原始 JSON) |
|
||||
| `site_tables_master` | `/Table/GetSiteTables` | data.siteTables |
|
||||
| `stock_goods_category_tree` | `/TenantGoodsCategory/QueryPrimarySecondaryCategory` | data.goodsCategoryList |
|
||||
| `store_goods_master` | `/TenantGoods/GetGoodsInventoryList` | data.orderGoodsList |
|
||||
| `store_goods_sales_records` | `/TenantGoods/GetGoodsSalesList` | data.orderGoodsLedgers |
|
||||
| `table_fee_discount_records` | `/Site/GetTaiFeeAdjustList` | data.taiFeeAdjustInfos |
|
||||
| `table_fee_transactions` | `/Site/GetSiteTableOrderDetails` | data.siteTableUseDetailsList |
|
||||
| `tenant_goods_master` | `/TenantGoods/QueryTenantGoods` | data.tenantGoodsList |
|
||||
|
||||
## 14. ODS 相关环境变量/默认值
|
||||
|
||||
- `.env` / 环境变量:
|
||||
- `JSON_DOC_DIR`:ODS 样例 JSON 目录(开发/回灌用)
|
||||
- `ODS_INCLUDE_FILES`:限定导入的文件名(逗号分隔,不含 .json)
|
||||
- `ODS_DROP_SCHEMA_FIRST`:true/false,是否重建 schema
|
||||
- `ETL_SKIP_DOTENV`:测试/CI 时设为 1 跳过本地 .env 读取
|
||||
- `config/defaults.py` 中 `ods` 默认值:
|
||||
- `json_doc_dir`: `C:\dev\LLTQ\export\test-json-doc`
|
||||
- `include_files`: `""`
|
||||
- `drop_schema_first`: `True`
|
||||
|
||||
---
|
||||
|
||||
## 15. DWD 维度 “业务事件”
|
||||
|
||||
1. 粒度唯一、原子
|
||||
|
||||
- 一张 DWD 表只能有一种业务粒度,比如:
|
||||
- 一条记录 = 一次结账;
|
||||
- 一条记录 = 一段台费流水;
|
||||
- 一条记录 = 一次助教服务;
|
||||
- 一条记录 = 一次会员余额变动。
|
||||
- 表里面不能又混“订单头”又混“订单行”,不能一部分是“汇总”,一部分是“明细”。
|
||||
- 一旦粒度确定,所有字段都要跟这个粒度匹配:
|
||||
- 比如“结账头表”就不要塞每一行商品明细;
|
||||
- 商品明细就不要塞整单级别的总金额。
|
||||
- 这是 DWD 层最重要的一条。
|
||||
|
||||
2. 以业务过程建模,不以 JSON 列表建模
|
||||
|
||||
- 先画清楚你真实的业务链路:
|
||||
- 开台 / 换台 / 关台 → 台费流水
|
||||
- 助教上桌 → 助教服务流水 / 废除事件
|
||||
- 点单 → 商品销售流水
|
||||
- 充值 / 消费 → 余额变更 / 充值单
|
||||
- 结账 → 结账头表 + 支付流水 / 退款流水
|
||||
- 团购 / 平台券 → 核销流水
|
||||
|
||||
3. 主键明确、外键统一
|
||||
|
||||
- 每张 DWD 表必须有业务主键(哪怕是接口给的 id),不要依赖数据库自增。
|
||||
- 所有“同一概念”的字段必须统一命名、统一含义:
|
||||
- 门店:统一叫 site_id,都对应 siteProfile.id;
|
||||
- 会员:统一叫 member_id 对应 member_profiles.id,system_member_id 单独一列;
|
||||
- 台桌:统一 table_id 对应 site_tables_master.id;
|
||||
- 结账:统一 order_settle_id;
|
||||
- 订单:统一 order_trade_no 等。
|
||||
- 否则后面 DWS、AI 要把表拼起来会非常痛苦。
|
||||
|
||||
4. 保留明细,不做过度汇总
|
||||
|
||||
- DWD 层的事实表原则上只做“明细级”的数据:
|
||||
- 不要在 DWD 就把“日汇总、周汇总、月汇总”算出来,那是 DWS 的事;
|
||||
- 也不要把多个事件折成一行(例如一张表同时放日汇总+单笔流水)。
|
||||
- 需要聚合时,再在 DWS 做主题宽表:
|
||||
- dws_member_day_profile、dws_site_day_summary 等。
|
||||
- DWD 只负责细颗粒度的真相。
|
||||
|
||||
5. 统一清洗、标准化,但保持可追溯
|
||||
|
||||
- 在 DWD 层一定要做的清洗:
|
||||
- 类型转换:字符串时间 → 时间类型,金额统一为 decimal,布尔统一为 0/1;
|
||||
- 单位统一:秒 / 分钟、元 / 分都统一;
|
||||
- 枚举标准化:状态码、类型码在 DWD 里就定死含义,必要时建枚举维表。
|
||||
- 同时要保证:
|
||||
- 每条 DWD 记录都能追溯回 ODS:
|
||||
- 保留源系统主键;
|
||||
- 保留原始时间 / 原始金额字段(不要覆盖掉)。
|
||||
|
||||
6. 扁平化、去嵌套
|
||||
|
||||
- JSON 里常见结构是:分页壳 + 头 + 明细数组 + 各种嵌套对象(siteProfile、tableProfile、goodsLedgers…)。
|
||||
- DWD 的原则是:
|
||||
- 去掉分页壳;
|
||||
- 把“数组”拆成子表(头表 / 行表);
|
||||
- 把重复出现的 profile 抽出去做维度表(门店、台、商品、会员……)。
|
||||
- 目标是:DWD 表都是二维表结构,不存复杂嵌套 JSON。
|
||||
|
||||
7. 模型长期稳定,可扩展
|
||||
|
||||
- DWD 的表结构要尽可能稳定,新增需求尽量通过:
|
||||
- 加字段;
|
||||
- 新建事实表 / 维度表;
|
||||
- 在 DWS 做派生指标;
|
||||
- 而不是频繁重构已有 DWD 表结构。
|
||||
- 这点跟你后面要喂给 LLM 也很相关:AI 配的 prompt、schema 理解都要尽量少改。
|
||||
@@ -8,6 +8,8 @@ import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from api.endpoint_routing import plan_calls
|
||||
|
||||
DEFAULT_BROWSER_HEADERS = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
@@ -142,7 +144,7 @@ class APIClient:
|
||||
raise ValueError(f"API 返回错误 code={code} msg={msg}")
|
||||
|
||||
# ------------------------------------------------------------------ 分页
|
||||
def iter_paginated(
|
||||
def _iter_paginated_single(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict | None,
|
||||
@@ -155,8 +157,7 @@ class APIClient:
|
||||
page_end: int | None = None,
|
||||
) -> Iterable[tuple[int, list, dict, dict]]:
|
||||
"""
|
||||
分页迭代器:逐页拉取数据并产出 (page_no, records, request_params, raw_response)。
|
||||
page_size=None 时不附带分页参数,仅拉取一次。
|
||||
单一 endpoint 的分页迭代器(不包含 recent/former 路由逻辑)。
|
||||
"""
|
||||
base_params = dict(params or {})
|
||||
page = page_start
|
||||
@@ -183,6 +184,42 @@ class APIClient:
|
||||
|
||||
page += 1
|
||||
|
||||
def iter_paginated(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict | None,
|
||||
page_size: int | None = 200,
|
||||
page_field: str = "page",
|
||||
size_field: str = "limit",
|
||||
data_path: tuple = ("data",),
|
||||
list_key: str | Sequence[str] | None = None,
|
||||
page_start: int = 1,
|
||||
page_end: int | None = None,
|
||||
) -> Iterable[tuple[int, list, dict, dict]]:
|
||||
"""
|
||||
分页迭代器:逐页拉取数据并产出 (page_no, records, request_params, raw_response)。
|
||||
page_size=None 时不附带分页参数,仅拉取一次。
|
||||
"""
|
||||
# recent/former 路由:当 params 带时间范围字段时,按“3个月自然月”边界决定走哪个 endpoint,
|
||||
# 跨越边界则拆分为两段请求并顺序产出,确保调用方使用 page_no 命名文件时不会被覆盖。
|
||||
call_plan = plan_calls(endpoint, params)
|
||||
global_page = 1
|
||||
|
||||
for call in call_plan:
|
||||
for _, records, request_params, payload in self._iter_paginated_single(
|
||||
endpoint=call.endpoint,
|
||||
params=call.params,
|
||||
page_size=page_size,
|
||||
page_field=page_field,
|
||||
size_field=size_field,
|
||||
data_path=data_path,
|
||||
list_key=list_key,
|
||||
page_start=page_start,
|
||||
page_end=page_end,
|
||||
):
|
||||
yield global_page, records, request_params, payload
|
||||
global_page += 1
|
||||
|
||||
def get_paginated(
|
||||
self,
|
||||
endpoint: str,
|
||||
|
||||
166
etl_billiards/api/endpoint_routing.py
Normal file
166
etl_billiards/api/endpoint_routing.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
“近期记录 / 历史记录(Former)”接口路由规则。
|
||||
|
||||
需求:
|
||||
- 当请求参数包含可定义时间范围的字段时,根据当前时间(北京时间/上海时区)判断:
|
||||
- 3个月(自然月)之前 -> 使用“历史记录”接口
|
||||
- 3个月以内 -> 使用“近期记录”接口
|
||||
- 若时间范围跨越边界 -> 拆分为两段分别请求并合并(由上层分页迭代器顺序产出)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from dateutil import parser as dtparser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
ROUTING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
RECENT_MONTHS = 3
|
||||
|
||||
|
||||
# 按 `fetch-test/recent_vs_former_report.md` 更新(“无”表示没有历史接口;相同 path 表示同一个接口可查历史)
|
||||
RECENT_TO_FORMER_OVERRIDES: dict[str, str | None] = {
|
||||
"/AssistantPerformance/GetAbolitionAssistant": None,
|
||||
"/Site/GetSiteTableUseDetails": "/Site/GetSiteTableUseDetails",
|
||||
"/GoodsStockManage/QueryGoodsOutboundReceipt": "/GoodsStockManage/QueryFormerGoodsOutboundReceipt",
|
||||
"/Promotion/GetOfflineCouponConsumePageList": "/Promotion/GetOfflineCouponConsumePageList",
|
||||
"/Order/GetRefundPayLogList": None,
|
||||
# 已知特殊
|
||||
"/Site/GetAllOrderSettleList": "/Site/GetFormerOrderSettleList",
|
||||
"/PayLog/GetPayLogListPage": "/PayLog/GetFormerPayLogListPage",
|
||||
}
|
||||
|
||||
|
||||
TIME_WINDOW_KEYS: tuple[tuple[str, str], ...] = (
|
||||
("startTime", "endTime"),
|
||||
("rangeStartTime", "rangeEndTime"),
|
||||
("StartPayTime", "EndPayTime"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowSpec:
|
||||
start_key: str
|
||||
end_key: str
|
||||
start: datetime
|
||||
end: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutedCall:
|
||||
endpoint: str
|
||||
params: dict
|
||||
|
||||
|
||||
def is_former_endpoint(endpoint: str) -> bool:
|
||||
return "Former" in str(endpoint or "")
|
||||
|
||||
|
||||
def _parse_dt(value: object, tz: ZoneInfo) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
dt = dtparser.parse(s)
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=tz)
|
||||
return dt.astimezone(tz)
|
||||
|
||||
|
||||
def _fmt_dt(dt: datetime, tz: ZoneInfo) -> str:
|
||||
return dt.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def extract_window_spec(params: dict | None, tz: ZoneInfo = ROUTING_TZ) -> WindowSpec | None:
|
||||
if not isinstance(params, dict) or not params:
|
||||
return None
|
||||
for start_key, end_key in TIME_WINDOW_KEYS:
|
||||
if start_key in params or end_key in params:
|
||||
start = _parse_dt(params.get(start_key), tz)
|
||||
end = _parse_dt(params.get(end_key), tz)
|
||||
if start and end:
|
||||
return WindowSpec(start_key=start_key, end_key=end_key, start=start, end=end)
|
||||
return None
|
||||
|
||||
|
||||
def derive_former_endpoint(recent_endpoint: str) -> str | None:
|
||||
endpoint = str(recent_endpoint or "").strip()
|
||||
if not endpoint:
|
||||
return None
|
||||
|
||||
if endpoint in RECENT_TO_FORMER_OVERRIDES:
|
||||
return RECENT_TO_FORMER_OVERRIDES[endpoint]
|
||||
|
||||
if is_former_endpoint(endpoint):
|
||||
return endpoint
|
||||
|
||||
idx = endpoint.find("Get")
|
||||
if idx == -1:
|
||||
return endpoint
|
||||
return f"{endpoint[:idx]}GetFormer{endpoint[idx + 3:]}"
|
||||
|
||||
|
||||
def recent_boundary(now: datetime, months: int = RECENT_MONTHS) -> datetime:
|
||||
"""
|
||||
3个月(自然月)边界:取 (now - months) 所在月份的 1 号 00:00:00。
|
||||
"""
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now 必须为时区时间")
|
||||
base = now - relativedelta(months=months)
|
||||
return base.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def plan_calls(
|
||||
endpoint: str,
|
||||
params: dict | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
tz: ZoneInfo = ROUTING_TZ,
|
||||
months: int = RECENT_MONTHS,
|
||||
) -> list[RoutedCall]:
|
||||
"""
|
||||
根据 endpoint + params 的时间窗口,返回要调用的 endpoint/params 列表(可能拆分为两段)。
|
||||
"""
|
||||
base_params = dict(params or {})
|
||||
if not base_params:
|
||||
return [RoutedCall(endpoint=endpoint, params=base_params)]
|
||||
|
||||
# 若调用方显式传了 Former 接口,则不二次路由。
|
||||
if is_former_endpoint(endpoint):
|
||||
return [RoutedCall(endpoint=endpoint, params=base_params)]
|
||||
|
||||
window = extract_window_spec(base_params, tz)
|
||||
if not window:
|
||||
return [RoutedCall(endpoint=endpoint, params=base_params)]
|
||||
|
||||
former_endpoint = derive_former_endpoint(endpoint)
|
||||
if former_endpoint is None or former_endpoint == endpoint:
|
||||
return [RoutedCall(endpoint=endpoint, params=base_params)]
|
||||
|
||||
now_dt = (now or datetime.now(tz)).astimezone(tz)
|
||||
boundary = recent_boundary(now_dt, months=months)
|
||||
|
||||
start, end = window.start, window.end
|
||||
if end <= boundary:
|
||||
return [RoutedCall(endpoint=former_endpoint, params=base_params)]
|
||||
if start >= boundary:
|
||||
return [RoutedCall(endpoint=endpoint, params=base_params)]
|
||||
|
||||
# 跨越边界:拆分两段(老数据 -> former,新数据 -> recent)
|
||||
p1 = dict(base_params)
|
||||
p1[window.start_key] = _fmt_dt(start, tz)
|
||||
p1[window.end_key] = _fmt_dt(boundary, tz)
|
||||
|
||||
p2 = dict(base_params)
|
||||
p2[window.start_key] = _fmt_dt(boundary, tz)
|
||||
p2[window.end_key] = _fmt_dt(end, tz)
|
||||
|
||||
return [RoutedCall(endpoint=former_endpoint, params=p1), RoutedCall(endpoint=endpoint, params=p2)]
|
||||
|
||||
@@ -20,6 +20,10 @@ class LocalJsonClient:
|
||||
if not self.base_dir.exists():
|
||||
raise FileNotFoundError(f"JSON 目录不存在: {self.base_dir}")
|
||||
|
||||
def get_source_hint(self, endpoint: str) -> str:
|
||||
"""Return the JSON file path for this endpoint (for source_file lineage)."""
|
||||
return str(self.base_dir / endpoint_to_filename(endpoint))
|
||||
|
||||
def iter_paginated(
|
||||
self,
|
||||
endpoint: str,
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from typing import Any, Iterable, Tuple
|
||||
|
||||
from api.client import APIClient
|
||||
from api.endpoint_routing import plan_calls
|
||||
from utils.json_store import dump_json, endpoint_to_filename
|
||||
|
||||
|
||||
@@ -33,6 +34,10 @@ class RecordingAPIClient:
|
||||
self.last_dump: dict[str, Any] | None = None
|
||||
|
||||
# ------------------------------------------------------------------ public API
|
||||
def get_source_hint(self, endpoint: str) -> str:
|
||||
"""Return the JSON dump path for this endpoint (for source_file lineage)."""
|
||||
return str(self.output_dir / endpoint_to_filename(endpoint))
|
||||
|
||||
def iter_paginated(
|
||||
self,
|
||||
endpoint: str,
|
||||
@@ -99,11 +104,18 @@ class RecordingAPIClient:
|
||||
):
|
||||
filename = endpoint_to_filename(endpoint)
|
||||
path = self.output_dir / filename
|
||||
routing_calls = []
|
||||
try:
|
||||
for call in plan_calls(endpoint, params):
|
||||
routing_calls.append({"endpoint": call.endpoint, "params": call.params})
|
||||
except Exception:
|
||||
routing_calls = []
|
||||
payload = {
|
||||
"task_code": self.task_code,
|
||||
"run_id": self.run_id,
|
||||
"endpoint": endpoint,
|
||||
"params": params or {},
|
||||
"endpoint_routing": {"calls": routing_calls} if routing_calls else None,
|
||||
"page_size": page_size,
|
||||
"pages": pages,
|
||||
"total_records": total_records,
|
||||
|
||||
181
etl_billiards/build_exe.py
Normal file
181
etl_billiards/build_exe.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ETL GUI 打包脚本
|
||||
|
||||
使用 PyInstaller 将 GUI 应用打包为 Windows EXE
|
||||
|
||||
用法:
|
||||
python build_exe.py [--onefile] [--console] [--clean]
|
||||
|
||||
参数:
|
||||
--onefile 打包为单个 EXE 文件(默认为目录模式)
|
||||
--console 显示控制台窗口(调试用)
|
||||
--clean 打包前清理旧的构建文件
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""获取项目根目录"""
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def clean_build():
|
||||
"""清理旧的构建文件"""
|
||||
project_root = get_project_root()
|
||||
|
||||
dirs_to_clean = [
|
||||
project_root / "build",
|
||||
project_root / "dist",
|
||||
]
|
||||
|
||||
files_to_clean = [
|
||||
project_root / "etl_gui.spec",
|
||||
]
|
||||
|
||||
for d in dirs_to_clean:
|
||||
if d.exists():
|
||||
print(f"清理目录: {d}")
|
||||
shutil.rmtree(d)
|
||||
|
||||
for f in files_to_clean:
|
||||
if f.exists():
|
||||
print(f"清理文件: {f}")
|
||||
f.unlink()
|
||||
|
||||
|
||||
def build_exe(onefile: bool = False, console: bool = False):
|
||||
"""构建 EXE"""
|
||||
project_root = get_project_root()
|
||||
|
||||
# 主入口
|
||||
main_script = project_root / "gui" / "main.py"
|
||||
|
||||
# 资源文件
|
||||
resources_dir = project_root / "gui" / "resources"
|
||||
database_dir = project_root / "database"
|
||||
|
||||
# 构建 PyInstaller 命令
|
||||
# 使用 ASCII 名称避免 Windows 控制台编码问题
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "ETL_Manager",
|
||||
"--noconfirm",
|
||||
]
|
||||
|
||||
# 单文件或目录模式
|
||||
if onefile:
|
||||
cmd.append("--onefile")
|
||||
else:
|
||||
cmd.append("--onedir")
|
||||
|
||||
# 窗口模式
|
||||
if not console:
|
||||
cmd.append("--windowed")
|
||||
|
||||
# 添加数据文件
|
||||
# 样式表
|
||||
if resources_dir.exists():
|
||||
cmd.extend(["--add-data", f"{resources_dir};gui/resources"])
|
||||
|
||||
# 数据库 SQL 文件
|
||||
if database_dir.exists():
|
||||
for sql_file in database_dir.glob("*.sql"):
|
||||
cmd.extend(["--add-data", f"{sql_file};database"])
|
||||
|
||||
# 隐式导入
|
||||
hidden_imports = [
|
||||
"PySide6.QtCore",
|
||||
"PySide6.QtGui",
|
||||
"PySide6.QtWidgets",
|
||||
"psycopg2",
|
||||
"psycopg2.extras",
|
||||
"psycopg2.extensions",
|
||||
# GUI 模块
|
||||
"gui.models.task_model",
|
||||
"gui.models.schedule_model",
|
||||
"gui.utils.cli_builder",
|
||||
"gui.utils.config_helper",
|
||||
"gui.utils.app_settings",
|
||||
"gui.workers.task_worker",
|
||||
"gui.workers.db_worker",
|
||||
"gui.widgets.settings_dialog",
|
||||
]
|
||||
for imp in hidden_imports:
|
||||
cmd.extend(["--hidden-import", imp])
|
||||
|
||||
# 排除不需要的模块(减小体积)
|
||||
excludes = [
|
||||
"matplotlib",
|
||||
"numpy",
|
||||
"pandas",
|
||||
"scipy",
|
||||
"PIL",
|
||||
"cv2",
|
||||
"tkinter",
|
||||
]
|
||||
for exc in excludes:
|
||||
cmd.extend(["--exclude-module", exc])
|
||||
|
||||
# 工作目录
|
||||
cmd.extend(["--workpath", str(project_root / "build")])
|
||||
cmd.extend(["--distpath", str(project_root / "dist")])
|
||||
cmd.extend(["--specpath", str(project_root)])
|
||||
|
||||
# 主脚本
|
||||
cmd.append(str(main_script))
|
||||
|
||||
print("执行命令:")
|
||||
print(" ".join(cmd))
|
||||
print()
|
||||
|
||||
# 执行打包
|
||||
result = subprocess.run(cmd, cwd=str(project_root))
|
||||
|
||||
if result.returncode == 0:
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("打包成功!")
|
||||
print(f"输出目录: {project_root / 'dist'}")
|
||||
print("=" * 50)
|
||||
else:
|
||||
print()
|
||||
print("打包失败,请检查错误信息")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="ETL GUI 打包工具")
|
||||
parser.add_argument("--onefile", action="store_true", help="打包为单个 EXE")
|
||||
parser.add_argument("--console", action="store_true", help="显示控制台窗口")
|
||||
parser.add_argument("--clean", action="store_true", help="打包前清理")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 检查 PyInstaller
|
||||
try:
|
||||
import PyInstaller
|
||||
print(f"PyInstaller 版本: {PyInstaller.__version__}")
|
||||
except ImportError:
|
||||
print("错误: 未安装 PyInstaller")
|
||||
print("请运行: pip install pyinstaller")
|
||||
sys.exit(1)
|
||||
|
||||
# 清理
|
||||
if args.clean:
|
||||
clean_build()
|
||||
|
||||
# 构建
|
||||
build_exe(onefile=args.onefile, console=args.console)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -40,6 +40,34 @@ def parse_args():
|
||||
parser.add_argument("--api-timeout", type=int, help="API超时(秒)")
|
||||
parser.add_argument("--api-page-size", type=int, help="分页大小")
|
||||
parser.add_argument("--api-retry-max", type=int, help="API重试最大次数")
|
||||
|
||||
# 回溯/手动窗口
|
||||
parser.add_argument(
|
||||
"--window-start",
|
||||
dest="window_start",
|
||||
help="固定时间窗口开始(优先级高于游标,例如:2025-07-01 00:00:00)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--window-end",
|
||||
dest="window_end",
|
||||
help="固定时间窗口结束(优先级高于游标,推荐用月末+1,例如:2025-08-01 00:00:00)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force-window-override",
|
||||
action="store_true",
|
||||
help="强制使用 window_start/window_end,不走 MAX(fetched_at) 兜底",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--window-split-unit",
|
||||
dest="window_split_unit",
|
||||
help="窗口切分单位(month/none),默认来自配置 run.window_split.unit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--window-compensation-hours",
|
||||
dest="window_compensation_hours",
|
||||
type=int,
|
||||
help="窗口前后补偿小时数,默认来自配置 run.window_split.compensation_hours",
|
||||
)
|
||||
|
||||
# 目录参数
|
||||
parser.add_argument("--export-root", help="导出根目录")
|
||||
@@ -108,6 +136,22 @@ def build_cli_overrides(args) -> dict:
|
||||
if args.write_pretty_json:
|
||||
overrides.setdefault("io", {})["write_pretty_json"] = True
|
||||
|
||||
# 回溯/手动窗口
|
||||
if args.window_start or args.window_end:
|
||||
overrides.setdefault("run", {}).setdefault("window_override", {})
|
||||
if args.window_start:
|
||||
overrides["run"]["window_override"]["start"] = args.window_start
|
||||
if args.window_end:
|
||||
overrides["run"]["window_override"]["end"] = args.window_end
|
||||
if args.force_window_override:
|
||||
overrides.setdefault("run", {})["force_window_override"] = True
|
||||
if args.window_split_unit:
|
||||
overrides.setdefault("run", {}).setdefault("window_split", {})["unit"] = args.window_split_unit
|
||||
if args.window_compensation_hours is not None:
|
||||
overrides.setdefault("run", {}).setdefault("window_split", {})[
|
||||
"compensation_hours"
|
||||
] = args.window_compensation_hours
|
||||
|
||||
# 运行窗口
|
||||
if args.idle_start:
|
||||
overrides.setdefault("run", {}).setdefault("idle_window", {})["start"] = args.idle_start
|
||||
|
||||
@@ -58,6 +58,10 @@ DEFAULTS = {
|
||||
"default_idle": 180,
|
||||
},
|
||||
"overlap_seconds": 120,
|
||||
"window_split": {
|
||||
"unit": "month",
|
||||
"compensation_hours": 2,
|
||||
},
|
||||
"idle_window": {
|
||||
"start": "04:00",
|
||||
"end": "16:00",
|
||||
@@ -65,8 +69,8 @@ DEFAULTS = {
|
||||
"allow_empty_result_advance": True,
|
||||
},
|
||||
"io": {
|
||||
"export_root": r"C:\dev\LLTQ\export\JSON",
|
||||
"log_root": r"C:\dev\LLTQ\export\LOG",
|
||||
"export_root": "export/JSON",
|
||||
"log_root": "export/LOG",
|
||||
"manifest_name": "manifest.json",
|
||||
"ingest_report_name": "ingest_report.json",
|
||||
"write_pretty_json": True,
|
||||
@@ -76,7 +80,7 @@ DEFAULTS = {
|
||||
# 运行流程:FETCH_ONLY(仅在线抓取落盘)、INGEST_ONLY(本地清洗入库)、FULL(抓取 + 清洗入库)
|
||||
"flow": "FULL",
|
||||
# 在线抓取 JSON 输出根目录(按任务、run_id 与时间自动创建子目录)
|
||||
"fetch_root": r"C:\dev\LLTQ\export\JSON",
|
||||
"fetch_root": "export/JSON",
|
||||
# 本地清洗入库时的 JSON 输入目录(为空则默认使用本次抓取目录)
|
||||
"ingest_source_dir": "",
|
||||
},
|
||||
@@ -97,10 +101,19 @@ DEFAULTS = {
|
||||
},
|
||||
"ods": {
|
||||
# ODS 离线重建/回放相关(仅开发/运维使用)
|
||||
"json_doc_dir": r"C:\dev\LLTQ\export\test-json-doc",
|
||||
"json_doc_dir": "export/test-json-doc",
|
||||
"include_files": "",
|
||||
"drop_schema_first": True,
|
||||
},
|
||||
"integrity": {
|
||||
"mode": "history",
|
||||
"history_start": "2025-07-01",
|
||||
"history_end": "",
|
||||
"include_dimensions": False,
|
||||
"auto_check": False,
|
||||
"ods_task_codes": "",
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
# 任务代码常量
|
||||
|
||||
@@ -40,11 +40,22 @@ ENV_MAP = {
|
||||
"IDLE_WINDOW_END": ("run.idle_window.end",),
|
||||
"ALLOW_EMPTY_RESULT_ADVANCE": ("run.allow_empty_result_advance",),
|
||||
"ALLOW_EMPTY_ADVANCE": ("run.allow_empty_result_advance",),
|
||||
"WINDOW_START": ("run.window_override.start",),
|
||||
"WINDOW_END": ("run.window_override.end",),
|
||||
"WINDOW_SPLIT_UNIT": ("run.window_split.unit",),
|
||||
"WINDOW_COMPENSATION_HOURS": ("run.window_split.compensation_hours",),
|
||||
"PIPELINE_FLOW": ("pipeline.flow",),
|
||||
"JSON_FETCH_ROOT": ("pipeline.fetch_root",),
|
||||
"JSON_SOURCE_DIR": ("pipeline.ingest_source_dir",),
|
||||
"FETCH_ROOT": ("pipeline.fetch_root",),
|
||||
"INGEST_SOURCE_DIR": ("pipeline.ingest_source_dir",),
|
||||
"INTEGRITY_MODE": ("integrity.mode",),
|
||||
"INTEGRITY_HISTORY_START": ("integrity.history_start",),
|
||||
"INTEGRITY_HISTORY_END": ("integrity.history_end",),
|
||||
"INTEGRITY_INCLUDE_DIMENSIONS": ("integrity.include_dimensions",),
|
||||
"INTEGRITY_AUTO_CHECK": ("integrity.auto_check",),
|
||||
"INTEGRITY_AUTO_BACKFILL": ("integrity.auto_backfill",),
|
||||
"INTEGRITY_ODS_TASK_CODES": ("integrity.ods_task_codes",),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,46 +25,54 @@ class DatabaseOperations:
|
||||
|
||||
use_returning = "RETURNING" in sql.upper()
|
||||
|
||||
with self.conn.cursor() as c:
|
||||
if not use_returning:
|
||||
# 不带 RETURNING:直接批量执行即可
|
||||
if not use_returning:
|
||||
with self.conn.cursor() as c:
|
||||
psycopg2.extras.execute_batch(c, sql, rows, page_size=page_size)
|
||||
return (0, 0)
|
||||
|
||||
# 尝试向量化执行
|
||||
return (0, 0)
|
||||
|
||||
# 尝试向量化执行(execute_values + fetch returning)
|
||||
vectorized_failed = False
|
||||
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():]
|
||||
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():]
|
||||
|
||||
with self.conn.cursor() as c:
|
||||
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)
|
||||
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:
|
||||
# 向量化失败后,事务通常处于 aborted 状态,需要先 rollback 才能继续执行。
|
||||
vectorized_failed = True
|
||||
|
||||
if vectorized_failed:
|
||||
try:
|
||||
self.conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退:逐行执行
|
||||
inserted = 0
|
||||
updated = 0
|
||||
|
||||
# 回退:逐行执行
|
||||
inserted = 0
|
||||
updated = 0
|
||||
with self.conn.cursor() as c:
|
||||
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)
|
||||
|
||||
return (inserted, updated)
|
||||
|
||||
@staticmethod
|
||||
def _is_inserted(rec) -> bool:
|
||||
|
||||
1945
etl_billiards/database/schema_ODS_doc.sql
Normal file
1945
etl_billiards/database/schema_ODS_doc.sql
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
50
etl_billiards/database/schema_dws.sql
Normal file
50
etl_billiards/database/schema_dws.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- DWS schema for aggregated / serving tables.
|
||||
CREATE SCHEMA IF NOT EXISTS billiards_dws;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billiards_dws.dws_order_summary (
|
||||
site_id BIGINT NOT NULL,
|
||||
order_settle_id BIGINT NOT NULL,
|
||||
order_trade_no TEXT,
|
||||
order_date DATE,
|
||||
tenant_id BIGINT,
|
||||
member_id BIGINT,
|
||||
member_flag BOOLEAN,
|
||||
recharge_order_flag BOOLEAN,
|
||||
item_count INT,
|
||||
total_item_quantity NUMERIC,
|
||||
table_fee_amount NUMERIC,
|
||||
assistant_service_amount NUMERIC,
|
||||
goods_amount NUMERIC,
|
||||
group_amount NUMERIC,
|
||||
total_coupon_deduction NUMERIC,
|
||||
member_discount_amount NUMERIC,
|
||||
manual_discount_amount NUMERIC,
|
||||
order_original_amount NUMERIC,
|
||||
order_final_amount NUMERIC,
|
||||
stored_card_deduct NUMERIC,
|
||||
external_paid_amount NUMERIC,
|
||||
total_paid_amount NUMERIC,
|
||||
book_table_flow NUMERIC,
|
||||
book_assistant_flow NUMERIC,
|
||||
book_goods_flow NUMERIC,
|
||||
book_group_flow NUMERIC,
|
||||
book_order_flow NUMERIC,
|
||||
order_effective_consume_cash NUMERIC,
|
||||
order_effective_recharge_cash NUMERIC,
|
||||
order_effective_flow NUMERIC,
|
||||
refund_amount NUMERIC,
|
||||
net_income NUMERIC,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (site_id, order_settle_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dws_order_summary_order_date
|
||||
ON billiards_dws.dws_order_summary (order_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dws_order_summary_tenant_date
|
||||
ON billiards_dws.dws_order_summary (tenant_id, order_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dws_order_summary_member_date
|
||||
ON billiards_dws.dws_order_summary (member_id, order_date);
|
||||
|
||||
105
etl_billiards/database/schema_etl_admin.sql
Normal file
105
etl_billiards/database/schema_etl_admin.sql
Normal file
@@ -0,0 +1,105 @@
|
||||
-- 文件说明:etl_admin 调度元数据 DDL(独立文件,便于初始化任务单独执行)。
|
||||
-- 包含任务注册表、游标表、运行记录表;字段注释使用中文。
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS etl_admin;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etl_admin.etl_task (
|
||||
task_id BIGSERIAL PRIMARY KEY,
|
||||
task_code TEXT NOT NULL,
|
||||
store_id BIGINT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
cursor_field TEXT,
|
||||
window_minutes_default INT DEFAULT 30,
|
||||
overlap_seconds INT DEFAULT 120,
|
||||
page_size INT DEFAULT 200,
|
||||
retry_max INT DEFAULT 3,
|
||||
params JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE (task_code, store_id)
|
||||
);
|
||||
COMMENT ON TABLE etl_admin.etl_task IS '任务注册表:调度依据的任务清单(与 task_registry 中的任务码对应)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.task_code IS '任务编码,需与代码中的任务码一致。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.store_id IS '门店/租户粒度,区分多门店执行。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.enabled IS '是否启用此任务。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.cursor_field IS '增量游标字段名(可选)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.window_minutes_default IS '默认时间窗口(分钟)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.overlap_seconds IS '窗口重叠秒数,用于防止遗漏。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.page_size IS '默认分页大小。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.retry_max IS 'API重试次数上限。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.params IS '任务级自定义参数 JSON。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.created_at IS '创建时间。';
|
||||
COMMENT ON COLUMN etl_admin.etl_task.updated_at IS '更新时间。';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etl_admin.etl_cursor (
|
||||
cursor_id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES etl_admin.etl_task(task_id) ON DELETE CASCADE,
|
||||
store_id BIGINT NOT NULL,
|
||||
last_start TIMESTAMPTZ,
|
||||
last_end TIMESTAMPTZ,
|
||||
last_id BIGINT,
|
||||
last_run_id BIGINT,
|
||||
extra JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE (task_id, store_id)
|
||||
);
|
||||
COMMENT ON TABLE etl_admin.etl_cursor IS '任务游标表:记录每个任务/门店的增量窗口及最后 run。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.task_id IS '关联 etl_task.task_id。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.store_id IS '门店/租户粒度。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.last_start IS '上次窗口开始时间(含重叠偏移)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.last_end IS '上次窗口结束时间。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.last_id IS '上次处理的最大主键/游标值(可选)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.last_run_id IS '上次运行ID,对应 etl_run.run_id。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.extra IS '附加游标信息 JSON。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.created_at IS '创建时间。';
|
||||
COMMENT ON COLUMN etl_admin.etl_cursor.updated_at IS '更新时间。';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etl_admin.etl_run (
|
||||
run_id BIGSERIAL PRIMARY KEY,
|
||||
run_uuid TEXT NOT NULL,
|
||||
task_id BIGINT NOT NULL REFERENCES etl_admin.etl_task(task_id) ON DELETE CASCADE,
|
||||
store_id BIGINT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
window_start TIMESTAMPTZ,
|
||||
window_end TIMESTAMPTZ,
|
||||
window_minutes INT,
|
||||
overlap_seconds INT,
|
||||
fetched_count INT DEFAULT 0,
|
||||
loaded_count INT DEFAULT 0,
|
||||
updated_count INT DEFAULT 0,
|
||||
skipped_count INT DEFAULT 0,
|
||||
error_count INT DEFAULT 0,
|
||||
unknown_fields INT DEFAULT 0,
|
||||
export_dir TEXT,
|
||||
log_path TEXT,
|
||||
request_params JSONB DEFAULT '{}'::jsonb,
|
||||
manifest JSONB DEFAULT '{}'::jsonb,
|
||||
error_message TEXT,
|
||||
extra JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
COMMENT ON TABLE etl_admin.etl_run IS '运行记录表:记录每次任务执行的窗口、状态、计数与日志路径。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.run_uuid IS '本次调度的唯一标识。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.task_id IS '关联 etl_task.task_id。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.store_id IS '门店/租户粒度。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.status IS '运行状态(SUCC/FAIL/PARTIAL 等)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.started_at IS '开始时间。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.ended_at IS '结束时间。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.window_start IS '本次窗口开始时间。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.window_end IS '本次窗口结束时间。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.window_minutes IS '窗口跨度(分钟)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.overlap_seconds IS '窗口重叠秒数。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.fetched_count IS '抓取/读取的记录数。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.loaded_count IS '插入的记录数。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.updated_count IS '更新的记录数。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.skipped_count IS '跳过的记录数。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.error_count IS '错误记录数。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.unknown_fields IS '未知字段计数(清洗阶段)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.export_dir IS '抓取/导出目录。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.log_path IS '日志路径。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.request_params IS '请求参数 JSON。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.manifest IS '运行产出清单/统计 JSON。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.error_message IS '错误信息(若失败)。';
|
||||
COMMENT ON COLUMN etl_admin.etl_run.extra IS '附加字段,保留扩展。';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,37 @@
|
||||
-- 将新的 ODS 任务注册到 etl_admin.etl_task(根据需要替换 store_id)
|
||||
-- 将新的 ODS 任务注册到 etl_admin.etl_task(按需替换 store_id)。
|
||||
-- 使用方式(示例):
|
||||
-- psql "$PG_DSN" -f etl_billiards/database/seed_ods_tasks.sql
|
||||
-- 或者在 psql 中执行本文件内容。
|
||||
-- 或在 psql 中直接执行本文件内容。
|
||||
|
||||
WITH target_store AS (
|
||||
SELECT 2790685415443269::bigint AS store_id -- TODO: 替换为实际 store_id
|
||||
),
|
||||
task_codes AS (
|
||||
SELECT unnest(ARRAY[
|
||||
'ODS_ASSISTANT_ACCOUNTS',
|
||||
-- Must match tasks/ods_tasks.py (ENABLED_ODS_CODES)
|
||||
'ODS_ASSISTANT_ACCOUNT',
|
||||
'ODS_ASSISTANT_LEDGER',
|
||||
'ODS_ASSISTANT_ABOLISH',
|
||||
'ODS_INVENTORY_CHANGE',
|
||||
'ODS_INVENTORY_STOCK',
|
||||
'ODS_PACKAGE',
|
||||
'ODS_GROUP_BUY_REDEMPTION',
|
||||
'ODS_MEMBER',
|
||||
'ODS_MEMBER_BALANCE',
|
||||
'ODS_MEMBER_CARD',
|
||||
'ODS_SETTLEMENT_RECORDS',
|
||||
'ODS_TABLE_USE',
|
||||
'ODS_PAYMENT',
|
||||
'ODS_REFUND',
|
||||
'ODS_COUPON_VERIFY',
|
||||
'ODS_PLATFORM_COUPON',
|
||||
'ODS_MEMBER',
|
||||
'ODS_MEMBER_CARD',
|
||||
'ODS_MEMBER_BALANCE',
|
||||
'ODS_RECHARGE_SETTLE',
|
||||
'ODS_GROUP_PACKAGE',
|
||||
'ODS_GROUP_BUY_REDEMPTION',
|
||||
'ODS_INVENTORY_STOCK',
|
||||
'ODS_INVENTORY_CHANGE',
|
||||
'ODS_TABLES',
|
||||
'ODS_GOODS_CATEGORY',
|
||||
'ODS_STORE_GOODS',
|
||||
'ODS_TABLE_DISCOUNT',
|
||||
'ODS_STORE_GOODS_SALES',
|
||||
'ODS_TABLE_FEE_DISCOUNT',
|
||||
'ODS_TENANT_GOODS',
|
||||
'ODS_SETTLEMENT_TICKET',
|
||||
'ODS_ORDER_SETTLE'
|
||||
'ODS_SETTLEMENT_TICKET'
|
||||
]) AS task_code
|
||||
)
|
||||
INSERT INTO etl_admin.etl_task (task_code, store_id, enabled)
|
||||
@@ -36,4 +39,3 @@ SELECT t.task_code, s.store_id, TRUE
|
||||
FROM task_codes t CROSS JOIN target_store s
|
||||
ON CONFLICT (task_code, store_id) DO UPDATE
|
||||
SET enabled = EXCLUDED.enabled;
|
||||
|
||||
|
||||
50
etl_billiards/database/seed_scheduler_tasks.sql
Normal file
50
etl_billiards/database/seed_scheduler_tasks.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- Seed scheduler-compatible tasks into etl_admin.etl_task.
|
||||
--
|
||||
-- Notes:
|
||||
-- - These task_code values must match orchestration/task_registry.py.
|
||||
-- - ODS_* tasks are intentionally excluded here because they don't follow the
|
||||
-- BaseTask(cursor_data) scheduler interface in this repo version.
|
||||
--
|
||||
-- Usage (example):
|
||||
-- psql "%PG_DSN%" -f etl_billiards/database/seed_scheduler_tasks.sql
|
||||
--
|
||||
WITH target_store AS (
|
||||
SELECT 2790685415443269::bigint AS store_id -- TODO: replace with your store_id
|
||||
),
|
||||
task_codes AS (
|
||||
SELECT unnest(ARRAY[
|
||||
'ASSISTANT_ABOLISH',
|
||||
'ASSISTANTS',
|
||||
'COUPON_USAGE',
|
||||
'CHECK_CUTOFF',
|
||||
'DATA_INTEGRITY_CHECK',
|
||||
'DWD_LOAD_FROM_ODS',
|
||||
'DWD_QUALITY_CHECK',
|
||||
'INIT_DWD_SCHEMA',
|
||||
'INIT_DWS_SCHEMA',
|
||||
'INIT_ODS_SCHEMA',
|
||||
'INVENTORY_CHANGE',
|
||||
'LEDGER',
|
||||
'MANUAL_INGEST',
|
||||
'MEMBERS',
|
||||
'MEMBERS_DWD',
|
||||
'ODS_JSON_ARCHIVE',
|
||||
'ORDERS',
|
||||
'PACKAGES_DEF',
|
||||
'PAYMENTS',
|
||||
'PAYMENTS_DWD',
|
||||
'PRODUCTS',
|
||||
'REFUNDS',
|
||||
'TABLE_DISCOUNT',
|
||||
'TABLES',
|
||||
'TICKET_DWD',
|
||||
'TOPUPS',
|
||||
'DWS_BUILD_ORDER_SUMMARY'
|
||||
]) AS task_code
|
||||
)
|
||||
INSERT INTO etl_admin.etl_task (task_code, store_id, enabled)
|
||||
SELECT t.task_code, s.store_id, TRUE
|
||||
FROM task_codes t CROSS JOIN target_store s
|
||||
ON CONFLICT (task_code, store_id) DO UPDATE
|
||||
SET enabled = EXCLUDED.enabled,
|
||||
updated_at = now();
|
||||
BIN
etl_billiards/dist/ETL_Manager/ETL_Manager.exe
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/ETL_Manager.exe
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/LIBBZ2.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/LIBBZ2.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/MSVCP140.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/MSVCP140.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/MSVCP140.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/MSVCP140.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/MSVCP140_1.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/MSVCP140_1.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/MSVCP140_2.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/MSVCP140_2.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Core.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Core.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Gui.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Gui.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Network.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Network.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6OpenGL.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6OpenGL.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Pdf.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Pdf.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Qml.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Qml.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6QmlMeta.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6QmlMeta.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6QmlModels.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6QmlModels.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6QmlWorkerScript.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6QmlWorkerScript.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Quick.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Quick.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Svg.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Svg.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6VirtualKeyboard.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6VirtualKeyboard.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Widgets.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/Qt6Widgets.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/VCRUNTIME140.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/VCRUNTIME140.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/VCRUNTIME140_1.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/VCRUNTIME140_1.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/opengl32sw.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/opengl32sw.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/generic/qtuiotouchplugin.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/generic/qtuiotouchplugin.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/iconengines/qsvgicon.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/iconengines/qsvgicon.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qgif.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qgif.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qicns.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qicns.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qico.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qico.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qjpeg.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qjpeg.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qpdf.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qpdf.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qsvg.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qsvg.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qtga.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qtga.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qtiff.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qtiff.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qwbmp.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qwbmp.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qwebp.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/imageformats/qwebp.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/networkinformation/qnetworklistmanager.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/networkinformation/qnetworklistmanager.dll
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qdirect2d.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qdirect2d.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qminimal.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qminimal.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qoffscreen.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qoffscreen.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qwindows.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/platforms/qwindows.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/styles/qmodernwindowsstyle.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/styles/qmodernwindowsstyle.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/tls/qcertonlybackend.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/tls/qcertonlybackend.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/tls/qopensslbackend.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/tls/qopensslbackend.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/tls/qschannelbackend.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/plugins/tls/qschannelbackend.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/pyside6.abi3.dll
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/pyside6.abi3.dll
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_ar.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_ar.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_bg.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_bg.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_ca.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_ca.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_cs.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_cs.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_da.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_da.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_de.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_de.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_en.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_en.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_es.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_es.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_fa.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_fa.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_fi.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_fi.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_fr.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_fr.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_gd.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_gd.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_gl.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_gl.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_he.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_he.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ar.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ar.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_bg.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_bg.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ca.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ca.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_cs.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_cs.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_da.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_da.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_de.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_de.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_en.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_en.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_es.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_es.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_fr.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_fr.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_gl.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_gl.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_hr.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_hr.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_hu.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_hu.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_it.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_it.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ja.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ja.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ka.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ka.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ko.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_ko.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_nl.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_nl.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_nn.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_nn.qm
vendored
Normal file
Binary file not shown.
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_pl.qm
vendored
Normal file
BIN
etl_billiards/dist/ETL_Manager/_internal/PySide6/translations/qt_help_pl.qm
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user