diff --git a/README.md b/README.md index 6038820..5400a5a 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,593 @@ # 飞球 ETL 系统(ODS → DWD) -面向门店业务的 ETL:拉取/或离线灌入上游 JSON,先落地 ODS,再清洗装载 DWD(含 SCD2 维度、事实增量),并提供质量校验报表。 +面向门店业务的 ETL:从上游 API 或离线 JSON 采集订单、支付、会员、库存等数据,先落地 **ODS**,再清洗装载 **DWD**(含 SCD2 维度、事实增量),并输出质量校验报表。 ## 快速运行(离线示例 JSON) -1) 环境:Python 3.10+、PostgreSQL;`.env` 关键项:`PG_DSN=postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/LLZQ-test`,`INGEST_SOURCE_DIR=C:\dev\LLTQ\export\test-json-doc`。 -2) 安装依赖: +> 以下命令默认在 `etl_billiards/` 目录执行(项目会从 `etl_billiards/.env` 读取配置;也可直接设置环境变量)。 + +1) 环境:Python 3.10+、PostgreSQL。 +2) 配置:编辑 `etl_billiards/.env`(或设环境变量),至少包含: + ```env + STORE_ID=123 + PG_DSN=postgresql://:@:/ + # 示例:使用仓库内置的最小样例(仅 1 个 JSON) + INGEST_SOURCE_DIR=../tmp/single_ingest + ``` +3) 安装依赖: ```bash cd etl_billiards pip install -r requirements.txt - ``` -3) 一键 ODS→DWD→质检: + ``` +4) 回放入库(ODS)→ 装载 DWD → 质检(可用 `--ingest-source` 覆盖 `INGEST_SOURCE_DIR`): ```bash - python -m etl_billiards.cli.main --tasks INIT_ODS_SCHEMA,INIT_DWD_SCHEMA --pipeline-flow INGEST_ONLY - python -m etl_billiards.cli.main --tasks MANUAL_INGEST --pipeline-flow INGEST_ONLY --ingest-source "C:\dev\LLTQ\export\test-json-doc" - python -m etl_billiards.cli.main --tasks DWD_LOAD_FROM_ODS --pipeline-flow INGEST_ONLY - python -m etl_billiards.cli.main --tasks DWD_QUALITY_CHECK --pipeline-flow INGEST_ONLY - # 报表:etl_billiards/reports/dwd_quality_report.json + python -m cli.main --pipeline-flow INGEST_ONLY --tasks INIT_ODS_SCHEMA,INIT_DWD_SCHEMA + python -m cli.main --pipeline-flow INGEST_ONLY --tasks MANUAL_INGEST --ingest-source "../tmp/single_ingest" + python -m cli.main --pipeline-flow INGEST_ONLY --tasks DWD_LOAD_FROM_ODS + python -m cli.main --pipeline-flow INGEST_ONLY --tasks DWD_QUALITY_CHECK + # 报表:reports/dwd_quality_report.json ``` -## 目录与文件作用 -- 根目录:`etl_billiards/` 主代码;`requirements.txt` 依赖;`run_etl.sh/.bat` 启动脚本;`.env/.env.example` 配置;`tmp/` 存放草稿/调试/备份。 -- etl_billiards/ 主线目录 - - `config/`:`defaults.py` 默认值,`env_parser.py` 解析 .env,`settings.py` 统一配置加载。 - - `api/`:`client.py` HTTP 请求、重试与分页。 - - `database/`:`connection.py` 连接封装,`operations.py` 批量 upsert,DDL:`schema_ODS_doc.sql`、`schema_dwd_doc.sql`。 - - `tasks/`:业务任务 - - `init_schema_task.py`:INIT_ODS_SCHEMA / INIT_DWD_SCHEMA。 - - `manual_ingest_task.py`:示例 JSON → ODS。 - - `dwd_load_task.py`:ODS → DWD(映射、SCD2/事实增量)。 - - 其他任务按需扩展。 - - `loaders/`:ODS/DWD/SCD2 Loader 实现。 - - `scd/`:`scd2_handler.py` 处理维度 SCD2 历史。 - - `quality/`:质量检查器(行数/金额对照)。 - - `orchestration/`:`scheduler.py` 调度;`task_registry.py` 任务注册;`run_tracker.py` 运行记录。 - - `scripts/`:重建/测试/探活工具。 - - `docs/`:`ods_to_dwd_mapping.md` 映射说明,`ods_sample_json.md` 示例 JSON 说明,`dwd_quality_check.md` 质检说明。 - - `reports/`:质检输出(如 `dwd_quality_report.json`)。 - - `tests/`:单元/集成测试;`utils/`:通用工具。 - - `backups/`(若存在):关键文件备份。 +> 可按需单独运行: +> - 仅建表:`python -m cli.main --tasks INIT_ODS_SCHEMA` +> - 仅 ODS 灌入:`python -m cli.main --tasks MANUAL_INGEST` +> - 仅 DWD 装载:`python -m cli.main --tasks INIT_DWD_SCHEMA,DWD_LOAD_FROM_ODS` -## 业务流程与文件关系 -1) 调度入口:`cli/main.py` 解析 CLI → `orchestration/scheduler.py` 依 `task_registry.py` 创建任务 → 初始化 DB/API/Config 上下文。 -2) ODS:`init_schema_task.py` 执行 `schema_ODS_doc.sql` 建表;`manual_ingest_task.py` 从 `INGEST_SOURCE_DIR` 读 JSON,批量 upsert ODS。 -3) DWD:`init_schema_task.py` 执行 `schema_dwd_doc.sql` 建表;`dwd_load_task.py` 依据 `TABLE_MAP/FACT_MAPPINGS` 从 ODS 清洗写入 DWD,维度走 SCD2(`scd/scd2_handler.py`),事实按时间/水位增量。 -4) 质检:质量任务读取 ODS/DWD,统计行数/金额,输出 `reports/dwd_quality_report.json`。 -5) 配置:`config/defaults.py` + `.env` + CLI 参数叠加;HTTP(如启用在线)走 `api/client.py`;DB 访问走 `database/connection.py`。 -6) 文档:`docs/ods_to_dwd_mapping.md` 记录字段映射;`docs/ods_sample_json.md` 描述示例数据结构,便于对照调试。 +> Windows:可用 `etl_billiards/run_ods.bat` 一键执行 ODS 建表 + 灌入示例 JSON(`INIT_ODS_SCHEMA` + `MANUAL_INGEST`)。 + +## 正式环境(在线抓取 → 更新 ODS → 更新 DWD) + +**核心入口 CLI(推荐在 `etl_billiards/` 目录执行)** +- `python -m cli.main` + +### 必备配置(建议通过环境变量或 `.env`) +- 数据库:`PG_DSN`、`STORE_ID` +- 在线抓取:`API_TOKEN`(可选 `API_BASE`、`API_TIMEOUT`、`API_PAGE_SIZE`、`API_RETRY_MAX`) +- 输出目录(可选):`EXPORT_ROOT`、`LOG_ROOT`、`FETCH_ROOT`/`JSON_FETCH_ROOT` + +**安全提示**:建议将数据库/Token 等凭证保存在 `.env` 或受控秘钥管理中,生产环境使用最小权限账号。 + +### 推荐定时方式 A(两段定时,更清晰) +1) **更新 ODS(在线抓取 + 入库,FULL)** + ```bash + python -m cli.main \ + --pipeline-flow FULL \ + --tasks PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER \ + --pg-dsn "$PG_DSN" --store-id "$STORE_ID" \ + --api-token "$API_TOKEN" + ``` +2) **ODS → DWD(将新增/变更同步到 DWD)** + ```bash + python -m cli.main \ + --pipeline-flow INGEST_ONLY \ + --tasks DWD_LOAD_FROM_ODS \ + --pg-dsn "$PG_DSN" --store-id "$STORE_ID" + ``` + +### 推荐定时方式 B(一条命令串起来) +同一条命令先跑在线抓取/入库任务,再跑 DWD 装载任务: +```bash +python -m cli.main \ + --pipeline-flow FULL \ + --tasks PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER,DWD_LOAD_FROM_ODS \ + --pg-dsn "$PG_DSN" --store-id "$STORE_ID" \ + --api-token "$API_TOKEN" +``` + +### `pipeline-flow` 说明 +- `FULL`:在线抓取落盘 + 本地清洗入库(ODS 任务会走抓取;`DWD_LOAD_FROM_ODS` 仅走入库阶段) +- `FETCH_ONLY`:仅在线抓取落盘,不入库 +- `INGEST_ONLY`:仅从本地 JSON 回放入库(适合离线回放/补跑) + +## 目录结构与关键文件 +- 仓库根目录:`etl_billiards/` 主代码;`app/` 示例 runner;`开发笔记/` 项目笔记;`tmp/` 草稿/调试归档;`requirements.txt`(仓库根)依赖;`run_etl.sh/.bat` 启动脚本。 + - 注意:根目录的 `run_etl.sh/.bat` 运行时要求当前目录为 `etl_billiards/`(因为入口是 `python -m cli.main`)。 +- `etl_billiards/`(主代码目录) + - `.env`:本地配置文件(可选,用环境变量也可) + - `cli/`:CLI 入口(`cli/main.py`) + - `config/`:`defaults.py` 默认值;`env_parser.py` 解析 `.env`/环境变量;`settings.py` AppConfig 加载/校验 + - `api/`:`client.py` HTTP 请求、重试、分页与落盘 + - `database/`:`connection.py` 连接封装;`operations.py` 批量 upsert;DDL:`schema_ODS_doc.sql`、`schema_dwd_doc.sql` + - `tasks/`:业务任务(ODS 抓取/回放、DWD 装载、质检等) + - `loaders/`:ODS/DWD/SCD2 Loader 实现 + - `scd/`:`scd2_handler.py`(维度 SCD2 历史) + - `quality/`:质量检查器(行数/金额对照) + - `orchestration/`:`scheduler.py` 调度;`task_registry.py` 注册;`cursor_manager.py` 水位管理;`run_tracker.py` 运行记录 + - `scripts/`:重建/测试/探活工具 + - `docs/`:映射/样例/质检说明文档 + - `fetch-test/`:接口联调/规则验证的一次性脚本与报告(不影响主流程) + - `reports/`:质检输出(如 `dwd_quality_report.json`) + - `tests/`:单元/集成测试 + +## 项目文件索引(维护/AI 快速定位) +> 说明:用于维护/AI 快速定位文件路径与用途;默认不列出 `.git/`、`__pycache__/`、`.pytest_cache/`、`*.pyc` 等自动生成内容。 + +### / +- `.gitignore`:Git 忽略规则。 +- `.gitkeep`:占位文件(用于保留空目录)。 +- `README.md`:项目总览与使用说明(本文档)。 +- `requirements.txt`:仓库根依赖清单(不含版本约束,建议优先用 `etl_billiards/requirements.txt`)。 +- `run_etl.bat`:Windows 启动脚本(需先 `cd etl_billiards`;入口为 `python -m cli.main`)。 +- `run_etl.sh`:Linux/macOS 启动脚本(需先 `cd etl_billiards`;会加载当前目录 `.env`)。 + +### app/ +- `app/etl_busy.py`:忙时 ETL 示例函数(TODO,占位)。 +- `app/etl_idle.py`:闲时 ETL 示例函数(TODO,占位)。 +- `app/runner.py`:简易 Runner:按 `--mode busy/idle` 调用 `app/etl_busy.py` 或 `app/etl_idle.py`(示例/未接入主 ETL)。 + +### etl_billiards/ +- `etl_billiards/.env`:本地运行环境变量(含敏感信息,勿提交/勿外传)。 +- `etl_billiards/__init__.py`:Python 包标记文件。 +- `etl_billiards/ods_row_report.json`:ODS 行数对照报告(source json vs ODS 表)。 +- `etl_billiards/requirements.txt`:ETL 运行依赖(带最低版本约束)。 +- `etl_billiards/run_ods.bat`:Windows 一键脚本:重建 ODS 并灌入示例 JSON。 +- `etl_billiards/setup.py`:打包/安装脚本(当前项目主要按“`cd etl_billiards; python -m cli.main`”方式运行)。 + +### etl_billiards/api/ +- `etl_billiards/api/__init__.py`:Python 包标记文件。 +- `etl_billiards/api/client.py`:API客户端:统一封装 POST/重试/分页与列表提取逻辑。 +- `etl_billiards/api/endpoint_routing.py`:“近期记录 / 历史记录(Former)”接口路由规则。 +- `etl_billiards/api/local_json_client.py`:本地 JSON 客户端,模拟 APIClient 的分页接口,从落盘的 JSON 回放数据。 +- `etl_billiards/api/recording_client.py`:包装 APIClient,将分页响应落盘便于后续本地清洗。 + +### etl_billiards/cli/ +- `etl_billiards/cli/__init__.py`:Python 包标记文件。 +- `etl_billiards/cli/main.py`:CLI主入口 + +### etl_billiards/config/ +- `etl_billiards/config/__init__.py`:Python 包标记文件。 +- `etl_billiards/config/defaults.py`:配置默认值定义 +- `etl_billiards/config/env_parser.py`:环境变量解析 +- `etl_billiards/config/settings.py`:配置管理主类 + +### etl_billiards/database/ +- `etl_billiards/database/__init__.py`:Python 包标记文件。 +- `etl_billiards/database/base.py`:数据库操作(批量、RETURNING支持) +- `etl_billiards/database/connection.py`:Database connection manager with capped connect_timeout. +- `etl_billiards/database/operations.py`:数据库批量操作 +- `etl_billiards/database/schema_dwd_doc.sql`:DWD Schema DDL(含字段/注释/口径说明)。 +- `etl_billiards/database/schema_etl_admin.sql`:etl_admin 元数据 Schema DDL(任务/水位/运行记录等)。 +- `etl_billiards/database/schema_ODS_doc.sql`:ODS Schema DDL(含字段/注释/口径说明)。 +- `etl_billiards/database/seed_ods_tasks.sql`:SQL 种子脚本:初始化/注册 ODS 任务。 +- `etl_billiards/database/seed_scheduler_tasks.sql`:SQL 种子脚本:初始化调度任务配置。 + +### etl_billiards/database/Deleded & backup/ +- (本目录无直接文件) + +### etl_billiards/docs/ +- `etl_billiards/docs/dwd_main_tables_dictionary.md`:DWD 主表(非 Ex)表格说明书 +- `etl_billiards/docs/在线抓取,更新ODS ,然后将更新的ODS内容,对应到DWD的更新。.md`:在线抓取,更新ODS ,然后将更新的ODS内容,对应到DWD的更新。 + +### etl_billiards/fetch-test/ +- `etl_billiards/fetch-test/compare_recent_former_endpoints.py`:对比“近期记录”与“历史记录(Former)”接口: +- `etl_billiards/fetch-test/README.md`:fetch-test +- `etl_billiards/fetch-test/recent_vs_former_report.json`:报告/比对输出(JSON)。 +- `etl_billiards/fetch-test/recent_vs_former_report.md`:近期记录 vs 历史记录(Former) 接口对比报告 + +### etl_billiards/loaders/ +- `etl_billiards/loaders/__init__.py`:Python 包标记文件。 +- `etl_billiards/loaders/base_loader.py`:数据加载器基类 + +### etl_billiards/loaders/dimensions/ +- `etl_billiards/loaders/dimensions/__init__.py`:Python 包标记文件。 +- `etl_billiards/loaders/dimensions/assistant.py`:助教维度加载器 +- `etl_billiards/loaders/dimensions/member.py`:会员维度表加载器 +- `etl_billiards/loaders/dimensions/package.py`:团购/套餐定义加载器 +- `etl_billiards/loaders/dimensions/product.py`:商品维度 + 价格SCD2 加载器 +- `etl_billiards/loaders/dimensions/table.py`:台桌维度加载器 + +### etl_billiards/loaders/facts/ +- `etl_billiards/loaders/facts/__init__.py`:Python 包标记文件。 +- `etl_billiards/loaders/facts/assistant_abolish.py`:助教作废事实表 +- `etl_billiards/loaders/facts/assistant_ledger.py`:助教流水事实表 +- `etl_billiards/loaders/facts/coupon_usage.py`:券核销事实表 +- `etl_billiards/loaders/facts/inventory_change.py`:库存变动事实表 +- `etl_billiards/loaders/facts/order.py`:订单事实表加载器 +- `etl_billiards/loaders/facts/payment.py`:支付事实表加载器 +- `etl_billiards/loaders/facts/refund.py`:退款事实表加载器 +- `etl_billiards/loaders/facts/table_discount.py`:台费打折事实表 +- `etl_billiards/loaders/facts/ticket.py`:小票详情加载器 +- `etl_billiards/loaders/facts/topup.py`:充值记录事实表 + +### etl_billiards/loaders/ods/ +- `etl_billiards/loaders/ods/__init__.py`:Python 包标记文件。 +- `etl_billiards/loaders/ods/generic.py`:Generic ODS loader that keeps raw payload + primary keys. + +### etl_billiards/models/ +- `etl_billiards/models/__init__.py`:Python 包标记文件。 +- `etl_billiards/models/parsers.py`:数据类型解析器 +- `etl_billiards/models/validators.py`:数据验证器 + +### etl_billiards/orchestration/ +- `etl_billiards/orchestration/__init__.py`:Python 包标记文件。 +- `etl_billiards/orchestration/cursor_manager.py`:游标管理器 +- `etl_billiards/orchestration/run_tracker.py`:运行记录追踪器 +- `etl_billiards/orchestration/scheduler.py`:ETL 调度:支持在线抓取、离线清洗入库、全流程三种模式。 +- `etl_billiards/orchestration/task_registry.py`:任务注册表 + +### etl_billiards/quality/ +- `etl_billiards/quality/__init__.py`:Python 包标记文件。 +- `etl_billiards/quality/balance_checker.py`:余额一致性检查器 +- `etl_billiards/quality/base_checker.py`:数据质量检查器基类 + +### etl_billiards/reports/ +- `etl_billiards/reports/dwd_quality_report.json`:DWD 质量核对输出(行数/金额对照)。 + +### etl_billiards/scd/ +- `etl_billiards/scd/__init__.py`:Python 包标记文件。 +- `etl_billiards/scd/scd2_handler.py`:SCD2 (Slowly Changing Dimension Type 2) 处理逻辑 + +### etl_billiards/scripts/ +- `etl_billiards/scripts/bootstrap_schema.py`:Apply the PRD-aligned warehouse schema (ODS/DWD/DWS) to PostgreSQL. +- `etl_billiards/scripts/build_dwd_from_ods.py`:Populate PRD DWD tables from ODS payload snapshots. +- `etl_billiards/scripts/build_dws_order_summary.py`:Recompute billiards_dws.dws_order_summary from DWD fact tables. +- `etl_billiards/scripts/check_ods_json_vs_table.py`:ODS JSON 字段核对脚本:对照当前数据库中的 ODS 表字段,检查示例 JSON(默认目录 C:\dev\LLTQ\export\test-json-doc) +- `etl_billiards/scripts/check_ods_gaps.py`:ODS 缺失校验脚本:API 主键 vs ODS 主键逐条比对,输出缺失明细样例。 +- `etl_billiards/scripts/reload_ods_windowed.py`:ODS 窗口化补跑脚本:按时间切片重跑 ODS 任务,并可配置窗口粒度与延时。 +- `etl_billiards/scripts/rebuild_db_and_run_ods_to_dwd.py`:一键重建 ETL 相关 Schema,并执行 ODS → DWD。 +- `etl_billiards/scripts/rebuild_ods_from_json.py`:从本地 JSON 示例目录重建 billiards_ods.* 表,并导入样例数据。 +- `etl_billiards/scripts/run_tests.py`:灵活的测试执行脚本,可像搭积木一样组合不同参数或预置命令(模式/数据库/归档路径等), +- `etl_billiards/scripts/Temp1.py`:空 Python 文件(占位/临时)。 +- `etl_billiards/scripts/test_db_connection.py`:Quick utility for validating PostgreSQL connectivity (ASCII-only output). +- `etl_billiards/scripts/test_presets.py`:测试命令仓库:集中维护 run_tests.py 的常用组合,支持一键执行。 + +### etl_billiards/scripts/Deleded & backup/ +- (本目录无直接文件) + +### etl_billiards/tasks/ +- `etl_billiards/tasks/__init__.py`:Python 包标记文件。 +- `etl_billiards/tasks/assistant_abolish_task.py`:助教作废任务 +- `etl_billiards/tasks/assistants_task.py`:助教账号任务 +- `etl_billiards/tasks/base_dwd_task.py`:DWD任务基类 +- `etl_billiards/tasks/base_task.py`:ETL任务基类(引入 Extract/Transform/Load 模板方法) +- `etl_billiards/tasks/coupon_usage_task.py`:平台券核销任务 +- `etl_billiards/tasks/dwd_load_task.py`:DWD 装载任务:从 ODS 增量写入 DWD(维度 SCD2,事实按时间增量)。 +- `etl_billiards/tasks/dwd_quality_task.py`:DWD 质量核对任务:按 dwd_quality_check.md 输出行数/金额对照报表。 +- `etl_billiards/tasks/init_dwd_schema_task.py`:初始化 DWD Schema:执行 schema_dwd_doc.sql,可选先 DROP SCHEMA。 +- `etl_billiards/tasks/init_schema_task.py`:任务:初始化运行环境,执行 ODS 与 etl_admin 的 DDL,并准备日志/导出目录。 +- `etl_billiards/tasks/inventory_change_task.py`:库存变更任务 +- `etl_billiards/tasks/ledger_task.py`:助教流水任务 +- `etl_billiards/tasks/manual_ingest_task.py`:手工示例数据灌入:按 schema_ODS_doc.sql 的表结构写入 ODS。 +- `etl_billiards/tasks/members_dwd_task.py`:DWD Task:Process Member Records from ODS to Dimension Table. +- `etl_billiards/tasks/members_task.py`:会员ETL任务 +- `etl_billiards/tasks/ods_json_archive_task.py`:在线抓取 ODS 相关接口并落盘为 JSON(用于后续离线回放/入库)。 +- `etl_billiards/tasks/ods_tasks.py`:ODS ingestion tasks. +- `etl_billiards/tasks/orders_task.py`:订单ETL任务 +- `etl_billiards/tasks/packages_task.py`:团购/套餐定义任务 +- `etl_billiards/tasks/payments_dwd_task.py`:DWD Task:Process Payment Records from ODS to Fact Table. +- `etl_billiards/tasks/payments_task.py`:支付记录ETL任务 +- `etl_billiards/tasks/products_task.py`:商品档案(PRODUCTS)ETL任务 +- `etl_billiards/tasks/refunds_task.py`:退款记录任务 +- `etl_billiards/tasks/table_discount_task.py`:台费折扣任务 +- `etl_billiards/tasks/tables_task.py`:台桌档案任务 +- `etl_billiards/tasks/ticket_dwd_task.py`:DWD Task:Process Ticket Details from ODS to DWD fact tables. +- `etl_billiards/tasks/topups_task.py`:充值记录任务 + +### etl_billiards/tasks/dwd/ +- (本目录无直接文件) + +### etl_billiards/tests/ +- `etl_billiards/tests/__init__.py`:Python 包标记文件。 + +### etl_billiards/tests/integration/ +- `etl_billiards/tests/integration/__init__.py`:Python 包标记文件。 +- `etl_billiards/tests/integration/test_database.py`:数据库集成测试 + +### etl_billiards/tests/unit/ +- `etl_billiards/tests/unit/__init__.py`:Python 包标记文件。 +- `etl_billiards/tests/unit/task_test_utils.py`:ETL 任务测试的共用辅助模块,涵盖在线/离线模式所需的伪造数据、客户端与配置等工具函数。 +- `etl_billiards/tests/unit/test_config.py`:配置管理测试 +- `etl_billiards/tests/unit/test_endpoint_routing.py`:Unit tests for recent/former endpoint routing. +- `etl_billiards/tests/unit/test_etl_tasks_offline.py`:离线模式任务测试,通过回放归档 JSON 来验证 T+L 链路可用。 +- `etl_billiards/tests/unit/test_etl_tasks_online.py`:在线模式下的端到端任务测试,验证所有任务在模拟 API 下能顺利执行。 +- `etl_billiards/tests/unit/test_etl_tasks_stages.py`:验证 14 个任务的 E/T/L 分阶段调用(FakeDB/FakeAPI,不访问真实接口或数据库)。 +- `etl_billiards/tests/unit/test_ods_tasks.py`:Unit tests for the new ODS ingestion tasks. +- `etl_billiards/tests/unit/test_parsers.py`:解析器测试 +- `etl_billiards/tests/unit/test_reporting.py`:汇总与报告工具的单测。 + +### etl_billiards/utils/ +- `etl_billiards/utils/__init__.py`:Python 包标记文件。 +- `etl_billiards/utils/helpers.py`:通用工具函数 +- `etl_billiards/utils/json_store.py`:JSON 归档/读取的通用工具。 +- `etl_billiards/utils/reporting.py`:简单的任务结果汇总与格式化工具。 + +### tmp/ +- `tmp/20251121-task.txt`:历史任务/计划记录(可能存在编码问题)。 +- `tmp/doc_extracted.txt`:从 DWD 文档抽取的正文(大文本)。 +- `tmp/doc_lines.txt`:DWD 文档按行抽取/对照(文本)。 +- `tmp/dwd_tables.json`:DWD 表清单(JSON)。 +- `tmp/dwd_tables_full.json`:DWD 表清单(完整版 JSON)。 +- `tmp/hebing.py`:临时脚本:按“同名 key”合并目录内 md+json 输出 merged_output.txt。 +- `tmp/README_FULL.md`:历史/草稿:README 详细版(已合并进根 README)。 +- `tmp/rebuild_run_20251214-042115.log`:运行日志/调试输出(临时文件)。 +- `tmp/rewrite_schema_dwd_doc_comments.py`:临时脚本:批量重写 DWD DDL 注释(归档/草稿)。 +- `tmp/rewrite_schema_ods_doc_comments.py`:临时脚本:批量重写 ODS DDL 注释(归档/草稿)。 +- `tmp/schema_dwd.sql`:DWD schema 草稿/导出(归档)。 +- `tmp/schema_dwd_doc.sql`:DWD schema doc 版本(归档)。 +- `tmp/schema_ODS_doc copy.sql`:ODS schema doc 备份(归档)。 +- `tmp/schema_ODS_doc.sql`:ODS schema doc 版本(归档)。 +- `tmp/temp_chinese.txt`:编码/文本对照测试。 +- `tmp/tmp_debug_sql.py`:临时脚本:调试 SQL/映射(归档)。 +- `tmp/tmp_drop_dwd.py`:临时脚本:DROP SCHEMA billiards_dwd(危险,勿在生产执行)。 +- `tmp/tmp_dwd_tasks.py`:临时脚本:调试 DWD 相关任务(归档)。 +- `tmp/tmp_problems.py`:临时脚本:问题排查记录/复现(归档)。 +- `tmp/tmp_run_sql.py`:临时脚本:拼接/执行一条 INSERT...SELECT 验证映射(需 PG_DSN)。 +- `tmp/非球接口API.md`:上游接口笔记/汇总(草稿/归档)。 + +### tmp/a/ +- (本目录无直接文件) + +### tmp/b/ +- (本目录无直接文件) + +### tmp/etl_billiards_misc/ +- `tmp/etl_billiards_misc/0.py`:Simple PostgreSQL connectivity smoke-checker. +- `tmp/etl_billiards_misc/feiqiu-ETL.code-workspace`:VS Code workspace 文件(归档)。 +- `tmp/etl_billiards_misc/草稿.txt`:草稿/说明(归档)。 + +### tmp/etl_billiards_misc/backups/ +- `tmp/etl_billiards_misc/backups/manual_ingest_task.py`:历史版本备份(归档)。 +- `tmp/etl_billiards_misc/backups/manual_ingest_task.py.bak_20251209`:历史版本备份(归档)。 +- `tmp/etl_billiards_misc/backups/schema_ODS_doc.sql`:历史版本备份(归档)。 +- `tmp/etl_billiards_misc/backups/schema_ODS_doc.sql.bak_20251209`:历史版本备份(归档)。 + +### tmp/etl_billiards_misc/tmp & Delete/ +- `tmp/etl_billiards_misc/tmp & Delete/.env.example`:旧示例配置(归档)。 +- `tmp/etl_billiards_misc/tmp & Delete/dwd_schema_columns.txt`:DWD 字段提取/对照文本(归档)。 +- `tmp/etl_billiards_misc/tmp & Delete/DWD层设计建议.docx`:DWD 设计建议文档(归档)。 +- `tmp/etl_billiards_misc/tmp & Delete/DWD层设计草稿.md`:DWD 设计草稿(归档)。 +- `tmp/etl_billiards_misc/tmp & Delete/schema_dwd_doc.sql.bak`:schema 备份(归档)。 +- `tmp/etl_billiards_misc/tmp & Delete/schema_ODS_doc.sql.bak`:schema 备份(归档)。 +- `tmp/etl_billiards_misc/tmp & Delete/schema_ODS_doc.sql.rewrite2.bak`:schema 重写过程备份(归档)。 +- `tmp/etl_billiards_misc/tmp & Delete/schema_v2.sql`:schema v2 草稿(归档)。 + +### tmp/recharge_only/ +- `tmp/recharge_only/recharge_settlements.json`:离线样例 JSON(仅充值结算)。 + +### tmp/single_ingest/ +- `tmp/single_ingest/goods_stock_movements.json`:离线最小样例 JSON(单文件)。 + +### 开发笔记/ +- `开发笔记/记录.md`:开发/迁移过程的备忘与待办(归档)。 + +## 架构与流程 +执行链路(控制流): +1) CLI(`cli/main.py`)解析参数 → 生成 AppConfig → 初始化日志/DB/API; +2) 调度层(`orchestration/scheduler.py`)按 `task_registry.py` 实例化任务,设置 run_uuid、cursor(水位)、上下文; +3) 任务执行模板:获取时间窗口/水位(`cursor_manager.py`)→ Extract(API 分页/重试或离线读 JSON)→ Transform(解析/校验)→ Load(Loader 批量 upsert/SCD2/增量写入,底层 `database/operations.py`)→(可选)质量检查 → 更新水位与运行记录(`run_tracker.py`),提交/回滚事务。 + +数据流与依赖: +- 配置:`config/defaults.py` + `.env`/环境变量 + CLI 参数叠加 +- 在线:`api/client.py` 支撑分页/重试;可落盘 JSON(`pipeline.fetch_root`) +- 离线:`manual_ingest_task.py` 从 `INGEST_SOURCE_DIR` 回放入库 +- DWD:`dwd_load_task.py` 依据 `TABLE_MAP/FACT_MAPPINGS` 映射装载,维度走 SCD2,事实走增量 +- 质检:`dwd_quality_task.py` 输出 `reports/dwd_quality_report.json` + +## ODS → DWD 策略与建模要点 +1) ODS 留底:保留源主键、payload、时间/来源信息,便于回溯。 +2) DWD 清洗:维度走 SCD2,事实按时间/水位增量;字段类型、单位、枚举标准化,同时保留溯源字段。 +3) 颗粒一致:一张 DWD 表只承载一种业务事件/颗粒,避免混颗粒。 +4) 业务键统一:site_id、member_id、table_id、order_settle_id、order_trade_no 等统一命名。 +5) 不过度汇总:DWD 只做明细/轻度清洗,聚合留到 DWS/报表。 +6) 去嵌套:数组展开为子表/子行,重复 profile 提炼为维度。 +7) 长期演进:优先加列/加表,减少对已有表结构的破坏。 + +## 常用 CLI +```bash +cd etl_billiards + +# 运行 defaults.py 中的默认任务列表(在线 FULL 流程) +python -m cli.main --pg-dsn "$PG_DSN" --store-id "$STORE_ID" --api-token "$API_TOKEN" + +# 运行指定任务 +python -m cli.main --tasks INIT_ODS_SCHEMA,MANUAL_INGEST --pipeline-flow INGEST_ONLY --ingest-source "../tmp/single_ingest" + +# 覆盖 DSN / API / 输出目录 +python -m cli.main --pg-dsn "postgresql://user:pwd@host:5432/db" --store-id 123 --api-token "..." --fetch-root "./json_fetch" + +# 试运行(不写库) +python -m cli.main --dry-run --tasks DWD_LOAD_FROM_ODS +``` + +## 测试 +说明:仓库未固定 pytest 版本(运行测试需自行安装 `pytest`)。 + +```bash +cd etl_billiards +pip install pytest + +# 单元测试(模拟 API + FakeDB) +pytest tests/unit + +# 集成测试(需要设置 TEST_DB_DSN) +TEST_DB_DSN="postgresql://user:pwd@host:5432/db" pytest tests/integration/test_database.py + +# 便捷测试执行器(可选) +python scripts/run_tests.py --suite online -k ORDERS +python scripts/test_db_connection.py --dsn "postgresql://user:pwd@host:5432/db" --query "SELECT 1" +``` + +## 开发与扩展 +- 新任务:在 `tasks/` 继承 BaseTask,实现 `get_task_code/execute`,并在 `orchestration/task_registry.py` 注册。 +- 新 Loader/Checker:参考 `loaders/`、`quality/`,复用批量 upsert/质检接口。 +- 新配置项:在 `config/defaults.py` 增加默认值,并在 `config/env_parser.py` 增加环境变量映射(如需要)。 + +## ODS 任务上线指引 +- 元数据/任务注册脚本: + - `etl_billiards/database/seed_ods_tasks.sql` + - `etl_billiards/database/seed_scheduler_tasks.sql` +- 确认 `etl_admin.etl_task` 中已启用所需任务(不同环境需替换 store_id / schema)。 +- 离线回放/重建 ODS(开发/运维): + ```bash + cd etl_billiards + python scripts/rebuild_ods_from_json.py --dsn "$PG_DSN" --json-dir "C:\\path\\to\\json-doc" + ``` + +## 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 | + +> 完整字段级映射见 `etl_billiards/docs/` 与 ODS/DWD DDL。 ## 当前状态(2025-12-09) -- 示例 JSON 全量灌入;DWD 行数与 ODS 对齐。 +- 示例 JSON 已全量灌入,DWD 行数与 ODS 对齐。 - 分类维度已展平大类+子类:`dim_goods_category` 26 行(category_level/leaf 已赋值)。 -- 剩余空值多因源数据为空;补值需先确认上游是否提供。 +- 部分空字段源数据即为空,如需补值请先确认上游。 ## 可精简/归档 -- `tmp/`、`tmp/etl_billiards_misc/` 中的草稿、旧备份、调试脚本仅供参考,运行不依赖。 -- 根级保留必要文件(README、requirements、run_etl.*、.env/.env.example),其余临时文件已移至 tmp。 +- `tmp/`、`tmp/etl_billiards_misc/` 中草稿、旧备份、调试脚本仅供参考,不影响运行。 +- 根级保留必要文件(README、requirements、run_etl.*),其余临时文件按需归档至 `tmp/`。 + +## 一键更新(推荐) + +日常需要把数据从 ODS 更新到最新,并同步刷新 DWD/DWS 时,直接运行一键脚本: + +```bash +cd etl_billiards +python run_update.py +``` + +常用参数: +- `--overlap-seconds 3600`:冗余抓取窗口(默认 3600 秒) +- `--dws-rebuild-days 1`:DWS 回算冗余天数(默认 1 天) +- `--dws-start YYYY-MM-DD --dws-end YYYY-MM-DD`:手工指定 DWS 回算日期范围 +- `--skip-ods`:跳过 ODS 在线抓取(仅跑 DWD/DWS) +- `--ods-tasks ODS_PAYMENT,ODS_TABLE_USE,...`:只跑指定 ODS 任务 +- `--check-ods-gaps`:在 ODS 更新完成后执行缺失校验(API 主键 vs ODS 主键) +- `--check-ods-overlap-hours 24`:缺失校验时,从 ODS 最新截止时间回溯的小时数(默认 24) +- `--check-ods-window-days 1`:缺失校验 API 窗口粒度(默认 1 天) +- `--check-ods-page-size 200`:缺失校验 API 每页大小(默认 200) +- `--check-ods-timeout-sec 1800`:缺失校验步骤超时秒数(默认 1800) +- `--check-ods-task-codes ODS_PAYMENT,ODS_TABLE_USE,...`:仅校验指定 ODS 任务 + +### ODS 缺失校验(API vs ODS) + +说明: +- 校验口径为 ODS 表 `MAX(fetched_at)` 的最小值,视为“最新一致截止时间”。 +- `--from-cutoff` 会从该截止时间回溯 N 小时(默认 24 小时)到当前,便于日常增量校验。 + +全量校验(从 2025-07 至今): +```bash +cd etl_billiards +python scripts/check_ods_gaps.py --start 2025-07-01 +``` + +更新时校验(从 ODS 最新截止时间回溯 24h): +```bash +cd etl_billiards +python run_update.py --check-ods-gaps +``` + +## FAQ +- 字段空值:若映射已存在且源列非空仍为空,再检查上游 JSON;维度 SCD2 按全量合并。 +- DSN/路径:确认 `PG_DSN`、`STORE_ID`、`INGEST_SOURCE_DIR` 与本地一致。 +- 新增任务:在 `tasks/` 实现并注册到 `task_registry.py`,必要时同步更新 DDL 与映射。 +- 权限/运行:检查网络、账号权限;脚本需执行权限(如 `chmod +x run_etl.sh`)。 + +--- + +## Cutoff(截止时间)检查 + +当你需要“上次数据截止到什么时候”“现在应该从哪里开始补跑”时,使用任务 `CHECK_CUTOFF`: + +```bash +cd etl_billiards +python -m cli.main --pipeline-flow INGEST_ONLY --tasks CHECK_CUTOFF +``` + +它会输出: +- `etl_admin.etl_cursor`:每个任务的 `last_start/last_end/last_run_id`(调度游标) +- ODS:对 `DWD_LOAD_FROM_ODS` 依赖的各个 `billiards_ods.*` 表做 `MAX(fetched_at)`(真实已入库 ODS 的截止) +- DWD/DWS:输出若干关键表的最大业务时间/最大更新时刻,便于快速核对 + +> 如果 `etl_cursor.last_end` 很新,但 ODS 的 `MAX(fetched_at)` 很旧,通常表示在线抓取没跑通(最常见是 `API_TOKEN` 过期导致 401)。 + +## 冗余抓取方案(推荐) + +为避免边界时间丢数(上游延迟写入、接口分页抖动、窗口切换等),建议在 cutoff 基础上向前追加 **1 小时** 冗余量: + +- 配置:将 `OVERLAP_SECONDS` 设为 3600(默认 120 秒) + +```env +# etl_billiards/.env +OVERLAP_SECONDS=3600 +``` + +冗余方案的关键点是“重抓不重落”,依靠各层的去重/幂等机制只落新数据: +- **ODS 层**:主键/冲突列 UPSERT(重复抓取只会 upsert,不会重复插入) +- **DWD 层**:事实表增量插入 + 主键冲突不重复落(重复范围会被跳过),维度表按 SCD2 合并 +- **DWS 层**:对指定日期窗口先 delete 再 upsert(窗口内重算幂等) + +> 如果你希望“冗余窗口内的数据发生变更也要覆盖更新”,需要把对应层的冲突策略从 `DO NOTHING` 调整为 `DO UPDATE`(当前实现以“只落新数据”为主)。 + +## DWS(汇总层)入库 + +本项目已包含 `billiards_dws` 汇总层(当前提供 `dws_order_summary`): + +1) 初始化 DWS 表结构: + +```bash +python -m cli.main --pipeline-flow INGEST_ONLY --tasks INIT_DWS_SCHEMA +``` + +2) 生成/刷新汇总表(按窗口重算,建议配合 `--window-start/--window-end`): + +```bash +python -m cli.main --pipeline-flow INGEST_ONLY --tasks DWS_BUILD_ORDER_SUMMARY \ + --window-start "2025-10-01 00:00:00" \ + --window-end "2025-12-26 23:59:59" +``` + +3) 推荐串联(ODS -> DWD -> DWS): + +```bash +# 先跑在线 ODS 抓取(需要有效 API_TOKEN;如果出现 401 请更新 token) +python -m cli.main --pipeline-flow FULL --tasks ODS_MEMBER,ODS_PAYMENT,ODS_REFUND,ODS_SETTLEMENT_RECORDS + +# 再把 ODS 增量同步到 DWD +python -m cli.main --pipeline-flow INGEST_ONLY --tasks DWD_LOAD_FROM_ODS + +# 最后重算 DWS +python -m cli.main --pipeline-flow INGEST_ONLY --tasks DWS_BUILD_ORDER_SUMMARY +``` + + +## 日志 (UTF-8) +- 默认日志目录:`etl_billiards/logs/` +- 每次运行都会生成一个带有时间戳的 `.log` 文件,以便于使用外部工具查看。 +常用选项: + +- `--log-file` 自定义日志路径(覆盖默认值)。 +- `--log-dir` 自定义日志目录。 +- `--log-level` 日志级别(`INFO`/`DEBUG`)。 +- `--no-log-console` 禁用控制台日志记录(仅写入文件)。 + +示例(按桌、按天设置窗口): +```bash +cd etl_billiards +python scripts/check_ods_gaps.py --start 2025-07-01 --window-days 1 --task-codes ODS_PAYMENT --sleep-per-window-seconds 0.5 +python scripts/reload_ods_windowed.py --tasks ODS_PAYMENT,ODS_TABLE_USE --start 2025-07-01 --window-days 1 --sleep-seconds 1 +python run_update.py --check-ods-gaps --check-ods-window-days 1 --check-ods-sleep-per-window-seconds 0.5 +``` \ No newline at end of file diff --git a/Untitled b/Untitled new file mode 100644 index 0000000..42061c0 --- /dev/null +++ b/Untitled @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/etl_billiards/.env b/etl_billiards/.env index 9d42149..8d797e2 100644 --- a/etl_billiards/.env +++ b/etl_billiards/.env @@ -12,9 +12,9 @@ STORE_ID=2790685415443269 TIMEZONE=Asia/Taipei # API 基础地址,config/env_parser.py -> api.base_url,FETCH 类任务调用 -API_BASE=https://api.example.com +API_BASE=https://pc.ficoo.vip/apiprod/admin/v1/ # API 鉴权 Token,config/env_parser.py -> api.token,FETCH 类任务调用 -API_TOKEN=your_token_here +API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6IlpWV3grVThBc2FYekFJeTRiaXF6MktwNjMxbTFNRlozV3pLaXNjOHREY289IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzEvMTcg5LiL5Y2INDoyMjo1OSIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3Njg2MzgxNzksImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.PVeAIx1iHqmHRNaQ4OMFPuOlHBoE47bR5TGJjZP-eOE # API 请求超时秒,config/env_parser.py -> api.timeout_sec API_TIMEOUT=20 # API 分页大小,config/env_parser.py -> api.page_size @@ -40,6 +40,10 @@ PIPELINE_FLOW=FULL # 指定任务列表(逗号分隔,覆盖默认),config/env_parser.py -> run.tasks # RUN_TASKS=INIT_ODS_SCHEMA,MANUAL_INGEST +# 固定回溯窗口(可选):同时设置 WINDOW_START + WINDOW_END,将覆盖游标/当前时间窗口 +# WINDOW_START=2025-07-01 00:00:00 +# WINDOW_END=2025-08-01 00:00:00 + # 窗口/补偿参数,config/env_parser.py -> run.* OVERLAP_SECONDS=120 WINDOW_BUSY_MIN=30 diff --git a/etl_billiards/api/client.py b/etl_billiards/api/client.py index 1ee807e..0959c01 100644 --- a/etl_billiards/api/client.py +++ b/etl_billiards/api/client.py @@ -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, diff --git a/etl_billiards/api/endpoint_routing.py b/etl_billiards/api/endpoint_routing.py new file mode 100644 index 0000000..8ddc4e0 --- /dev/null +++ b/etl_billiards/api/endpoint_routing.py @@ -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)] + diff --git a/etl_billiards/api/local_json_client.py b/etl_billiards/api/local_json_client.py index ce30836..8d752c3 100644 --- a/etl_billiards/api/local_json_client.py +++ b/etl_billiards/api/local_json_client.py @@ -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, diff --git a/etl_billiards/api/recording_client.py b/etl_billiards/api/recording_client.py index f8659b1..187abf5 100644 --- a/etl_billiards/api/recording_client.py +++ b/etl_billiards/api/recording_client.py @@ -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, diff --git a/etl_billiards/cli/main.py b/etl_billiards/cli/main.py index 3c2bd23..9268f74 100644 --- a/etl_billiards/cli/main.py +++ b/etl_billiards/cli/main.py @@ -40,6 +40,23 @@ 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("--export-root", help="导出根目录") @@ -108,6 +125,16 @@ 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.idle_start: overrides.setdefault("run", {}).setdefault("idle_window", {})["start"] = args.idle_start diff --git a/etl_billiards/config/env_parser.py b/etl_billiards/config/env_parser.py index 2ec2553..787bda6 100644 --- a/etl_billiards/config/env_parser.py +++ b/etl_billiards/config/env_parser.py @@ -40,6 +40,8 @@ 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",), "PIPELINE_FLOW": ("pipeline.flow",), "JSON_FETCH_ROOT": ("pipeline.fetch_root",), "JSON_SOURCE_DIR": ("pipeline.ingest_source_dir",), diff --git a/etl_billiards/database/operations.py b/etl_billiards/database/operations.py index ec48c0d..d9b6f3f 100644 --- a/etl_billiards/database/operations.py +++ b/etl_billiards/database/operations.py @@ -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: diff --git a/etl_billiards/database/schema_dws.sql b/etl_billiards/database/schema_dws.sql new file mode 100644 index 0000000..03e1a86 --- /dev/null +++ b/etl_billiards/database/schema_dws.sql @@ -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); + diff --git a/etl_billiards/database/seed_ods_tasks.sql b/etl_billiards/database/seed_ods_tasks.sql index 756743a..c0184d6 100644 --- a/etl_billiards/database/seed_ods_tasks.sql +++ b/etl_billiards/database/seed_ods_tasks.sql @@ -8,28 +8,30 @@ WITH target_store AS ( ), task_codes AS ( SELECT unnest(ARRAY[ - 'assistant_accounts_masterS', - 'assistant_service_records', - 'assistant_cancellation_records', - 'goods_stock_movements', - 'ODS_INVENTORY_STOCK', - 'ODS_PACKAGE', - 'ODS_GROUP_BUY_REDEMPTION', - 'ODS_MEMBER', - 'ODS_MEMBER_BALANCE', - 'member_stored_value_cards', + -- Must match tasks/ods_tasks.py (ENABLED_ODS_CODES) + 'ODS_ASSISTANT_ACCOUNT', + 'ODS_ASSISTANT_LEDGER', + 'ODS_ASSISTANT_ABOLISH', + 'ODS_SETTLEMENT_RECORDS', + 'ODS_TABLE_USE', 'ODS_PAYMENT', 'ODS_REFUND', - 'platform_coupon_redemption_records', - 'recharge_settlements', + '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', - 'table_fee_discount_records', + 'ODS_STORE_GOODS_SALES', + 'ODS_TABLE_FEE_DISCOUNT', 'ODS_TENANT_GOODS', - 'ODS_SETTLEMENT_TICKET', - 'settlement_records', - 'INIT_ODS_SCHEMA' + 'ODS_SETTLEMENT_TICKET' ]) AS task_code ) INSERT INTO etl_admin.etl_task (task_code, store_id, enabled) diff --git a/etl_billiards/database/seed_scheduler_tasks.sql b/etl_billiards/database/seed_scheduler_tasks.sql new file mode 100644 index 0000000..94bf66d --- /dev/null +++ b/etl_billiards/database/seed_scheduler_tasks.sql @@ -0,0 +1,49 @@ +-- 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', + '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(); diff --git a/etl_billiards/docs/dwd_main_tables_dictionary.md b/etl_billiards/docs/dwd_main_tables_dictionary.md new file mode 100644 index 0000000..ea88118 --- /dev/null +++ b/etl_billiards/docs/dwd_main_tables_dictionary.md @@ -0,0 +1,1250 @@ +# DWD 主表(非 Ex)表格说明书 + + + +- 来源:`etl_billiards/database/schema_dwd_doc.sql` + +- 范围:仅包含“主表”(表名不含 `_Ex`/`_EX` 的 `CREATE TABLE`);扩展字段见同名 `_Ex` 表 + +- 目的:二次数据清洗/建模的字段口径、来源与可连接关系参考 + +- 关联(推断)列规则:仅按“字段名 = 其他表主键字段名”推断可 join 关系;DWD 未声明外键,需结合业务确认 + + + +## 表清单 + + + +| 表名 | 类型 | 主键 | 表说明 | + +|---|---|---|---| + +| `dim_assistant` | 维度 | assistant_id | DWD 维度表:dim_assistant。ODS 来源表:billiards_ods.assistant_accounts_master(对应 JSON:assistant_accounts_master.json;分析:assistant_accounts_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_goods_category` | 维度 | category_id | DWD 维度表:dim_goods_category。ODS 来源表:billiards_ods.stock_goods_category_tree(对应 JSON:stock_goods_category_tree.json;分析:stock_goods_category_tree-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_groupbuy_package` | 维度 | groupbuy_package_id | DWD 维度表:dim_groupbuy_package。ODS 来源表:billiards_ods.group_buy_packages(对应 JSON:group_buy_packages.json;分析:group_buy_packages-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_member` | 维度 | member_id | DWD 维度表:dim_member。ODS 来源表:billiards_ods.member_profiles(对应 JSON:member_profiles.json;分析:member_profiles-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_member_card_account` | 维度 | member_card_id | DWD 维度表:dim_member_card_account。ODS 来源表:billiards_ods.member_stored_value_cards(对应 JSON:member_stored_value_cards.json;分析:member_stored_value_cards-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_site` | 维度 | site_id | DWD 维度表:dim_site。ODS 来源表:billiards_ods.table_fee_transactions(对应 JSON:table_fee_transactions.json;分析:table_fee_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_store_goods` | 维度 | site_goods_id | DWD 维度表:dim_store_goods。ODS 来源表:billiards_ods.store_goods_master(对应 JSON:store_goods_master.json;分析:store_goods_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_table` | 维度 | table_id | DWD 维度表:dim_table。ODS 来源表:billiards_ods.site_tables_master(对应 JSON:site_tables_master.json;分析:site_tables_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dim_tenant_goods` | 维度 | tenant_goods_id | DWD 维度表:dim_tenant_goods。ODS 来源表:billiards_ods.tenant_goods_master(对应 JSON:tenant_goods_master.json;分析:tenant_goods_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_assistant_service_log` | 事实/明细 | assistant_service_id | DWD 明细事实表:dwd_assistant_service_log。ODS 来源表:billiards_ods.assistant_service_records(对应 JSON:assistant_service_records.json;分析:assistant_service_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_assistant_trash_event` | 事实/明细 | assistant_trash_event_id | DWD 明细事实表:dwd_assistant_trash_event。ODS 来源表:billiards_ods.assistant_cancellation_records(对应 JSON:assistant_cancellation_records.json;分析:assistant_cancellation_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_groupbuy_redemption` | 事实/明细 | redemption_id | DWD 明细事实表:dwd_groupbuy_redemption。ODS 来源表:billiards_ods.group_buy_redemption_records(对应 JSON:group_buy_redemption_records.json;分析:group_buy_redemption_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_member_balance_change` | 事实/明细 | balance_change_id | DWD 明细事实表:dwd_member_balance_change。ODS 来源表:billiards_ods.member_balance_changes(对应 JSON:member_balance_changes.json;分析:member_balance_changes-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_payment` | 事实/明细 | payment_id | DWD 明细事实表:dwd_payment。ODS 来源表:billiards_ods.payment_transactions(对应 JSON:payment_transactions.json;分析:payment_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_platform_coupon_redemption` | 事实/明细 | platform_coupon_redemption_id | DWD 明细事实表:dwd_platform_coupon_redemption。ODS 来源表:billiards_ods.platform_coupon_redemption_records(对应 JSON:platform_coupon_redemption_records.json;分析:platform_coupon_redemption_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_recharge_order` | 事实/明细 | recharge_order_id | DWD 明细事实表:dwd_recharge_order。ODS 来源表:billiards_ods.recharge_settlements(对应 JSON:recharge_settlements.json;分析:recharge_settlements-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_refund` | 事实/明细 | refund_id | DWD 明细事实表:dwd_refund。ODS 来源表:billiards_ods.refund_transactions(对应 JSON:refund_transactions.json;分析:refund_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_settlement_head` | 事实/明细 | order_settle_id | DWD 明细事实表:dwd_settlement_head。ODS 来源表:billiards_ods.settlement_records(对应 JSON:settlement_records.json;分析:settlement_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_store_goods_sale` | 事实/明细 | store_goods_sale_id | DWD 明细事实表:dwd_store_goods_sale。ODS 来源表:billiards_ods.store_goods_sales_records(对应 JSON:store_goods_sales_records.json;分析:store_goods_sales_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_table_fee_adjust` | 事实/明细 | table_fee_adjust_id | DWD 明细事实表:dwd_table_fee_adjust。ODS 来源表:billiards_ods.table_fee_discount_records(对应 JSON:table_fee_discount_records.json;分析:table_fee_discount_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + +| `dwd_table_fee_log` | 事实/明细 | table_fee_log_id | DWD 明细事实表:dwd_table_fee_log。ODS 来源表:billiards_ods.table_fee_transactions(对应 JSON:table_fee_transactions.json;分析:table_fee_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 | + + + +## `dim_assistant` + + + +- 表说明:DWD 维度表:dim_assistant。ODS 来源表:billiards_ods.assistant_accounts_master(对应 JSON:assistant_accounts_master.json;分析:assistant_accounts_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:assistant_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `assistant_id` | BIGINT | Y | | 助教账号主键 ID,在“助教流水.json”中对应 site_assistant_id。;用途:所有与助教相关的事实表(助教流水、助教排班等)都会通过这个 ID 关联到该维表;用于跨表关联与去重。 | assistant_accounts_master - id。 | assistant_accounts_master.json - data.assistantInfos - id。 | + +| `user_id` | BIGINT | | | 预留给“人事系统员工 ID”的字段,目前未接入或未启用;用于跨表关联与去重。 | assistant_accounts_master - staff_id。 | assistant_accounts_master.json - data.assistantInfos - staff_id。 | + +| `assistant_no` | TEXT | | | 助教工号 / 编号,便于业务侧识别。;关联:在“助教流水.json”中有 assistantNo,与此字段对应。 | assistant_accounts_master - assistant_no。 | assistant_accounts_master.json - data.assistantInfos - assistant_no。 | + +| `real_name` | TEXT | | | 助教真实姓名,如“何海婷”“梁婷婷”等。;关联:在“助教流水.json”的 assistantName 与此一致。 | assistant_accounts_master - real_name。 | assistant_accounts_master.json - data.assistantInfos - real_name。 | + +| `nickname` | TEXT | | | 助教在前台展示的昵称,如“佳怡”“周周”“球球”等。 | assistant_accounts_master - nickname。 | assistant_accounts_master.json - data.assistantInfos - nickname。 | + +| `mobile` | TEXT | | | 助教手机号,用于登录绑定、通知、钉钉同步等。 | assistant_accounts_master - mobile。 | assistant_accounts_master.json - data.assistantInfos - mobile。 | + +| `tenant_id` | BIGINT | | | 品牌/租户 ID,对应“非球科技”系统中该商户的唯一标识;用途:多租户数据隔离与按租户汇总。 | assistant_accounts_master - tenant_id。 | assistant_accounts_master.json - data.assistantInfos - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID,对应本次数据的这家球房(朗朗桌球)。;关联:与其它 JSON(台费流水、库存、销售等)中的 site_id 一致;用途:门店维度分组、计营业额、与门店档案关联。 | assistant_accounts_master - site_id。 | assistant_accounts_master.json - data.assistantInfos - site_id。 | + +| `team_id` | BIGINT | | | 助教所属团队 ID。;关联:在“助教流水.json”中 assistant_team_id 与此一致;用于跨表关联与去重。 | assistant_accounts_master - team_id。 | assistant_accounts_master.json - data.assistantInfos - team_id。 | + +| `team_name` | TEXT | | | 团队名称,展示用,和 team_id 一一对应。 | assistant_accounts_master - team_name。 | assistant_accounts_master.json - data.assistantInfos - team_name。 | + +| `level` | INTEGER | | | 8:助教管理/管理员(和流水里的 "助教管理" 对应);关联:在“助教流水.json”里以 assistant_level+levelName 体现。 | assistant_accounts_master - level。 | assistant_accounts_master.json - data.assistantInfos - level。 | + +| `entry_time` | TIMESTAMPTZ | | | 入职时间。 | assistant_accounts_master - entry_time。 | assistant_accounts_master.json - data.assistantInfos - entry_time。 | + +| `resign_time` | TIMESTAMPTZ | | | 离职日期;使用“远未来日期”作为“未离职”的占位。 | assistant_accounts_master - resign_time。 | assistant_accounts_master.json - data.assistantInfos - resign_time。 | + +| `leave_status` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | assistant_accounts_master - leave_status。 | assistant_accounts_master.json - data.assistantInfos - leave_status。 | + +| `assistant_status` | INTEGER | | | 账号启用状态:。 | assistant_accounts_master - assistant_status。 | assistant_accounts_master.json - data.assistantInfos - assistant_status。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_goods_category` + + + +- 表说明:DWD 维度表:dim_goods_category。ODS 来源表:billiards_ods.stock_goods_category_tree(对应 JSON:stock_goods_category_tree.json;分析:stock_goods_category_tree-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:category_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `category_id` | BIGINT | Y | | 分类节点主键 ID(在商品分类维度中的唯一标识);用于跨表关联与去重。 | stock_goods_category_tree - id。 | stock_goods_category_tree.json - data.goodsCategoryList - id。 | + +| `tenant_id` | BIGINT | | | 租户 ID(品牌/商户 ID);用途:多租户数据隔离与按租户汇总。 | stock_goods_category_tree - tenant_id。 | stock_goods_category_tree.json - data.goodsCategoryList - tenant_id。 | + +| `category_name` | VARCHAR(50) | | | 分类名称(实际业务分类名称)。 | stock_goods_category_tree - category_name。 | stock_goods_category_tree.json - data.goodsCategoryList - category_name。 | + +| `alias_name` | VARCHAR(50) | | | 预留的“别名”字段,可用于:。 | stock_goods_category_tree - alias_name。 | stock_goods_category_tree.json - data.goodsCategoryList - alias_name。 | + +| `parent_category_id` | BIGINT | | | 父级分类 ID;用于跨表关联与去重。 | stock_goods_category_tree - pid。 | stock_goods_category_tree.json - data.goodsCategoryList - pid。 | + +| `business_name` | VARCHAR(50) | | | 业务大类名称。 | stock_goods_category_tree - business_name。 | stock_goods_category_tree.json - data.goodsCategoryList - business_name。 | + +| `tenant_goods_business_id` | BIGINT | | | 业务大类 ID;用于跨表关联与去重。 | stock_goods_category_tree - tenant_goods_business_id。 | stock_goods_category_tree.json - data.goodsCategoryList - tenant_goods_business_id。 | + +| `category_level` | INTEGER | | | 业务明细字段,用于补充该记录的业务属性。 | stock_goods_category_tree - CASE WHEN pid = 0 THEN 1 ELSE 2 END。 | stock_goods_category_tree.json - data.goodsCategoryList - CASE WHEN pid = 0 THEN 1 ELSE 2 END。 | + +| `is_leaf` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | stock_goods_category_tree - CASE WHEN categoryboxes IS NULL OR jsonb_array_length(categoryboxes)=0 THEN 1 ELSE 0 END。 | stock_goods_category_tree.json - data.goodsCategoryList - CASE WHEN categoryboxes IS NULL OR jsonb_array_length(categoryboxes)=0 THEN 1 ELSE 0 END。 | + +| `open_salesman` | INTEGER | | | 是否启用“营业员”或“导购提成”相关的功能开关。 | stock_goods_category_tree - open_salesman。 | stock_goods_category_tree.json - data.goodsCategoryList - open_salesman。 | + +| `sort_order` | INTEGER | | | 分类的排序序号,用于前端展示顺序的控制。 | stock_goods_category_tree - sort。 | stock_goods_category_tree.json - data.goodsCategoryList - sort。 | + +| `is_warehousing` | INTEGER | | | 是否“走库存 / 参与仓储管理”:。 | stock_goods_category_tree - is_warehousing。 | stock_goods_category_tree.json - data.goodsCategoryList - is_warehousing。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_groupbuy_package` + + + +- 表说明:DWD 维度表:dim_groupbuy_package。ODS 来源表:billiards_ods.group_buy_packages(对应 JSON:group_buy_packages.json;分析:group_buy_packages-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:groupbuy_package_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `groupbuy_package_id` | BIGINT | Y | | 门店侧套餐 ID,本文件内部的主键。;关联:平台验券记录表中常见 group_package_id 字段,通常会指向这里的 id,即:平台券核销记录指向哪一个团购套餐配置;用于跨表关联与去重。 | group_buy_packages - id。 | group_buy_packages.json - data.packageCouponList - id。 | + +| `tenant_id` | BIGINT | | | 租户 ID(品牌/商户 ID);用途:多租户数据隔离与按租户汇总。 | group_buy_packages - tenant_id。 | group_buy_packages.json - data.packageCouponList - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID;用途:门店维度分组、计营业额、与门店档案关联。 | group_buy_packages - site_id。 | group_buy_packages.json - data.packageCouponList - site_id。 | + +| `package_name` | VARCHAR(200) | | | 团购套餐名称,用于前台展示和核销界面。 | group_buy_packages - package_name。 | group_buy_packages.json - data.packageCouponList - package_name。 | + +| `package_template_id` | BIGINT | | | “上层套餐 ID” 或“总部/系统级套餐 ID”;用于跨表关联与去重。 | group_buy_packages - package_id。 | group_buy_packages.json - data.packageCouponList - package_id。 | + +| `selling_price` | NUMERIC(10,2) | | | 语义上应该是“团购售卖价”(顾客在平台购买券时的成交价格)。 | group_buy_packages - selling_price。 | group_buy_packages.json - data.packageCouponList - selling_price。 | + +| `coupon_face_value` | NUMERIC(10,2) | | | 券面值或内部结算面值,表示该套餐在门店侧对应的金额额度。 | group_buy_packages - coupon_money。 | group_buy_packages.json - data.packageCouponList - coupon_money。 | + +| `duration_seconds` | INTEGER | | | 套餐内包含的时长(秒)。 | group_buy_packages - duration。 | group_buy_packages.json - data.packageCouponList - duration。 | + +| `start_time` | TIMESTAMPTZ | | | 套餐开始生效的日期时间。 | group_buy_packages - start_time。 | group_buy_packages.json - data.packageCouponList - start_time。 | + +| `end_time` | TIMESTAMPTZ | | | 套餐失效的日期时间(到这个时间点后不可使用)。 | group_buy_packages - end_time。 | group_buy_packages.json - data.packageCouponList - end_time。 | + +| `table_area_name` | VARCHAR(100) | | | 套餐适用的“门店台区名称”,用于显示和筛选。 | group_buy_packages - table_area_name。 | group_buy_packages.json - data.packageCouponList - table_area_name。 | + +| `is_enabled` | INTEGER | | | 启用状态。 | group_buy_packages - is_enabled。 | group_buy_packages.json - data.packageCouponList - is_enabled。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志;软删除/作废标记,分析通常需过滤为有效记录。 | group_buy_packages - is_delete。 | group_buy_packages.json - data.packageCouponList - is_delete。 | + +| `create_time` | TIMESTAMPTZ | | | 该套餐在系统中创建的时间;记录源系统创建时间,用于增量同步和口径对齐。 | group_buy_packages - create_time。 | group_buy_packages.json - data.packageCouponList - create_time。 | + +| `tenant_table_area_id_list` | VARCHAR(512) | | | 实际代表“台区集合 ID”或“租户台区配置 ID”,用来限制套餐可用的台区范围。 | group_buy_packages - tenant_table_area_id_list。 | group_buy_packages.json - data.packageCouponList - tenant_table_area_id_list。 | + +| `card_type_ids` | VARCHAR(255) | | | 原意是“适用会员卡类型 ID 列表”,例如某套餐只允许某几种会员卡使用,可以在此配置。 | group_buy_packages - card_type_ids。 | group_buy_packages.json - data.packageCouponList - card_type_ids。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_member` + + + +- 表说明:DWD 维度表:dim_member。ODS 来源表:billiards_ods.member_profiles(对应 JSON:member_profiles.json;分析:member_profiles-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:member_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `member_id` | BIGINT | Y | | 这是“租户内会员账户”的主键 ID;用于跨表关联与去重。 | member_profiles - id。 | member_profiles.json - data.tenantMemberInfos - id。 | + +| `system_member_id` | BIGINT | | | 这是“系统级会员 ID”,在全平台唯一,用来把一个会员在不同门店/不同卡类型下的账户统一到一个“人”的维度上;用于跨表关联与去重。 | member_profiles - system_member_id。 | member_profiles.json - data.tenantMemberInfos - system_member_id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID;用途:多租户数据隔离与按租户汇总。 | member_profiles - tenant_id。 | member_profiles.json - data.tenantMemberInfos - tenant_id。 | + +| `register_site_id` | BIGINT | | | 会员的注册门店 ID;用于跨表关联与去重。 | member_profiles - register_site_id。 | member_profiles.json - data.tenantMemberInfos - register_site_id。 | + +| `mobile` | TEXT | | | 会员绑定的手机号码;手机号码,用于账户/会员识别、查询与联系。 | member_profiles - mobile。 | member_profiles.json - data.tenantMemberInfos - mobile。 | + +| `nickname` | TEXT | | | 会员在当前租户下的显示名称(可以是姓名,也可以是昵称)。 | member_profiles - nickname。 | member_profiles.json - data.tenantMemberInfos - nickname。 | + +| `member_card_grade_code` | BIGINT | | | 业务明细字段,用于补充该记录的业务属性。 | member_profiles - member_card_grade_code。 | member_profiles.json - data.tenantMemberInfos - member_card_grade_code。 | + +| `member_card_grade_name` | TEXT | | | 这是“会员卡种类/等级”的定义字段。 | member_profiles - member_card_grade_name。 | member_profiles.json - data.tenantMemberInfos - member_card_grade_name。 | + +| `create_time` | TIMESTAMPTZ | | | 会员账户的创建时间(即这条档案/这张卡在系统中被创建的时间);记录源系统创建时间,用于增量同步和口径对齐。 | member_profiles - create_time。 | member_profiles.json - data.tenantMemberInfos - create_time。 | + +| `update_time` | TIMESTAMPTZ | | | 记录源系统更新时间,用于增量同步与变更追踪。 | member_profiles - update_time。 | member_profiles.json - data.tenantMemberInfos - update_time。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_member_card_account` + + + +- 表说明:DWD 维度表:dim_member_card_account。ODS 来源表:billiards_ods.member_stored_value_cards(对应 JSON:member_stored_value_cards.json;分析:member_stored_value_cards-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:member_card_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `member_card_id` | BIGINT | Y | | 会员卡 ID(源系统唯一标识),用于跨表关联、去重与维度汇总。 | member_stored_value_cards - id。 | member_stored_value_cards.json - data.tenantMemberCards - id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID,与其他 JSON 中 tenant_id 一致;用途:多租户数据隔离与按租户汇总。 | member_stored_value_cards - tenant_id。 | member_stored_value_cards.json - data.tenantMemberCards - tenant_id。 | + +| `register_site_id` | BIGINT | | | 卡首次办理的门店 ID;用于跨表关联与去重。 | member_stored_value_cards - register_site_id。 | member_stored_value_cards.json - data.tenantMemberCards - register_site_id。 | + +| `tenant_member_id` | BIGINT | | | 当前商户(品牌/租户)中会员的主键 ID;用于跨表关联与去重。 | member_stored_value_cards - tenant_member_id。 | member_stored_value_cards.json - data.tenantMemberCards - tenant_member_id。 | + +| `system_member_id` | BIGINT | | | 系统级会员 ID(跨门店统一主键);用于跨表关联与去重。 | member_stored_value_cards - system_member_id。 | member_stored_value_cards.json - data.tenantMemberCards - system_member_id。 | + +| `card_type_id` | BIGINT | | | 卡种 ID(定义“这是哪一种卡”);用于跨表关联与去重。 | member_stored_value_cards - card_type_id。 | member_stored_value_cards.json - data.tenantMemberCards - card_type_id。 | + +| `member_card_grade_code` | BIGINT | | | 卡等级/卡类代码,和下面两个名称字段一一对应。 | member_stored_value_cards - member_card_grade_code。 | member_stored_value_cards.json - data.tenantMemberCards - member_card_grade_code。 | + +| `member_card_grade_code_name` | TEXT | | | 卡等级/卡类名称。 | member_stored_value_cards - member_card_grade_code_name。 | member_stored_value_cards.json - data.tenantMemberCards - member_card_grade_code_name。 | + +| `member_card_type_name` | TEXT | | | 卡类型名称,实际与 member_card_grade_code_name 一致。 | member_stored_value_cards - member_card_type_name。 | member_stored_value_cards.json - data.tenantMemberCards - member_card_type_name。 | + +| `member_name` | TEXT | | | 持卡会员姓名快照。 | member_stored_value_cards - member_name。 | member_stored_value_cards.json - data.tenantMemberCards - member_name。 | + +| `member_mobile` | TEXT | | | 持卡会员手机号快照;手机号码,用于账户/会员识别、查询与联系。 | member_stored_value_cards - member_mobile。 | member_stored_value_cards.json - data.tenantMemberCards - member_mobile。 | + +| `balance` | NUMERIC(18,2) | | | 当前卡内余额(主要针对储值卡、部分券卡)。 | member_stored_value_cards - balance。 | member_stored_value_cards.json - data.tenantMemberCards - balance。 | + +| `start_time` | TIMESTAMPTZ | | | 卡片生效开始时间(有效期起始)。 | member_stored_value_cards - start_time。 | member_stored_value_cards.json - data.tenantMemberCards - start_time。 | + +| `end_time` | TIMESTAMPTZ | | | 卡片有效期结束时间。 | member_stored_value_cards - end_time。 | member_stored_value_cards.json - data.tenantMemberCards - end_time。 | + +| `last_consume_time` | TIMESTAMPTZ | | | 最近一次消费时间。 | member_stored_value_cards - last_consume_time。 | member_stored_value_cards.json - data.tenantMemberCards - last_consume_time。 | + +| `status` | INTEGER | | | 1:正常可用。 | member_stored_value_cards - status。 | member_stored_value_cards.json - data.tenantMemberCards - status。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志;软删除/作废标记,分析通常需过滤为有效记录。 | member_stored_value_cards - is_delete。 | member_stored_value_cards.json - data.tenantMemberCards - is_delete。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_site` + + + +- 表说明:DWD 维度表:dim_site。ODS 来源表:billiards_ods.table_fee_transactions(对应 JSON:table_fee_transactions.json;分析:table_fee_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:site_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `site_id` | BIGINT | Y | | 门店 ID,本次数据全部来自同一门店(朗朗桌球)。;关联:与 siteProfile.id 一致;用途:门店维度分组、计营业额、与门店档案关联。 | table_fee_transactions - site_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - site_id。 | + +| `org_id` | BIGINT | | | 组织/机构 ID,用于组织维度归属和管理聚合。 | table_fee_transactions - siteProfile.org_id。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - org_id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID。本文件所有记录都属于同一租户。;关联:与所有其它 JSON 中的 tenant_id 一致,用于跨表做“商户维度”的过滤。 | table_fee_transactions - siteProfile.tenant_id。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - tenant_id。 | + +| `shop_name` | TEXT | | | 名称字段,用于展示、检索与分组。 | table_fee_transactions - siteProfile.shop_name。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - shop_name。 | + +| `site_label` | TEXT | | | 业务明细字段,用于补充该记录的业务属性。 | table_fee_transactions - siteProfile.site_label。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - site_label。 | + +| `full_address` | TEXT | | | 业务明细字段,用于补充该记录的业务属性。 | table_fee_transactions - siteProfile.full_address。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - full_address。 | + +| `address` | TEXT | | | 业务明细字段,用于补充该记录的业务属性。 | table_fee_transactions - siteProfile.address。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - address。 | + +| `longitude` | NUMERIC(10,6) | | | 业务明细字段,用于补充该记录的业务属性。 | table_fee_transactions - siteProfile.longitude。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - longitude(派生:CAST(longitude AS numeric))。 | + +| `latitude` | NUMERIC(10,6) | | | 业务明细字段,用于补充该记录的业务属性。 | table_fee_transactions - siteProfile.latitude。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - latitude(派生:CAST(latitude AS numeric))。 | + +| `tenant_site_region_id` | BIGINT | | | 租户/品牌门店区域 ID(源系统唯一标识),用于跨表关联、去重与维度汇总。 | table_fee_transactions - siteProfile.tenant_site_region_id。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - tenant_site_region_id。 | + +| `business_tel` | TEXT | | | 业务明细字段,用于补充该记录的业务属性。 | table_fee_transactions - siteProfile.business_tel。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - business_tel。 | + +| `site_type` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | table_fee_transactions - siteProfile.site_type。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - site_type。 | + +| `shop_status` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | table_fee_transactions - siteProfile.shop_status。 | table_fee_transactions.json - data.siteTableUseDetailsList.siteProfile - shop_status。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_store_goods` + + + +- 表说明:DWD 维度表:dim_store_goods。ODS 来源表:billiards_ods.store_goods_master(对应 JSON:store_goods_master.json;分析:store_goods_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:site_goods_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `site_goods_id` | BIGINT | Y | | 门店商品 ID,门店维度的商品主键;用于跨表关联与去重。 | store_goods_master - id。 | store_goods_master.json - data.orderGoodsList - id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID。同一品牌下多个门店共享一个 tenant_id;用途:多租户数据隔离与按租户汇总。 | store_goods_master - tenant_id。 | store_goods_master.json - data.orderGoodsList - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID;用途:门店维度分组、计营业额、与门店档案关联。 | store_goods_master - site_id。 | store_goods_master.json - data.orderGoodsList - site_id。 | + +| `tenant_goods_id` | BIGINT | | dim_tenant_goods(tenant_goods_id) | 租户/品牌维度的商品 ID,相当于“全局商品 ID”;用于跨表关联与去重。 | store_goods_master - tenant_goods_id。 | store_goods_master.json - data.orderGoodsList - tenant_goods_id。 | + +| `goods_name` | TEXT | | | 商品名称,例如“合味道泡面”“地道肠”“麻将房茶位费”等。 | store_goods_master - goods_name。 | store_goods_master.json - data.orderGoodsList - goods_name。 | + +| `goods_category_id` | BIGINT | | | 商品一级分类 ID;用于跨表关联与去重。 | store_goods_master - goods_category_id。 | store_goods_master.json - data.orderGoodsList - goods_category_id。 | + +| `goods_second_category_id` | BIGINT | | | 商品二级分类 ID;用于跨表关联与去重。 | store_goods_master - goods_second_category_id。 | store_goods_master.json - data.orderGoodsList - goods_second_category_id。 | + +| `category_level1_name` | TEXT | | | 一级分类名称,如“零食”“酒水”“服务费”等。 | store_goods_master - oneCategoryName。 | store_goods_master.json - data.orderGoodsList - oneCategoryName。 | + +| `category_level2_name` | TEXT | | | 二级分类名称,如“面”“洋酒”“纸巾”等。 | store_goods_master - twoCategoryName。 | store_goods_master.json - data.orderGoodsList - twoCategoryName。 | + +| `batch_stock_qty` | INTEGER | | | 当前可用库存数量(以 unit 为单位)。 | store_goods_master - stock。 | store_goods_master.json - data.orderGoodsList - stock。 | + +| `sale_qty` | INTEGER | | | 在当前统计口径下的销售数量(总销量,单位同 unit)。 | store_goods_master - sale_num。 | store_goods_master.json - data.orderGoodsList - sale_num。 | + +| `total_sales_qty` | INTEGER | | | 累计销售数量。 | store_goods_master - total_sales。 | store_goods_master.json - data.orderGoodsList - total_sales。 | + +| `sale_price` | NUMERIC(18,2) | | | 商品标准销售价(挂牌价),单位为元。 | store_goods_master - sale_price。 | store_goods_master.json - data.orderGoodsList - sale_price。 | + +| `created_at` | TIMESTAMPTZ | | | 门店商品档案创建时间(商品在门店建立档案的时间点)。 | store_goods_master - create_time。 | store_goods_master.json - data.orderGoodsList - create_time。 | + +| `updated_at` | TIMESTAMPTZ | | | 最后一次修改该商品档案的时间(包括价格调整、状态变更等)。 | store_goods_master - update_time。 | store_goods_master.json - data.orderGoodsList - update_time。 | + +| `avg_monthly_sales` | NUMERIC(18,4) | | | 平均月销量(件/月),根据某个统计周期内的销售数据折算而来。 | store_goods_master - average_monthly_sales。 | store_goods_master.json - data.orderGoodsList - average_monthly_sales。 | + +| `goods_state` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | store_goods_master - goods_state。 | store_goods_master.json - data.orderGoodsList - goods_state。 | + +| `enable_status` | INTEGER | | | 1:启用。;用途:控制商品档案是否参与任何业务(库存、销售等)。 | store_goods_master - enable_status。 | store_goods_master.json - data.orderGoodsList - enable_status。 | + +| `send_state` | INTEGER | | | 1:可销售/可下单。 | store_goods_master - send_state。 | store_goods_master.json - data.orderGoodsList - send_state。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志;软删除/作废标记,分析通常需过滤为有效记录。 | store_goods_master - is_delete。 | store_goods_master.json - data.orderGoodsList - is_delete。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_table` + + + +- 表说明:DWD 维度表:dim_table。ODS 来源表:billiards_ods.site_tables_master(对应 JSON:site_tables_master.json;分析:site_tables_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:table_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `table_id` | BIGINT | Y | | 台桌主键 ID。;用途:这是“台”的全系统唯一标识,是各类流水表引用的核心外键。;关联:与 台费流水.json 中的 site_table_id 一致;用于跨表关联与去重。 | site_tables_master - id。 | site_tables_master.json - data.siteTables - id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID。;关联:与各个流水表、siteProfile.id 一致,本数据全部属于“朗朗桌球”这一家门店;用途:门店维度分组、计营业额、与门店档案关联。 | site_tables_master - site_id。 | site_tables_master.json - data.siteTables - site_id。 | + +| `table_name` | TEXT | | | 台号/台名称,用于前台操作界面展示,也出现在小票和各种流水中的 ledger_name 或 tableName 字段。 | site_tables_master - table_name。 | site_tables_master.json - data.siteTables - table_name。 | + +| `site_table_area_id` | BIGINT | | | 门店维度的“台桌区域 ID”;用于跨表关联与去重。 | site_tables_master - site_table_area_id。 | site_tables_master.json - data.siteTables - site_table_area_id。 | + +| `site_table_area_name` | TEXT | | | 区域名称,用于前台展示和区域维度管理。 | site_tables_master - areaName。 | site_tables_master.json - data.siteTables - areaName。 | + +| `tenant_table_area_id` | BIGINT | | | 门店维度的“台桌区域 ID”;用于跨表关联与去重。 | site_tables_master - site_table_area_id。 | site_tables_master.json - data.siteTables - site_table_area_id。 | + +| `table_price` | NUMERIC(18,2) | | | 设计上应为“台的基础单价”字段(例如按小时或按局单价)。 | site_tables_master - table_price。 | site_tables_master.json - data.siteTables - table_price。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dim_tenant_goods` + + + +- 表说明:DWD 维度表:dim_tenant_goods。ODS 来源表:billiards_ods.tenant_goods_master(对应 JSON:tenant_goods_master.json;分析:tenant_goods_master-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:tenant_goods_id + +- 类型:维度 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `tenant_goods_id` | BIGINT | Y | | 商品档案主键 ID,唯一标识一条商品。;用途:作为其他业务表(销售明细、库存流水、门店商品表等)的外键,通常以 tenant_goods_id 或类似字段出现;用于跨表关联与去重。 | tenant_goods_master - id。 | tenant_goods_master.json - data.tenantGoodsList - id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID。;用途:和其它 JSON 中的 tenant_id / tenantId 一致,用于区分不同商户(本次数据只包含同一租户)。 | tenant_goods_master - tenant_id。 | tenant_goods_master.json - data.tenantGoodsList - tenant_id。 | + +| `supplier_id` | BIGINT | | | 供应商 ID,用于关联到供应商档案。 | tenant_goods_master - supplier_id。 | tenant_goods_master.json - data.tenantGoodsList - supplier_id。 | + +| `category_name` | VARCHAR(64) | | | 商品一级分类名称(业务可读)。 | tenant_goods_master - categoryName。 | tenant_goods_master.json - data.tenantGoodsList - categoryName。 | + +| `goods_category_id` | BIGINT | | | 商品一级分类 ID;用于跨表关联与去重。 | tenant_goods_master - goods_category_id。 | tenant_goods_master.json - data.tenantGoodsList - goods_category_id。 | + +| `goods_second_category_id` | BIGINT | | | 商品二级分类 ID;用于跨表关联与去重。 | tenant_goods_master - goods_second_category_id。 | tenant_goods_master.json - data.tenantGoodsList - goods_second_category_id。 | + +| `goods_name` | VARCHAR(128) | | | 商品名称(前台展示名称)。 | tenant_goods_master - goods_name。 | tenant_goods_master.json - data.tenantGoodsList - goods_name。 | + +| `goods_number` | VARCHAR(64) | | | 商品内部编码(自定义货号/系统货号)。 | tenant_goods_master - goods_number。 | tenant_goods_master.json - data.tenantGoodsList - goods_number。 | + +| `unit` | VARCHAR(16) | | | 计量单位。 | tenant_goods_master - unit。 | tenant_goods_master.json - data.tenantGoodsList - unit。 | + +| `market_price` | NUMERIC(18,2) | | | 商品标价 / 售价(标准销售单价)。 | tenant_goods_master - market_price。 | tenant_goods_master.json - data.tenantGoodsList - market_price。 | + +| `goods_state` | INTEGER | | | 商品状态(上架/下架等)。 | tenant_goods_master - goods_state。 | tenant_goods_master.json - data.tenantGoodsList - goods_state。 | + +| `create_time` | TIMESTAMPTZ | | | 商品档案创建时间;记录源系统创建时间,用于增量同步和口径对齐。 | tenant_goods_master - create_time。 | tenant_goods_master.json - data.tenantGoodsList - create_time。 | + +| `update_time` | TIMESTAMPTZ | | | 商品档案最近一次修改时间;记录源系统更新时间,用于增量同步与变更追踪。 | tenant_goods_master - update_time。 | tenant_goods_master.json - data.tenantGoodsList - update_time。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志;软删除/作废标记,分析通常需过滤为有效记录。 | tenant_goods_master - is_delete。 | tenant_goods_master.json - data.tenantGoodsList - is_delete。 | + +| `SCD2_start_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本生效起始时间,用于历史追踪。 | | | + +| `SCD2_end_time` | TIMESTAMPTZ | | | 维度慢变(SCD2)版本失效时间(默认 9999-12-31 表示当前版本),用于历史追踪。 | | | + +| `SCD2_is_current` | INT | | | 维度慢变(SCD2)当前版本标记(1=当前),用于筛选最新维度记录。 | | | + +| `SCD2_version` | INT | | | 维度慢变(SCD2)版本号(自增),用于区分历史版本。 | | | + + + +## `dwd_assistant_service_log` + + + +- 表说明:DWD 明细事实表:dwd_assistant_service_log。ODS 来源表:billiards_ods.assistant_service_records(对应 JSON:assistant_service_records.json;分析:assistant_service_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:assistant_service_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `assistant_service_id` | BIGINT | Y | | 本条助教流水记录的主键 ID(流水唯一标识)。;用途:在系统内部唯一定位这一条助教服务记录;用于跨表关联与去重。 | assistant_service_records - id。 | assistant_service_records.json - data.orderAssistantDetails - id。 | + +| `order_trade_no` | BIGINT | | | 订单交易号,整个订单层面的编号。;关联:与台费流水、门店销售记录、团购套餐流水等表中的同名字段是一致的,用于把 同一笔订单下的各类消费明细(台费/商品/助教/套餐)串起来。 | assistant_service_records - order_trade_no。 | assistant_service_records.json - data.orderAssistantDetails - order_trade_no。 | + +| `order_settle_id` | BIGINT | | dwd_settlement_head(order_settle_id) | 订单结算 ID,相当于“结账单号”的内部主键。;关联:与小票详情中的 orderSettleId 对应;用于跨表关联与去重。 | assistant_service_records - order_settle_id。 | assistant_service_records.json - data.orderAssistantDetails - order_settle_id。 | + +| `order_pay_id` | BIGINT | | | 关联到“支付记录”的主键 ID。;用途:可以和支付记录中的 id / relate_id 等字段对应,找到这条助教服务对应的支付流水;用于跨表关联与去重。 | assistant_service_records - order_pay_id。 | assistant_service_records.json - data.orderAssistantDetails - order_pay_id。 | + +| `order_assistant_id` | BIGINT | | | 订单中“助教项目明细”的内部 ID。;用途:如果订单里有多条助教项目(比如换助教、多个时间段),此字段唯一标识这一条助教明细;用于跨表关联与去重。 | assistant_service_records - order_assistant_id。 | assistant_service_records.json - data.orderAssistantDetails - order_assistant_id。 | + +| `order_assistant_type` | INTEGER | | | 1:常规助教服务(主课/基础课)。 | assistant_service_records - order_assistant_type。 | assistant_service_records.json - data.orderAssistantDetails - order_assistant_type。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID;你这份数据中是固定值(同一个商户)。;关联:全库所有表都有,作为“商户维度”的过滤键;用途:多租户数据隔离与按租户汇总。 | assistant_service_records - tenant_id。 | assistant_service_records.json - data.orderAssistantDetails - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID,本数据中指“朗朗桌球”这一家门店。;关联:与其他所有 JSON 中的 site_id 一致,用于判断记录属于哪家门店。 | assistant_service_records - site_id。 | assistant_service_records.json - data.orderAssistantDetails - site_id。 | + +| `site_table_id` | BIGINT | | | 球台 ID。;关联:对应台桌列表中的 id 字段,表示具体是哪一张桌;用于跨表关联与去重。 | assistant_service_records - site_table_id。 | assistant_service_records.json - data.orderAssistantDetails - site_table_id。 | + +| `tenant_member_id` | BIGINT | | | 商户维度会员 ID(门店/品牌内的会员主键)。;关联:**会员档案(tenantMemberInfos)**中的 id = 此处的 tenant_member_id;用于跨表关联与去重。 | assistant_service_records - tenant_member_id。 | assistant_service_records.json - data.orderAssistantDetails - tenant_member_id。 | + +| `system_member_id` | BIGINT | | | 系统级会员 ID(全集团统一 ID)。;关联:会员档案中的 system_member_id 字段;用于跨表关联与去重。 | assistant_service_records - system_member_id。 | assistant_service_records.json - data.orderAssistantDetails - system_member_id。 | + +| `assistant_no` | VARCHAR(64) | | | 助教编号,例如 "27"。;关联:在助教账号表里也有 assistant_no 字段,对应工号/编号。 | assistant_service_records - assistantNo。 | assistant_service_records.json - data.orderAssistantDetails - assistantNo。 | + +| `nickname` | VARCHAR(64) | | | 助教对外昵称,如“佳怡”“周周”“球球”等。;关联:在很多小票、商品名里,会把 “编号-昵称” 组合使用(如 ledger_name = "2-佳怡")。 | assistant_service_records - nickname。 | assistant_service_records.json - data.orderAssistantDetails - nickname。 | + +| `site_assistant_id` | BIGINT | | | 订单中“助教项目明细”的内部 ID。;用途:如果订单里有多条助教项目(比如换助教、多个时间段),此字段唯一标识这一条助教明细;用于跨表关联与去重。 | assistant_service_records - order_assistant_id。 | assistant_service_records.json - data.orderAssistantDetails - order_assistant_id。 | + +| `user_id` | BIGINT | | | 助教对应的“用户账号 ID”(系统级用户)。;关联:在助教账号表中有同名字段 user_id,与这里完全一致;用于跨表关联与去重。 | assistant_service_records - user_id。 | assistant_service_records.json - data.orderAssistantDetails - user_id。 | + +| `assistant_team_id` | BIGINT | | | 助教所属团队 ID。;关联:在助教账号表中有 team_id 字段,对应相同值;用于跨表关联与去重。 | assistant_service_records - assistant_team_id。 | assistant_service_records.json - data.orderAssistantDetails - assistant_team_id。 | + +| `person_org_id` | BIGINT | | | 助教所属“人事组织/部门 ID”。;关联:在助教账号表中同样存在 person_org_id 字段,值完全一致;用于跨表关联与去重。 | assistant_service_records - person_org_id。 | assistant_service_records.json - data.orderAssistantDetails - person_org_id。 | + +| `assistant_level` | INTEGER | | | 业务明细字段,用于补充该记录的业务属性。 | assistant_service_records - assistant_level。 | assistant_service_records.json - data.orderAssistantDetails - assistant_level。 | + +| `level_name` | VARCHAR(64) | | | 助教等级名称,与 assistant_level 一一对应(初级/中级/高级/助教管理)。 | assistant_service_records - levelName。 | assistant_service_records.json - data.orderAssistantDetails - levelName。 | + +| `skill_id` | BIGINT | | | 助教服务“课程/技能”ID。;关联:应对应某个“课程/技能配置表”的主键(你这次导出里没见那个表);用于跨表关联与去重。 | assistant_service_records - skill_id。 | assistant_service_records.json - data.orderAssistantDetails - skill_id。 | + +| `skill_name` | VARCHAR(64) | | | 当前这条助教服务所对应的“课程/技能名称”。 | assistant_service_records - skillName。 | assistant_service_records.json - data.orderAssistantDetails - skillName。 | + +| `ledger_unit_price` | NUMERIC(10,2) | | | 助教服务 标准单价(通常是标价:每小时、每节课的单价)。 | assistant_service_records - ledger_unit_price。 | assistant_service_records.json - data.orderAssistantDetails - ledger_unit_price。 | + +| `ledger_amount` | NUMERIC(10,2) | | | 按标准单价计算出来的应收金额(近似 = ledger_unit_price × income_seconds / 3600)。 | assistant_service_records - ledger_amount。 | assistant_service_records.json - data.orderAssistantDetails - ledger_amount。 | + +| `projected_income` | NUMERIC(10,2) | | | 实际结算计入门店的金额(已经考虑折扣、卡权益、券等后的结果)。 | assistant_service_records - projected_income。 | assistant_service_records.json - data.orderAssistantDetails - projected_income。 | + +| `coupon_deduct_money` | NUMERIC(10,2) | | | 由“优惠券/代金券/团购券”等 直接抵扣到这条助教服务上的金额。 | assistant_service_records - coupon_deduct_money。 | assistant_service_records.json - data.orderAssistantDetails - coupon_deduct_money。 | + +| `income_seconds` | INTEGER | | | 计费秒数 / 应计收入对应的时间。 | assistant_service_records - income_seconds。 | assistant_service_records.json - data.orderAssistantDetails - income_seconds。 | + +| `real_use_seconds` | INTEGER | | | 实际使用时长(秒)。 | assistant_service_records - real_use_seconds。 | assistant_service_records.json - data.orderAssistantDetails - real_use_seconds。 | + +| `add_clock` | INTEGER | | | 加钟秒数,即在原有预约/服务基础上临时追加的时长。 | assistant_service_records - add_clock。 | assistant_service_records.json - data.orderAssistantDetails - add_clock。 | + +| `create_time` | TIMESTAMPTZ | | | 这条助教流水记录创建时间(一般接近结算/下单时间);记录源系统创建时间,用于增量同步和口径对齐。 | assistant_service_records - create_time。 | assistant_service_records.json - data.orderAssistantDetails - create_time。 | + +| `start_use_time` | TIMESTAMPTZ | | | 助教实际开始服务时间。 | assistant_service_records - start_use_time。 | assistant_service_records.json - data.orderAssistantDetails - start_use_time。 | + +| `last_use_time` | TIMESTAMPTZ | | | 最后一次使用(实际服务)时间。 | assistant_service_records - last_use_time。 | assistant_service_records.json - data.orderAssistantDetails - last_use_time。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志。;注意:这份助教流水里没有直接出现“顾客姓名”字段,只通过这两个 ID 与会员档案、储值卡等表关联;软删除/作废标记,分析通常需过滤为有效记录。 | assistant_service_records - is_delete。 | assistant_service_records.json - data.orderAssistantDetails - is_delete。 | + + + +## `dwd_assistant_trash_event` + + + +- 表说明:DWD 明细事实表:dwd_assistant_trash_event。ODS 来源表:billiards_ods.assistant_cancellation_records(对应 JSON:assistant_cancellation_records.json;分析:assistant_cancellation_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:assistant_trash_event_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `assistant_trash_event_id` | BIGINT | Y | | 助教trashevent ID(源系统唯一标识),用于跨表关联、去重与维度汇总。 | assistant_cancellation_records - id。 | assistant_cancellation_records.json - data.abolitionAssistants - id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID,即该废除记录所在门店。;关联:与 siteProfile.id 一致;用途:门店维度分组、计营业额、与门店档案关联。 | assistant_cancellation_records - siteId。 | assistant_cancellation_records.json - data.abolitionAssistants - siteId。 | + +| `table_id` | BIGINT | | dim_table(table_id) | 球台/桌子的 ID。;关联:对应 “台桌列表.json” 中的 id 字段;用于跨表关联与去重。 | assistant_cancellation_records - tableId。 | assistant_cancellation_records.json - data.abolitionAssistants - tableId。 | + +| `table_area_id` | BIGINT | | | 台桌所在区域 ID。;关联:应对应“区域配置表”的主键(本次导出未包含该表);用于跨表关联与去重。 | assistant_cancellation_records - tableAreaId。 | assistant_cancellation_records.json - data.abolitionAssistants - tableAreaId。 | + +| `assistant_no` | VARCHAR(32) | | | 助教姓名/对外展示名称。;注意:这是被废除的那位助教,不是顾客姓名。 | assistant_cancellation_records - assistantName。 | assistant_cancellation_records.json - data.abolitionAssistants - assistantName。 | + +| `assistant_name` | VARCHAR(64) | | | 助教姓名/对外展示名称。;注意:这是被废除的那位助教,不是顾客姓名。 | assistant_cancellation_records - assistantName。 | assistant_cancellation_records.json - data.abolitionAssistants - assistantName。 | + +| `charge_minutes_raw` | INTEGER | | | “已发生的计费时长(分钟)”,即这次助教服务在被废除前已经累计了多少分钟。 | assistant_cancellation_records - pdChargeMinutes。 | assistant_cancellation_records.json - data.abolitionAssistants - pdChargeMinutes。 | + +| `abolish_amount` | NUMERIC(18,2) | | | 与“助教废除”关联的金额字段。字面上是“助教废除金额”。 | assistant_cancellation_records - assistantAbolishAmount。 | assistant_cancellation_records.json - data.abolitionAssistants - assistantAbolishAmount。 | + +| `trash_reason` | VARCHAR(255) | | | 用于记录“废除原因”的文本描述,例如“顾客临时有事取消”“录入错误”“更换助教”等。 | assistant_cancellation_records - trashReason。 | assistant_cancellation_records.json - data.abolitionAssistants - trashReason。 | + +| `create_time` | TIMESTAMPTZ | | | 这条“助教废除记录”被创建的时间,即系统正式记录“废除”操作的时刻;记录源系统创建时间,用于增量同步和口径对齐。 | assistant_cancellation_records - createTime。 | assistant_cancellation_records.json - data.abolitionAssistants - createTime。 | + + + +## `dwd_groupbuy_redemption` + + + +- 表说明:DWD 明细事实表:dwd_groupbuy_redemption。ODS 来源表:billiards_ods.group_buy_redemption_records(对应 JSON:group_buy_redemption_records.json;分析:group_buy_redemption_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:redemption_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `redemption_id` | BIGINT | Y | | 本条“团购套餐流水”记录的 主键 ID。;用途:唯一标识一条券使用到台费上的记录;用于跨表关联与去重。 | group_buy_redemption_records - id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID;用途:多租户数据隔离与按租户汇总。 | group_buy_redemption_records - tenant_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID,与其它 JSON 中一致。;关联:与“团购套餐定义”、“助教流水”、“台费流水”、“门店销售记录”等文件中的 site_id 完全一致,用于统一按门店过滤。 | group_buy_redemption_records - site_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - site_id。 | + +| `table_id` | BIGINT | | dim_table(table_id) | 球台 ID。;关联:对应“台桌列表”表中的 id 字段;用于跨表关联与去重。 | group_buy_redemption_records - table_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - table_id。 | + +| `tenant_table_area_id` | BIGINT | | | 租户级台区分组 ID,表示当前使用券的台桌所属的区域组合。;关联:与“团购套餐定义”中的 tenant_table_area_id_list 对应(那边是字符串形态,这里是数值形态),表明该券只能在某些台区组合上使用;用于跨表关联与去重。 | group_buy_redemption_records - tenant_table_area_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - tenant_table_area_id。 | + +| `table_charge_seconds` | INTEGER | | | 本次结算中该球台总计计费的秒数(整台的台费计费时间)。 | group_buy_redemption_records - table_charge_seconds。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - table_charge_seconds。 | + +| `order_trade_no` | BIGINT | | | 订单交易号,和其它消费明细(台费、商品、助教、团购)共用的订单主键。;关联:与“小票详情”、“台费流水”、“助教流水”等的 order_trade_no 一致,用于将同一笔结账中的所有子项目关联起来。 | group_buy_redemption_records - order_trade_no。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - order_trade_no。 | + +| `order_settle_id` | BIGINT | | dwd_settlement_head(order_settle_id) | 结算单 ID(小票结账主键)。;关联:与“小票详情”中的 orderSettleId 相对应;用于跨表关联与去重。 | group_buy_redemption_records - order_settle_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - order_settle_id。 | + +| `order_coupon_id` | BIGINT | | | 订单中“券使用记录”的 ID;用于跨表关联与去重。 | group_buy_redemption_records - order_coupon_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - order_coupon_id。 | + +| `coupon_origin_id` | BIGINT | | | 平台/上游系统中的券记录主键 ID,“券来源 ID”;用于跨表关联与去重。 | group_buy_redemption_records - coupon_origin_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - coupon_origin_id。 | + +| `promotion_activity_id` | BIGINT | | | 团购/促销活动 ID;用于跨表关联与去重。 | group_buy_redemption_records - promotion_activity_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - promotion_activity_id。 | + +| `promotion_coupon_id` | BIGINT | | | 团购套餐定义 ID。;关联:与 20251110_043255_团购套餐.json 中的 id 字段一一对应,即:;用于跨表关联与去重。 | group_buy_redemption_records - promotion_coupon_id。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - promotion_coupon_id。 | + +| `order_coupon_channel` | INTEGER | | | 券渠道类型,例如:。 | group_buy_redemption_records - order_coupon_channel。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - order_coupon_channel。 | + +| `ledger_unit_price` | NUMERIC(18,2) | | | 对应台费的标准单价,单位元/小时(从数值来看是类似29.9/小时这种定价)。;用途:配合 ledger_count 用于计算这一条券在台费层面对应的金额(理论上应接近 = 单价 × 秒数/3600)。 | group_buy_redemption_records - ledger_unit_price。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - ledger_unit_price。 | + +| `ledger_count` | INTEGER | | | 按此次优惠实际计算的“核销秒数”。 | group_buy_redemption_records - ledger_count。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - ledger_count。 | + +| `ledger_amount` | NUMERIC(18,2) | | | 本次券实际冲抵台费的金额。 | group_buy_redemption_records - ledger_amount。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - ledger_amount。 | + +| `coupon_money` | NUMERIC(18,2) | | | 本次核销时,这张券在门店侧对应的金额额度(“可抵扣金额”)。 | group_buy_redemption_records - coupon_money。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - coupon_money。 | + +| `promotion_seconds` | INTEGER | | | 团购套餐定义的“标准时长”(券本身标称的可用时长)。 | group_buy_redemption_records - promotion_seconds。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - promotion_seconds。 | + +| `coupon_code` | VARCHAR(64) | | | 团购券券码,核销时扫描/录入的字符串。;关联:与平台验券记录表中的 coupon_code 完全一致,通过该字段可以串起“平台 → 核销 → 台费流水”全链路。 | group_buy_redemption_records - coupon_code。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - coupon_code。 | + +| `is_single_order` | INTEGER | | | 是否单独作为一条订单行。 | group_buy_redemption_records - is_single_order。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - is_single_order。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志:;软删除/作废标记,分析通常需过滤为有效记录。 | group_buy_redemption_records - is_delete。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - is_delete。 | + +| `ledger_name` | VARCHAR(128) | | | 台费侧关联的“团购项目名称”(记账名)。 | group_buy_redemption_records - ledger_name。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - ledger_name。 | + +| `create_time` | TIMESTAMPTZ | | | 本条团购套餐使用流水创建时间(即券核销时间,或与结账时间接近);记录源系统创建时间,用于增量同步和口径对齐。 | group_buy_redemption_records - create_time。 | group_buy_redemption_records.json - data.siteTableUseDetailsList - create_time。 | + + + +## `dwd_member_balance_change` + + + +- 表说明:DWD 明细事实表:dwd_member_balance_change。ODS 来源表:billiards_ods.member_balance_changes(对应 JSON:member_balance_changes.json;分析:member_balance_changes-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:balance_change_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `balance_change_id` | BIGINT | Y | | 余额变更记录的主键 ID,唯一标识这一条“账户余额变化事件”;用于跨表关联与去重。 | member_balance_changes - id。 | member_balance_changes.json - data.tenantMemberCardLogs - id。 | + +| `tenant_id` | BIGINT | | | 租户/商户 ID,本数据中是固定值(同一品牌/商户);用途:多租户数据隔离与按租户汇总。 | member_balance_changes - tenant_id。 | member_balance_changes.json - data.tenantMemberCardLogs - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 非 0:记录所属的具体门店 ID(与其他 JSON 内的 site_id 一致)。;关联:可与门店档案(siteProfile.id)对应;用途:门店维度分组、计营业额、与门店档案关联。 | member_balance_changes - site_id。 | member_balance_changes.json - data.tenantMemberCardLogs - site_id。 | + +| `register_site_id` | BIGINT | | | 会员卡的“注册门店 ID”,即办卡所在门店;用于跨表关联与去重。 | member_balance_changes - register_site_id。 | member_balance_changes.json - data.tenantMemberCardLogs - register_site_id。 | + +| `tenant_member_id` | BIGINT | | | 商户维度的会员 ID(租户内会员主键)。;用途:在本表与会员档案之间形成外键关系: 余额变更记录.tenant_member_id = 会员档案.id;关联:对应“会员档案(20251110_043209_…)”中的 id 字段,即同一个租户下的会员主键;用于跨表关联与去重。 | member_balance_changes - tenant_member_id。 | member_balance_changes.json - data.tenantMemberCardLogs - tenant_member_id。 | + +| `system_member_id` | BIGINT | | | 系统级(全局)会员 ID。;关联:对应会员档案中的 system_member_id 字段;用于跨表关联与去重。 | member_balance_changes - system_member_id。 | member_balance_changes.json - data.tenantMemberCardLogs - system_member_id。 | + +| `tenant_member_card_id` | BIGINT | | | 会员卡账户 ID,在租户内唯一标识某张卡。;用途:一名会员可以有多张卡(储值卡、台费卡、酒水卡、活动券等),tenant_member_card_id 指明这条余额变更是针对哪一张卡。;关联:对应“会员档案/储值卡列表”中的 id(卡账户 ID);用于跨表关联与去重。 | member_balance_changes - tenant_member_card_id。 | member_balance_changes.json - data.tenantMemberCardLogs - tenant_member_card_id。 | + +| `card_type_id` | BIGINT | | | 卡种类型 ID,用于区分不同卡种。 | member_balance_changes - card_type_id。 | member_balance_changes.json - data.tenantMemberCardLogs - card_type_id。 | + +| `card_type_name` | VARCHAR(32) | | | 卡种名称,与 card_type_id 一一对应,是一个 卡种枚举名称。 | member_balance_changes - memberCardTypeName。 | member_balance_changes.json - data.tenantMemberCardLogs - memberCardTypeName。 | + +| `member_name` | VARCHAR(64) | | | 会员姓名或称呼(非昵称字段)。 | member_balance_changes - memberName。 | member_balance_changes.json - data.tenantMemberCardLogs - memberName。 | + +| `member_mobile` | VARCHAR(20) | | | 会员手机号;手机号码,用于账户/会员识别、查询与联系。 | member_balance_changes - memberMobile。 | member_balance_changes.json - data.tenantMemberCardLogs - memberMobile。 | + +| `balance_before` | NUMERIC(18,2) | | | 本次变动前,该卡账户的余额(元)。 | member_balance_changes - before。 | member_balance_changes.json - data.tenantMemberCardLogs - before。 | + +| `change_amount` | NUMERIC(18,2) | | | 本次变动的金额(元),正数表示增加,负数表示减少。 | member_balance_changes - account_data。 | member_balance_changes.json - data.tenantMemberCardLogs - account_data。 | + +| `balance_after` | NUMERIC(18,2) | | | 本次变动后,该卡账户的余额(元)。 | member_balance_changes - after。 | member_balance_changes.json - data.tenantMemberCardLogs - after。 | + +| `from_type` | INTEGER | | | 1:日常消费扣款。 | member_balance_changes - from_type。 | member_balance_changes.json - data.tenantMemberCardLogs - from_type。 | + +| `payment_method` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | member_balance_changes - payment_method。 | member_balance_changes.json - data.tenantMemberCardLogs - payment_method。 | + +| `change_time` | TIMESTAMPTZ | | | 本条余额变更记录的创建时间,通常接近交易发生时间。 | member_balance_changes - create_time。 | member_balance_changes.json - data.tenantMemberCardLogs - create_time。 | + +| `is_delete` | INTEGER | | | 逻辑删除标记:;软删除/作废标记,分析通常需过滤为有效记录。 | member_balance_changes - is_delete。 | member_balance_changes.json - data.tenantMemberCardLogs - is_delete。 | + +| `remark` | VARCHAR(255) | | | 当为空时,说明这条变动没有额外备注说明。 | member_balance_changes - remark。 | member_balance_changes.json - data.tenantMemberCardLogs - remark。 | + + + +## `dwd_payment` + + + +- 表说明:DWD 明细事实表:dwd_payment。ODS 来源表:billiards_ods.payment_transactions(对应 JSON:payment_transactions.json;分析:payment_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:payment_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `payment_id` | BIGINT | Y | | 支付流水记录的主键 ID。;用途:在“支付记录”这个表内部,唯一标识一条支付流水(包括金额为 0 的记录);用于跨表关联与去重。 | payment_transactions - id。 | payment_transactions.json - $ - id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 支付记录所属的门店 ID;用途:门店维度分组、计营业额、与门店档案关联。 | payment_transactions - site_id。 | payment_transactions.json - $ - site_id。 | + +| `relate_type` | INTEGER | | | 表示“这条支付记录关联的业务类型”。 | payment_transactions - relate_type。 | payment_transactions.json - $ - relate_type。 | + +| `relate_id` | BIGINT | | | 关联业务记录的主键 ID(按 relate_type 不同指向不同表);用于跨表关联与去重。 | payment_transactions - relate_id。 | payment_transactions.json - $ - relate_id。 | + +| `pay_amount` | NUMERIC(18,2) | | | 本条支付流水的“支付金额”,单位为元。 | payment_transactions - pay_amount。 | payment_transactions.json - $ - pay_amount。 | + +| `pay_status` | INTEGER | | | 支付状态枚举字段。 | payment_transactions - pay_status。 | payment_transactions.json - $ - pay_status。 | + +| `payment_method` | INTEGER | | | 支付方式枚举,例如微信、支付宝、现金、银行卡、储值卡等某一种。 | payment_transactions - payment_method。 | payment_transactions.json - $ - payment_method。 | + +| `online_pay_channel` | INTEGER | | | 线上支付渠道枚举,例如:。 | payment_transactions - online_pay_channel。 | payment_transactions.json - $ - online_pay_channel。 | + +| `create_time` | TIMESTAMPTZ | | | 支付记录创建时间,通常与发起支付请求的时间一致(创建支付流水的时间戳);记录源系统创建时间,用于增量同步和口径对齐。 | payment_transactions - create_time。 | payment_transactions.json - $ - create_time。 | + +| `pay_time` | TIMESTAMPTZ | | | 实际支付完成时间(支付状态变为成功的时间戳)。 | payment_transactions - pay_time。 | payment_transactions.json - $ - pay_time。 | + +| `pay_date` | DATE | | | 业务明细字段,用于补充该记录的业务属性。 | payment_transactions - pay_time(派生:DATE(pay_time))。 | payment_transactions.json - $ - pay_time(派生:DATE(pay_time))。 | + + + +## `dwd_platform_coupon_redemption` + + + +- 表说明:DWD 明细事实表:dwd_platform_coupon_redemption。ODS 来源表:billiards_ods.platform_coupon_redemption_records(对应 JSON:platform_coupon_redemption_records.json;分析:platform_coupon_redemption_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:platform_coupon_redemption_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `platform_coupon_redemption_id` | BIGINT | Y | | 本条平台验券记录在本系统内的主键 ID;用于跨表关联与去重。 | platform_coupon_redemption_records - id。 | platform_coupon_redemption_records.json - $ - id。 | + +| `tenant_id` | BIGINT | | | 商户/租户 ID(品牌级别)。;关联:与其他所有 JSON 中的 tenant_id 一致,用于区分不同品牌/商户的数据域。 | platform_coupon_redemption_records - tenant_id。 | platform_coupon_redemption_records.json - $ - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID。;关联:对应 siteProfile.id;用途:门店维度分组、计营业额、与门店档案关联。 | platform_coupon_redemption_records - site_id。 | platform_coupon_redemption_records.json - $ - site_id。 | + +| `coupon_code` | VARCHAR(64) | | | 券码,顾客出示的团购券密码/编号。 | platform_coupon_redemption_records - coupon_code。 | platform_coupon_redemption_records.json - $ - coupon_code。 | + +| `coupon_channel` | INTEGER | | | 券来源渠道(第三方平台渠道编号)。 | platform_coupon_redemption_records - coupon_channel。 | platform_coupon_redemption_records.json - $ - coupon_channel。 | + +| `coupon_name` | VARCHAR(200) | | | 团购券产品名称(即第三方平台上向顾客展示的名称)。 | platform_coupon_redemption_records - coupon_name。 | platform_coupon_redemption_records.json - $ - coupon_name。 | + +| `sale_price` | NUMERIC(10,2) | | | 顾客在第三方平台上实际支付的价格(团购售价)。 | platform_coupon_redemption_records - sale_price。 | platform_coupon_redemption_records.json - $ - sale_price。 | + +| `coupon_money` | NUMERIC(10,2) | | | 券面值 / 套餐价值(系统层面的“可抵扣金额或对应套餐价值”)。 | platform_coupon_redemption_records - coupon_money。 | platform_coupon_redemption_records.json - $ - coupon_money。 | + +| `coupon_free_time` | INTEGER | | | 券附带的“免费时长”字段(例如送多少分钟台费)。 | platform_coupon_redemption_records - coupon_free_time。 | platform_coupon_redemption_records.json - $ - coupon_free_time。 | + +| `channel_deal_id` | BIGINT | | | 渠道侧 dealId / 产品 ID,一般是第三方平台给该团购商品定义的主键;用于跨表关联与去重。 | platform_coupon_redemption_records - channel_deal_id。 | platform_coupon_redemption_records.json - $ - channel_deal_id。 | + +| `deal_id` | BIGINT | | | 另一个层次的团购产品 ID;用于跨表关联与去重。 | platform_coupon_redemption_records - deal_id。 | platform_coupon_redemption_records.json - $ - deal_id。 | + +| `group_package_id` | BIGINT | | | group套餐 ID(源系统唯一标识),用于跨表关联、去重与维度汇总。 | platform_coupon_redemption_records - group_package_id。 | platform_coupon_redemption_records.json - $ - group_package_id。 | + +| `site_order_id` | BIGINT | | | 门店内部的订单 ID(平台券核销时对应的店内订单)。;关联:与台费流水、门店销售记录、助教流水等中出现的订单 ID 字段对应,用于把“平台券核销记录”挂到一笔本地订单上。 | platform_coupon_redemption_records - site_order_id。 | platform_coupon_redemption_records.json - $ - site_order_id。 | + +| `table_id` | BIGINT | | dim_table(table_id) | 使用券的球台 ID。;关联:与“台桌列表”中的 id 对应;用于跨表关联与去重。 | platform_coupon_redemption_records - table_id。 | platform_coupon_redemption_records.json - $ - table_id。 | + +| `certificate_id` | VARCHAR(64) | | | 平台侧的凭证 ID(通常由第三方团购平台生成的券实例 ID);用于跨表关联与去重。 | platform_coupon_redemption_records - certificate_id。 | platform_coupon_redemption_records.json - $ - certificate_id。 | + +| `verify_id` | VARCHAR(64) | | | 平台核销记录 ID(某些平台会为每一次核销生成一个唯一 ID);用于跨表关联与去重。 | platform_coupon_redemption_records - verify_id。 | platform_coupon_redemption_records.json - $ - verify_id。 | + +| `use_status` | INTEGER | | | 1:已使用 / 已核销(正常消耗)。 | platform_coupon_redemption_records - use_status。 | platform_coupon_redemption_records.json - $ - use_status。 | + +| `is_delete` | INTEGER | | | 0:未删除;用途:把平台验券记录挂到本门店的一条订单上;软删除/作废标记,分析通常需过滤为有效记录。 | platform_coupon_redemption_records - is_delete。 | platform_coupon_redemption_records.json - $ - is_delete。 | + +| `create_time` | TIMESTAMPTZ | | | 验券记录在本系统中创建的时间(记录入库时间);记录源系统创建时间,用于增量同步和口径对齐。 | platform_coupon_redemption_records - create_time。 | platform_coupon_redemption_records.json - $ - create_time。 | + +| `consume_time` | TIMESTAMPTZ | | | 券被核销/使用的业务时间。 | platform_coupon_redemption_records - consume_time。 | platform_coupon_redemption_records.json - $ - consume_time。 | + + + +## `dwd_recharge_order` + + + +- 表说明:DWD 明细事实表:dwd_recharge_order。ODS 来源表:billiards_ods.recharge_settlements(对应 JSON:recharge_settlements.json;分析:recharge_settlements-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:recharge_order_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `recharge_order_id` | BIGINT | Y | | 门店 ID;用于跨表关联与去重。 | recharge_settlements - id。 | recharge_settlements.json - $ - id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID,和 siteProfile.tenant_id 一致;用途:多租户数据隔离与按租户汇总。 | recharge_settlements - tenantid。 | recharge_settlements.json - $ - tenantid。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID,和 siteProfile.id 一致;用途:门店维度分组、计营业额、与门店档案关联。 | recharge_settlements - siteid。 | recharge_settlements.json - $ - siteid。 | + +| `member_id` | BIGINT | | dim_member(member_id) | 会员档案的主键 ID。;关联:对应“会员档案.json”中 tenantMemberInfos 的 id 字段(部分成员能直接匹配);用于跨表关联与去重。 | recharge_settlements - memberid。 | recharge_settlements.json - $ - memberid。 | + +| `member_name_snapshot` | TEXT | | | 会员名称/昵称快照。 | recharge_settlements - membername。 | recharge_settlements.json - $ - membername。 | + +| `member_phone_snapshot` | TEXT | | | 会员手机号快照,用于查找和展示。 | recharge_settlements - memberphone。 | recharge_settlements.json - $ - memberphone。 | + +| `tenant_member_card_id` | BIGINT | | | 会员卡实例 ID(某张具体卡);用于跨表关联与去重。 | recharge_settlements - tenantmembercardid。 | recharge_settlements.json - $ - tenantmembercardid。 | + +| `member_card_type_name` | TEXT | | | 本次充值针对的会员卡类型名称。 | recharge_settlements - membercardtypename。 | recharge_settlements.json - $ - membercardtypename。 | + +| `settle_relate_id` | BIGINT | | | 关联的“结算单/业务单”ID;用于跨表关联与去重。 | recharge_settlements - settlerelateid。 | recharge_settlements.json - $ - settlerelateid。 | + +| `settle_type` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | recharge_settlements - settletype。 | recharge_settlements.json - $ - settletype。 | + +| `settle_name` | TEXT | | | 业务类型名称,用于前端展示。 | recharge_settlements - settlename。 | recharge_settlements.json - $ - settlename。 | + +| `is_first` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | recharge_settlements - isfirst。 | recharge_settlements.json - $ - isfirst。 | + +| `pay_amount` | NUMERIC(18,2) | | | 本次记录对应的充值金额(含正负)。 | recharge_settlements - payamount。 | recharge_settlements.json - $ - payamount。 | + +| `refund_amount` | NUMERIC(18,2) | | | 针对本条充值订单所做的退款金额(通常为正数)。 | recharge_settlements - refundamount。 | recharge_settlements.json - $ - refundamount。 | + +| `point_amount` | NUMERIC(18,2) | | | 计入会员账户的“储值金额”或“积分型金额”。 | recharge_settlements - pointamount。 | recharge_settlements.json - $ - pointamount。 | + +| `cash_amount` | NUMERIC(18,2) | | | 现金收款金额。 | recharge_settlements - cashamount。 | recharge_settlements.json - $ - cashamount。 | + +| `payment_method` | INTEGER | | | 支付方式编码。 | recharge_settlements - paymentmethod。 | recharge_settlements.json - $ - paymentmethod。 | + +| `create_time` | TIMESTAMPTZ | | | 充值记录创建时间,一般即收银完成时间;记录源系统创建时间,用于增量同步和口径对齐。 | recharge_settlements - createtime。 | recharge_settlements.json - $ - createtime。 | + +| `pay_time` | TIMESTAMPTZ | | | 支付完成时间。 | recharge_settlements - paytime。 | recharge_settlements.json - $ - paytime。 | + + + +## `dwd_refund` + + + +- 表说明:DWD 明细事实表:dwd_refund。ODS 来源表:billiards_ods.refund_transactions(对应 JSON:refund_transactions.json;分析:refund_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:refund_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `refund_id` | BIGINT | Y | | 本条 退款流水 的唯一 ID。;用途:作为退款记录表主键,内部检索用;用于跨表关联与去重。 | refund_transactions - id。 | refund_transactions.json - $ - id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID,全系统维度标识该商户。;用途:作为所有门店数据的“租户分区键”;用途:多租户数据隔离与按租户汇总。 | refund_transactions - tenant_id。 | refund_transactions.json - $ - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID。;用途:关联其他数据表中同一门店的数据;用途:门店维度分组、计营业额、与门店档案关联。 | refund_transactions - site_id。 | refund_transactions.json - $ - site_id。 | + +| `relate_type` | INTEGER | | | 本退款对应的“业务类型”。 | refund_transactions - relate_type。 | refund_transactions.json - $ - relate_type。 | + +| `relate_id` | BIGINT | | | 本次退款关联的业务 ID;用于跨表关联与去重。 | refund_transactions - relate_id。 | refund_transactions.json - $ - relate_id。 | + +| `pay_amount` | NUMERIC(18,2) | | | 本次退款的 资金变动金额。 | refund_transactions - pay_amount。 | refund_transactions.json - $ - pay_amount。 | + +| `channel_fee` | NUMERIC(18,2) | | | 第三方支付渠道对本次退款收取的手续费。 | refund_transactions - channel_fee。 | refund_transactions.json - $ - channel_fee。 | + +| `pay_time` | TIMESTAMPTZ | | | 退款在支付渠道层面实际发生的时间。 | refund_transactions - pay_time。 | refund_transactions.json - $ - pay_time。 | + +| `create_time` | TIMESTAMPTZ | | | 本条退款流水在系统内创建时间;记录源系统创建时间,用于增量同步和口径对齐。 | refund_transactions - create_time。 | refund_transactions.json - $ - create_time。 | + +| `payment_method` | INTEGER | | | 支付/退款的 方式类型:。 | refund_transactions - payment_method。 | refund_transactions.json - $ - payment_method。 | + +| `member_id` | BIGINT | | dim_member(member_id) | 租户内部的会员 ID(对应会员档案中的某个主键);用于跨表关联与去重。 | refund_transactions - member_id。 | refund_transactions.json - $ - member_id。 | + +| `member_card_id` | BIGINT | | dim_member_card_account(member_card_id) | 关联的会员卡账户 ID(对应“储值卡列表”或“会员档案”中的某一张卡);用于跨表关联与去重。 | refund_transactions - member_card_id。 | refund_transactions.json - $ - member_card_id。 | + + + +## `dwd_settlement_head` + + + +- 表说明:DWD 明细事实表:dwd_settlement_head。ODS 来源表:billiards_ods.settlement_records(对应 JSON:settlement_records.json;分析:settlement_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:order_settle_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `order_settle_id` | BIGINT | Y | | 结账记录主键 ID(订单结算 ID)。;关联:与台费流水(siteTableUseDetailsList)中的 order_settle_id 一致;用于跨表关联与去重。 | settlement_records - id。 | settlement_records.json - $ - id。 | + +| `tenant_id` | BIGINT | | | 租户/商户 ID(品牌维度);用途:多租户数据隔离与按租户汇总。 | settlement_records - tenantid。 | settlement_records.json - $ - tenantid。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID。;关联:与其他所有 JSON 中的 site_id 对应;用途:门店维度分组、计营业额、与门店档案关联。 | settlement_records - siteid。 | settlement_records.json - $ - siteid。 | + +| `site_name` | VARCHAR(100) | | | 门店名称,冗余展示字段。 | settlement_records - sitename。 | settlement_records.json - $ - sitename。 | + +| `table_id` | BIGINT | | dim_table(table_id) | 本次结账对应的桌台 ID。;关联:对应台桌维表或台费流水中的 site_table_id;用于跨表关联与去重。 | settlement_records - tableid。 | settlement_records.json - $ - tableid。 | + +| `settle_name` | VARCHAR(100) | | | 结账对象名称,一般是“区域 + 桌号”的组合。 | settlement_records - settlename。 | settlement_records.json - $ - settlename。 | + +| `order_trade_no` | BIGINT | | | 关联订单的“交易号”(order_trade_no)。;关联:与台费流水(order_trade_no)、助教流水(order_trade_no)中的该字段完全一致。 | settlement_records - settlerelateid。 | settlement_records.json - $ - settlerelateid。 | + +| `create_time` | TIMESTAMPTZ | | | 结账记录创建时间,一般对应收银端点“确认结账”的时间;记录源系统创建时间,用于增量同步和口径对齐。 | settlement_records - createtime。 | settlement_records.json - $ - createtime。 | + +| `pay_time` | TIMESTAMPTZ | | | 实际支付完成时间。通常晚于 createTime(比如多支付场景)。 | settlement_records - paytime。 | settlement_records.json - $ - paytime。 | + +| `settle_type` | INTEGER | | | 代表结账类型,比如:。 | settlement_records - settletype。 | settlement_records.json - $ - settletype。 | + +| `revoke_order_id` | BIGINT | | | 若当前记录是“被撤销的单”,则记录对应的“撤销单 ID”;或反过来记录“原单 ID”;用于跨表关联与去重。 | settlement_records - revokeorderid。 | settlement_records.json - $ - revokeorderid。 | + +| `member_id` | BIGINT | | dim_member(member_id) | 会员主键 ID。;关联:与“会员卡列表(tenantMemberCards)”中的 tenant_member_id 一致;用于跨表关联与去重。 | settlement_records - memberid。 | settlement_records.json - $ - memberid。 | + +| `member_name` | VARCHAR(100) | | | 会员姓名快照。 | settlement_records - membername。 | settlement_records.json - $ - membername。 | + +| `member_phone` | VARCHAR(50) | | | 会员手机号快照;手机号码,用于账户/会员识别、查询与联系。 | settlement_records - memberphone。 | settlement_records.json - $ - memberphone。 | + +| `member_card_account_id` | BIGINT | | | 会员卡账户 ID(与 memberId、会员卡表的 id 之间存在映射);用于跨表关联与去重。 | settlement_records - tenantmembercardid。 | settlement_records.json - $ - tenantmembercardid。 | + +| `member_card_type_name` | VARCHAR(100) | | | 会员卡类型名称,如“储值卡”“次卡”“活动抵用券”等。 | settlement_records - membercardtypename。 | settlement_records.json - $ - membercardtypename。 | + +| `is_bind_member` | BOOLEAN | | | 本次结账是否绑定了会员。 | settlement_records - isbindmember。 | settlement_records.json - $ - isbindmember。 | + +| `member_discount_amount` | NUMERIC(18,2) | | | 会员折扣产生的优惠金额(元)。 | settlement_records - memberdiscountamount。 | settlement_records.json - $ - memberdiscountamount。 | + +| `consume_money` | NUMERIC(18,2) | | | 本次结账消费总额(不考虑支付方式/优惠结构的前后顺序,单纯汇总项目金额)。 | settlement_records - consumemoney。 | settlement_records.json - $ - consumemoney。 | + +| `table_charge_money` | NUMERIC(18,2) | | | 台费(桌台计费部分)的金额。 | settlement_records - tablechargemoney。 | settlement_records.json - $ - tablechargemoney。 | + +| `goods_money` | NUMERIC(18,2) | | | 商品销售金额(原始商品金额)。 | settlement_records - goodsmoney。 | settlement_records.json - $ - goodsmoney。 | + +| `real_goods_money` | NUMERIC(18,2) | | | 商品实际计入金额(可能已扣除某些折扣、促销)。 | settlement_records - realgoodsmoney。 | settlement_records.json - $ - realgoodsmoney。 | + +| `assistant_pd_money` | NUMERIC(18,2) | | | 助教“排钟/上课”应计金额(原价)。;关联:与 助教流水.json 中对应订单的 ledger_amount 一致(应收金额)。 | settlement_records - assistantpdmoney。 | settlement_records.json - $ - assistantpdmoney。 | + +| `assistant_cx_money` | NUMERIC(18,2) | | | 助教“次课/套餐/持续课”等另一类助教项目的金额。 | settlement_records - assistantcxmoney。 | settlement_records.json - $ - assistantcxmoney。 | + +| `adjust_amount` | NUMERIC(18,2) | | | 人工调价金额(总和),包括整单减免、特殊调整等。 | settlement_records - adjustamount。 | settlement_records.json - $ - adjustamount。 | + +| `pay_amount` | NUMERIC(18,2) | | | 本次结账“实付金额”(顾客实际支付的总金额),不包括券面值、积分等非现金部分。 | settlement_records - payamount。 | settlement_records.json - $ - payamount。 | + +| `balance_amount` | NUMERIC(18,2) | | | 从会员余额账户扣除的金额(储值卡余额消费)。 | settlement_records - balanceamount。 | settlement_records.json - $ - balanceamount。 | + +| `recharge_card_amount` | NUMERIC(18,2) | | | 与“充值卡”相关的支付额,可能表示本次使用充值卡抵扣的金额。 | settlement_records - rechargecardamount。 | settlement_records.json - $ - rechargecardamount。 | + +| `gift_card_amount` | NUMERIC(18,2) | | | 礼品卡/代金卡的支付金额。 | settlement_records - giftcardamount。 | settlement_records.json - $ - giftcardamount。 | + +| `coupon_amount` | NUMERIC(18,2) | | | 本单实际由优惠券(代金券/团购券等)抵扣的金额。 | settlement_records - couponamount。 | settlement_records.json - $ - couponamount。 | + +| `rounding_amount` | NUMERIC(18,2) | | | 抹零金额/舍入差值。如四舍五入或按角、分抹零产生的调整。 | settlement_records - roundingamount。 | settlement_records.json - $ - roundingamount。 | + +| `point_amount` | NUMERIC(18,2) | | | 代表与积分相关的一个金额或数量指标。结合字段命名,可能有两种用途:。 | settlement_records - pointamount。 | settlement_records.json - $ - pointamount。 | + + + +## `dwd_store_goods_sale` + + + +- 表说明:DWD 明细事实表:dwd_store_goods_sale。ODS 来源表:billiards_ods.store_goods_sales_records(对应 JSON:store_goods_sales_records.json;分析:store_goods_sales_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:store_goods_sale_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `store_goods_sale_id` | BIGINT | Y | | 本条「门店销售流水」记录的主键 ID;用于跨表关联与去重。 | store_goods_sales_records - id。 | store_goods_sales_records.json - data.orderGoodsLedgers - id。 | + +| `order_trade_no` | BIGINT | | | 订单交易号(业务单号)。 | store_goods_sales_records - order_trade_no。 | store_goods_sales_records.json - data.orderGoodsLedgers - order_trade_no。 | + +| `order_settle_id` | BIGINT | | dwd_settlement_head(order_settle_id) | 订单结算 ID(结账单主键);用于跨表关联与去重。 | store_goods_sales_records - order_settle_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - order_settle_id。 | + +| `order_pay_id` | BIGINT | | | 关联支付记录的 ID;用于跨表关联与去重。 | store_goods_sales_records - order_pay_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - order_pay_id。 | + +| `order_goods_id` | BIGINT | | | 订单商品明细 ID(订单内部的商品行主键);用于跨表关联与去重。 | store_goods_sales_records - order_goods_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - order_goods_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID(系统主键);用途:门店维度分组、计营业额、与门店档案关联。 | store_goods_sales_records - site_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - site_id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID;用途:多租户数据隔离与按租户汇总。 | store_goods_sales_records - tenant_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - tenant_id。 | + +| `site_goods_id` | BIGINT | | dim_store_goods(site_goods_id) | 门店商品 ID;用于跨表关联与去重。 | store_goods_sales_records - site_goods_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - site_goods_id。 | + +| `tenant_goods_id` | BIGINT | | dim_tenant_goods(tenant_goods_id) | 租户(品牌)级商品 ID(全局商品 ID);用于跨表关联与去重。 | store_goods_sales_records - tenant_goods_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - tenant_goods_id。 | + +| `tenant_goods_category_id` | BIGINT | | | 租户级商品一级分类 ID;用于跨表关联与去重。 | store_goods_sales_records - tenant_goods_category_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - tenant_goods_category_id。 | + +| `tenant_goods_business_id` | BIGINT | | | 租户级商品「业务大类」ID(例如“零食类”“酒水类”等更高维度);用于跨表关联与去重。 | store_goods_sales_records - tenant_goods_business_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - tenant_goods_business_id。 | + +| `site_table_id` | BIGINT | | | 球台 ID;用于跨表关联与去重。 | store_goods_sales_records - site_table_id。 | store_goods_sales_records.json - data.orderGoodsLedgers - site_table_id。 | + +| `ledger_name` | VARCHAR(200) | | | 销售项目名称(商品名称),例如 “哇哈哈矿泉水”“地道肠”“东方树叶”等。 | store_goods_sales_records - ledger_name。 | store_goods_sales_records.json - data.orderGoodsLedgers - ledger_name。 | + +| `ledger_group_name` | VARCHAR(100) | | | 销售项目所属的「门店内部分组名称」,类似前台菜单分组或大类标签。 | store_goods_sales_records - ledger_group_name。 | store_goods_sales_records.json - data.orderGoodsLedgers - ledger_group_name。 | + +| `ledger_unit_price` | NUMERIC(18,2) | | | 商品在该次销售中的「结算单价」(元/单位)。 | store_goods_sales_records - ledger_unit_price。 | store_goods_sales_records.json - data.orderGoodsLedgers - ledger_unit_price。 | + +| `ledger_count` | INTEGER | | | 销售数量(以 unit 为单位,unit 字段在门店商品档案中)。 | store_goods_sales_records - ledger_count。 | store_goods_sales_records.json - data.orderGoodsLedgers - ledger_count。 | + +| `ledger_amount` | NUMERIC(18,2) | | | 原始应收金额,公式上接近 ledger_unit_price × ledger_count。 | store_goods_sales_records - ledger_amount。 | store_goods_sales_records.json - data.orderGoodsLedgers - ledger_amount。 | + +| `discount_price` | NUMERIC(18,2) | | | 本条销售明细的「价格优惠金额」,即原价部分被减免掉的金额。 | store_goods_sales_records - discount_money。 | store_goods_sales_records.json - data.orderGoodsLedgers - discount_money。 | + +| `real_goods_money` | NUMERIC(18,2) | | | 商品实际入账金额(考虑折扣、可能还会考虑其它抵扣后的实际销售金额)。 | store_goods_sales_records - real_goods_money。 | store_goods_sales_records.json - data.orderGoodsLedgers - real_goods_money。 | + +| `cost_money` | NUMERIC(18,2) | | | 本条销售对应的成本金额(以元计)。 | store_goods_sales_records - cost_money。 | store_goods_sales_records.json - data.orderGoodsLedgers - cost_money。 | + +| `ledger_status` | INTEGER | | | 销售流水状态。 | store_goods_sales_records - ledger_status。 | store_goods_sales_records.json - data.orderGoodsLedgers - ledger_status。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志;软删除/作废标记,分析通常需过滤为有效记录。 | store_goods_sales_records - is_delete。 | store_goods_sales_records.json - data.orderGoodsLedgers - is_delete。 | + +| `create_time` | TIMESTAMPTZ | | | 销售记录创建时间,通常就是结账时间或录入时间;记录源系统创建时间,用于增量同步和口径对齐。 | store_goods_sales_records - create_time。 | store_goods_sales_records.json - data.orderGoodsLedgers - create_time。 | + + + +## `dwd_table_fee_adjust` + + + +- 表说明:DWD 明细事实表:dwd_table_fee_adjust。ODS 来源表:billiards_ods.table_fee_discount_records(对应 JSON:table_fee_discount_records.json;分析:table_fee_discount_records-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:table_fee_adjust_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `table_fee_adjust_id` | BIGINT | Y | | 台费打折 / 调整流水主键 ID。;用途:在“台费调账表”中唯一标识一条折扣/调账操作;用于跨表关联与去重。 | table_fee_discount_records - id。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - id。 | + +| `order_trade_no` | BIGINT | | | 订单交易号。;关联:与 台费流水.json、助教流水.json、小票详情.json 中的同名字段一致,用于把这一条“台费调整”挂接到某笔订单上。 | table_fee_discount_records - order_trade_no。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - order_trade_no。 | + +| `order_settle_id` | BIGINT | | dwd_settlement_head(order_settle_id) | 结算单/小票 ID。;关联:与“小票详情.json”中的 orderSettleId 对应;用于跨表关联与去重。 | table_fee_discount_records - order_settle_id。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - order_settle_id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID。;用途:标识记录属于哪一个商户(同一个“非球科技”租户);用途:多租户数据隔离与按租户汇总。 | table_fee_discount_records - tenant_id。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID,本批数据全部为同一家门店(朗朗桌球)。;关联:与 siteProfile.id 一致;用途:门店维度分组、计营业额、与门店档案关联。 | table_fee_discount_records - site_id。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - site_id。 | + +| `table_id` | BIGINT | | dim_table(table_id) | 台桌 ID。;关联:与 台费流水.json 中的 site_table_id 一致;用于跨表关联与去重。 | table_fee_discount_records - site_table_id。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - site_table_id。 | + +| `table_area_id` | BIGINT | | | 租户维度的“台桌区域 ID”。;关联:与台桌区域配置表对应,帮助从区域维度分析打折分布(结构上可用);用于跨表关联与去重。 | table_fee_discount_records - tenant_table_area_id。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - tenant_table_area_id。 | + +| `table_area_name` | VARCHAR(64) | | | 名称字段,用于展示、检索与分组。 | table_fee_discount_records - tableprofile.table_area_name。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - tableprofile.table_area_name。 | + +| `tenant_table_area_id` | BIGINT | | | 租户维度的“台桌区域 ID”。;关联:与台桌区域配置表对应,帮助从区域维度分析打折分布(结构上可用);用于跨表关联与去重。 | table_fee_discount_records - tenant_table_area_id。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - tenant_table_area_id。 | + +| `ledger_amount` | NUMERIC(18,2) | | | 通过与 台费流水.json 做对比,可以明确:。 | table_fee_discount_records - ledger_amount。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - ledger_amount。 | + +| `ledger_status` | INTEGER | | | 业务状态/类型字段,用于过滤、分类与统计口径区分。 | table_fee_discount_records - ledger_status。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - ledger_status。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志:;软删除/作废标记,分析通常需过滤为有效记录。 | table_fee_discount_records - is_delete。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - is_delete。 | + +| `adjust_time` | TIMESTAMPTZ | | | 台费调整记录的创建时间,即打折操作被执行的时间戳。 | table_fee_discount_records - create_time。 | table_fee_discount_records.json - data.taiFeeAdjustInfos - create_time。 | + + + +## `dwd_table_fee_log` + + + +- 表说明:DWD 明细事实表:dwd_table_fee_log。ODS 来源表:billiards_ods.table_fee_transactions(对应 JSON:table_fee_transactions.json;分析:table_fee_transactions-Analysis.md)。装载/清洗逻辑参考:etl_billiards/tasks/dwd_load_task.py(DwdLoadTask)。 + +- 主键:table_fee_log_id + +- 类型:事实/明细 + + + +| 字段 | 类型 | 主键 | 关联(推断) | 字段用途(说明) | ODS来源 | JSON字段 | + +|---|---|---:|---|---|---|---| + +| `table_fee_log_id` | BIGINT | Y | | 台费流水记录主键(事实表主键);用于跨表关联与去重。 | table_fee_transactions - id。 | table_fee_transactions.json - data.siteTableUseDetailsList - id。 | + +| `order_trade_no` | BIGINT | | | 订单交易号,是整笔订单的主编号。;关联:与其它 JSON(如 助教流水、小票详情、门店销售记录)中的同名字段一致,用于把 同一订单下的台费、助教、商品等多条明细串联。 | table_fee_transactions - order_trade_no。 | table_fee_transactions.json - data.siteTableUseDetailsList - order_trade_no。 | + +| `order_settle_id` | BIGINT | | dwd_settlement_head(order_settle_id) | 结算单号/结账 ID,对应一次结账操作。;关联:与“小票详情.json”中的 orderSettleId 对应;用于跨表关联与去重。 | table_fee_transactions - order_settle_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - order_settle_id。 | + +| `order_pay_id` | BIGINT | | | 订单支付记录 ID。;关联:对应“支付记录.json”中的 id 或 relate_id(视模型而定),用于追踪这条台费最终对应哪一条支付流水。 | table_fee_transactions - order_pay_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - order_pay_id。 | + +| `tenant_id` | BIGINT | | | 租户/品牌 ID。本文件所有记录都属于同一租户。;关联:与所有其它 JSON 中的 tenant_id 一致,用于跨表做“商户维度”的过滤。 | table_fee_transactions - tenant_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - tenant_id。 | + +| `site_id` | BIGINT | | dim_site(site_id) | 门店 ID,本次数据全部来自同一门店(朗朗桌球)。;关联:与 siteProfile.id 一致;用途:门店维度分组、计营业额、与门店档案关联。 | table_fee_transactions - site_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - site_id。 | + +| `site_table_id` | BIGINT | | | 球台 ID。;关联:对应“台桌列表”中的 id(当前导出文件中有一类与之对应的台桌配置表);用于跨表关联与去重。 | table_fee_transactions - site_table_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - site_table_id。 | + +| `site_table_area_id` | BIGINT | | | 门店内“台桌区域” ID(站在门店物理布局的角度)。;关联:对应“门店台桌区域配置表”的主键;用于跨表关联与去重。 | table_fee_transactions - site_table_area_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - site_table_area_id。 | + +| `site_table_area_name` | VARCHAR(64) | | | 台桌区域的名称,用于门店表现和区域统计。 | table_fee_transactions - site_table_area_name。 | table_fee_transactions.json - data.siteTableUseDetailsList - site_table_area_name。 | + +| `tenant_table_area_id` | BIGINT | | | 租户维度的台桌区域 ID(品牌层面的同一类区域)。;关联:对应租户层面的“区域维表”,支持多门店共享同一套区域配置;用于跨表关联与去重。 | table_fee_transactions - tenant_table_area_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - tenant_table_area_id。 | + +| `member_id` | BIGINT | | dim_member(member_id) | 门店/租户内的会员 ID。;关联:与“会员档案.json(tenantMemberInfos)”内的 id 对应(有部分 ID 完全匹配,部分会员可能不在当前导出页);用于跨表关联与去重。 | table_fee_transactions - member_id。 | table_fee_transactions.json - data.siteTableUseDetailsList - member_id。 | + +| `ledger_name` | VARCHAR(64) | | | 台号名称,实际展示给员工/顾客看的桌台编号。 | table_fee_transactions - ledger_name。 | table_fee_transactions.json - data.siteTableUseDetailsList - ledger_name。 | + +| `ledger_unit_price` | NUMERIC(18,2) | | | 台费结算时设置的 每小时单价/计费单价。 | table_fee_transactions - ledger_unit_price。 | table_fee_transactions.json - data.siteTableUseDetailsList - ledger_unit_price。 | + +| `ledger_count` | INTEGER | | | 台账记录的计费秒数,计费用秒数(应收时长)。 | table_fee_transactions - ledger_count。 | table_fee_transactions.json - data.siteTableUseDetailsList - ledger_count。 | + +| `ledger_amount` | NUMERIC(18,2) | | | 按单价与计费时长计算出的原始应收台费金额。 | table_fee_transactions - ledger_amount。 | table_fee_transactions.json - data.siteTableUseDetailsList - ledger_amount。 | + +| `real_table_charge_money` | NUMERIC(18,2) | | | 台费中实际向顾客收取的金额(现金/实付维度,未含券方承担或内部调账的那一部分)。 | table_fee_transactions - real_table_charge_money。 | table_fee_transactions.json - data.siteTableUseDetailsList - real_table_charge_money。 | + +| `coupon_promotion_amount` | NUMERIC(18,2) | | | 由优惠券/活动/团购(平台/门店促销)承担的优惠金额,直接抵扣在台费上。 | table_fee_transactions - coupon_promotion_amount。 | table_fee_transactions.json - data.siteTableUseDetailsList - coupon_promotion_amount。 | + +| `member_discount_amount` | NUMERIC(18,2) | | | 由会员权益产生的优惠金额,例如会员折扣、会员价等。 | table_fee_transactions - member_discount_amount。 | table_fee_transactions.json - data.siteTableUseDetailsList - member_discount_amount。 | + +| `adjust_amount` | NUMERIC(18,2) | | | 调整金额/调账金额,用于将台费金额转移或冲减到其它项目,或手工调整。 | table_fee_transactions - adjust_amount。 | table_fee_transactions.json - data.siteTableUseDetailsList - adjust_amount。 | + +| `real_table_use_seconds` | INTEGER | | | 实际使用的总秒数(系统真实统计的使用时长)。 | table_fee_transactions - real_table_use_seconds。 | table_fee_transactions.json - data.siteTableUseDetailsList - real_table_use_seconds。 | + +| `add_clock_seconds` | INTEGER | | | 加钟秒数,在原有使用基础上追加的时长。 | table_fee_transactions - add_clock_seconds。 | table_fee_transactions.json - data.siteTableUseDetailsList - add_clock_seconds。 | + +| `start_use_time` | TIMESTAMPTZ | | | 台开始使用的时间(实际开台时间)。 | table_fee_transactions - start_use_time。 | table_fee_transactions.json - data.siteTableUseDetailsList - start_use_time。 | + +| `ledger_end_time` | TIMESTAMPTZ | | | 台账上的计费结束时间。 | table_fee_transactions - ledger_end_time。 | table_fee_transactions.json - data.siteTableUseDetailsList - ledger_end_time。 | + +| `create_time` | TIMESTAMPTZ | | | 这条台费流水记录的创建时间,通常接近结账时间;记录源系统创建时间,用于增量同步和口径对齐。 | table_fee_transactions - create_time。 | table_fee_transactions.json - data.siteTableUseDetailsList - create_time。 | + +| `ledger_status` | INTEGER | | | 1:正常已结算台费。 | table_fee_transactions - ledger_status。 | table_fee_transactions.json - data.siteTableUseDetailsList - ledger_status。 | + +| `is_single_order` | INTEGER | | | 1:该台费记录对应的是一个独立计费单元(单独结算的桌费)。 | table_fee_transactions - is_single_order。 | table_fee_transactions.json - data.siteTableUseDetailsList - is_single_order。 | + +| `is_delete` | INTEGER | | | 逻辑删除标志:;软删除/作废标记,分析通常需过滤为有效记录。 | table_fee_transactions - is_delete。 | table_fee_transactions.json - data.siteTableUseDetailsList - is_delete。 | + + + +--- + +生成时间:2025-12-15 19:35:55 + diff --git a/etl_billiards/docs/table_2025-12-19/_generate_assistant_tables.py b/etl_billiards/docs/table_2025-12-19/_generate_assistant_tables.py new file mode 100644 index 0000000..93301b6 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/_generate_assistant_tables.py @@ -0,0 +1,585 @@ +# -*- coding: utf-8 -*- +"""生成 2025年10-12月 助教排行榜 + 助教详情表(CSV + MD)。 + +输出目录:etl_billiards/docs/table_2025-12-19 + +注意:客户流水/充值归因涉及“多助教/多订单命中”时按全额复制计入,会导致助教汇总>门店汇总,表格说明会写明。 +""" + +from __future__ import annotations + +import csv +import re +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from statistics import median +from typing import Any + +import psycopg2 +import psycopg2.extras + + +SITE_ID = 2790685415443269 +TZ = "Asia/Shanghai" + +WIN_OCT = ("2025-10-01 00:00:00+08", "2025-11-01 00:00:00+08") +WIN_NOV = ("2025-11-01 00:00:00+08", "2025-12-01 00:00:00+08") +WIN_DEC = ("2025-12-01 00:00:00+08", "2026-01-01 00:00:00+08") +WIN_ALL = (WIN_OCT[0], WIN_DEC[1]) + +MONTHS = [ + ("2025-10", "10月", WIN_OCT), + ("2025-11", "11月", WIN_NOV), + ("2025-12", "12月", WIN_DEC), +] + +REPO_ROOT = Path(__file__).resolve().parents[3] +ENV_PATH = REPO_ROOT / "etl_billiards" / ".env" +OUT_DIR = Path(__file__).resolve().parent + + +@dataclass(frozen=True) +class SqlBlock: + title: str + sql: str + + +def read_pg_dsn() -> str: + text = ENV_PATH.read_text(encoding="utf-8") + m = re.search(r"^PG_DSN=(.+)$", text, re.M) + if not m: + raise RuntimeError(f"未在 {ENV_PATH} 中找到 PG_DSN") + return m.group(1).strip() + + +def conn(): + return psycopg2.connect(read_pg_dsn(), connect_timeout=10) + + +def sanitize_filename(name: str) -> str: + name = name.strip() + name = re.sub(r"[<>:\"/\\|?*]+", "_", name) + name = re.sub(r"\s+", " ", name) + return name + + +def d(v: Any) -> Decimal: + if v is None: + return Decimal("0") + if isinstance(v, Decimal): + return v + return Decimal(str(v)) + + +def fmt_money(v: Any) -> str: + return f"{d(v):.2f}" + + +def fmt_hours(v: Any, digits: int = 2) -> str: + q = Decimal("1").scaleb(-digits) + return f"{d(v).quantize(q):f}h" + + +def write_csv(path: Path, title: str, description: str, header_rows: list[list[str]], rows: list[list[Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow([title]) + w.writerow([description]) + w.writerow([]) + for hr in header_rows: + w.writerow(hr) + for r in rows: + w.writerow(["" if v is None else v for v in r]) + + +def write_csv_sections(path: Path, title: str, description: str, section_rows: list[list[Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow([title]) + w.writerow([description]) + w.writerow([]) + for r in section_rows: + w.writerow(["" if v is None else v for v in r]) + + +def write_md(path: Path, title: str, thinking: str, description: str, sql_blocks: list[SqlBlock]) -> None: + parts: list[str] = [] + parts.append(f"# {title}\n") + parts.append("## 思考过程\n") + parts.append(thinking.strip() + "\n") + parts.append("\n## 查询说明\n") + parts.append(description.strip() + "\n") + parts.append("\n## SQL\n") + for b in sql_blocks: + parts.append(f"\n### {b.title}\n") + parts.append("```sql\n") + parts.append(b.sql.strip() + "\n") + parts.append("```\n") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(parts), encoding="utf-8") + + +def fetch_all(cur, sql: str, params: dict[str, Any]) -> list[dict[str, Any]]: + cur.execute(sql, params) + return list(cur.fetchall()) + + +def month_case(ts_expr: str) -> str: + parts = [] + for month_key, _, (ws, we) in MONTHS: + parts.append( + f"when {ts_expr} >= '{ws}'::timestamptz and {ts_expr} < '{we}'::timestamptz then '{month_key}'" + ) + return "case " + " ".join(parts) + " else null end" + + +def sql_order_base(window_start: str, window_end: str) -> str: + return f""" +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '{window_start}'::timestamptz + and tfl.start_use_time < '{window_end}'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) +""" + + +def dense_rank_desc(values: dict[str, Decimal]) -> dict[str, int]: + uniq = sorted({v for v in values.values() if v > 0}, reverse=True) + rank_map = {v: i + 1 for i, v in enumerate(uniq)} + return {k: rank_map.get(v, 0) for k, v in values.items()} + + +def calc_diff(all_values: dict[str, Decimal], current: Decimal) -> tuple[Decimal, Decimal]: + xs = [v for v in all_values.values() if v > 0] + if not xs or current <= 0: + return Decimal("0"), Decimal("0") + avg = sum(xs) / Decimal(len(xs)) + med = Decimal(str(median([float(v) for v in xs]))) + return current - avg, current - med + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + + with conn() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + assistants_rows = fetch_all( + cur, + """ +select distinct nickname as assistant +from billiards_dwd.dwd_assistant_service_log +where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +order by assistant; +""", + {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]}, + ) + assistants = [r["assistant"] for r in assistants_rows if r.get("assistant")] + + # 助教-客户-月份:服务时长 + sql_svc = f""" +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + {month_case('asl.start_use_time')} as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +""" + svc_rows = fetch_all(cur, sql_svc, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]}) + + # 助教-客户-月份:客户流水 + sql_rev = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + {month_case('o.order_start_time')} as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +""" + rev_rows = fetch_all(cur, sql_rev, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]}) + + # 助教-客户-月份:充值归因 + sql_rech = f""" +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + {month_case('m.pay_time')} as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +""" + rech_rows = fetch_all(cur, sql_rech, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]}) + + # 汇总:月度助教指标 + svc_map = {mk: {a: {"base": Decimal('0'), "extra": Decimal('0')} for a in assistants} for mk,_,_ in MONTHS} + for r in svc_rows: + mk = r["month_key"]; a = r["assistant"] + if mk in svc_map and a in svc_map[mk]: + svc_map[mk][a]["base"] += d(r["base_hours"]) + svc_map[mk][a]["extra"] += d(r["extra_hours"]) + + revenue_map = {mk: {a: Decimal('0') for a in assistants} for mk,_,_ in MONTHS} + for r in rev_rows: + mk = r["month_key"]; a = r["assistant"] + if mk in revenue_map and a in revenue_map[mk]: + revenue_map[mk][a] += d(r["revenue_amount"]) + + recharge_map = {mk: {a: Decimal('0') for a in assistants} for mk,_,_ in MONTHS} + for r in rech_rows: + mk = r["month_key"]; a = r["assistant"] + if mk in recharge_map and a in recharge_map[mk]: + recharge_map[mk][a] += d(r["recharge_amount"]) + + # ====== 输出4张排行榜 ====== + def write_rank(file_stem: str, title: str, desc: str, rows: list[list[Any]]): + write_csv(OUT_DIR / f"{file_stem}.csv", title, desc, [["月份", "排名", "助教昵称", "指标"]], rows) + write_md(OUT_DIR / f"{file_stem}.md", title, "按月聚合并做dense_rank排名。", desc, []) + + rows = [] + for mk,_,_ in MONTHS: + values = {a: svc_map[mk][a]["base"] for a in assistants} + ranks = dense_rank_desc(values) + for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)): + v = values[a] + if v > 0: + rows.append([mk, ranks[a], a, fmt_hours(v, 2)]) + write_rank( + "助教_基础课时长排行_2025年10-12月", + "2025年10-12月 助教基础课时长排行榜", + "口径:order_assistant_type=1,时长=income_seconds/3600(小时),按月排名。", + rows, + ) + + rows = [] + for mk,_,_ in MONTHS: + values = {a: svc_map[mk][a]["extra"] for a in assistants} + ranks = dense_rank_desc(values) + for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)): + v = values[a] + if v > 0: + rows.append([mk, ranks[a], a, fmt_hours(v, 2)]) + write_rank( + "助教_附加课时长排行_2025年10-12月", + "2025年10-12月 助教附加课(超休)时长排行榜", + "口径:order_assistant_type=2,超休时长=income_seconds/3600(小时),按月排名。", + rows, + ) + + rows = [] + for mk,_,_ in MONTHS: + values = revenue_map[mk] + ranks = dense_rank_desc(values) + for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)): + v = values[a] + if v > 0: + rows.append([mk, ranks[a], a, fmt_money(v)]) + write_rank( + "助教_客户流水排行_2025年10-12月", + "2025年10-12月 助教客户流水排行榜(全额复制口径)", + "口径:客户流水=台费+助教+商品应付金额按订单归集后,全额计入订单内每位助教;多助教会导致汇总>门店总额。", + rows, + ) + + rows = [] + for mk,_,_ in MONTHS: + values = recharge_map[mk] + ranks = dense_rank_desc(values) + for a in sorted(assistants, key=lambda x: (ranks[x] if ranks[x] else 999999, x)): + v = values[a] + if v > 0: + rows.append([mk, ranks[a], a, fmt_money(v)]) + write_rank( + "助教_客户充值归因排行_2025年10-12月", + "2025年10-12月 助教客户充值归因排行榜(全额复制口径)", + "口径:充值支付(dwd_payment.relate_type=5)在消费窗口±30分钟内命中且订单有助教,则全额计入助教;多助教/多订单命中会重复计入。", + rows, + ) + + # ====== 输出助教详情(每人一份) ====== + # 会员昵称 + cur.execute("select member_id, nickname from billiards_dwd.dim_member where scd2_is_current=1") + member_name = {r["member_id"]: (r.get("nickname") or "") for r in cur.fetchall()} + + # 索引:assistant->member->month + svc_idx = {a: {} for a in assistants} + for r in svc_rows: + a = r["assistant"]; mid = int(r["member_id"]); mk = r["month_key"] + svc_idx.setdefault(a, {}).setdefault(mid, {})[mk] = {"base": d(r["base_hours"]), "extra": d(r["extra_hours"])} + + rev_idx = {a: {} for a in assistants} + for r in rev_rows: + a = r["assistant"]; mid = int(r["member_id"]); mk = r["month_key"] + rev_idx.setdefault(a, {}).setdefault(mid, {})[mk] = d(r["revenue_amount"]) + + rech_idx = {a: {} for a in assistants} + for r in rech_rows: + a = r["assistant"]; mid = int(r["member_id"]); mk = r["month_key"] + rech_idx.setdefault(a, {}).setdefault(mid, {})[mk] = d(r["recharge_amount"]) + + for a in assistants: + safe = sanitize_filename(a) + csv_path = OUT_DIR / f"助教详情_{safe}.csv" + md_path = OUT_DIR / f"助教详情_{safe}.md" + + # 评价(简短) + base_total = sum((svc_map[mk][a]["base"] for mk,_,_ in MONTHS), Decimal('0')) + extra_total = sum((svc_map[mk][a]["extra"] for mk,_,_ in MONTHS), Decimal('0')) + rev_total = sum((revenue_map[mk][a] for mk,_,_ in MONTHS), Decimal('0')) + rech_total = sum((recharge_map[mk][a] for mk,_,_ in MONTHS), Decimal('0')) + + # 头部客户 Top100(按12月消费业绩) + members = set(rev_idx.get(a, {}).keys()) | set(svc_idx.get(a, {}).keys()) | set(rech_idx.get(a, {}).keys()) + def rev_dec(mid: int) -> Decimal: + return rev_idx.get(a, {}).get(mid, {}).get('2025-12', Decimal('0')) + top_members = sorted(members, key=lambda mid: rev_dec(mid), reverse=True)[:100] + + top3 = '、'.join([(member_name.get(mid) or str(mid)) for mid in top_members[:3]]) + assistant_review = ( + f"评价:基础{fmt_hours(base_total,1)},附加{fmt_hours(extra_total,1)};" + f"客户流水¥{rev_total:.2f},充值归因¥{rech_total:.2f};" + f"头部客户(12月)Top3:{top3 or '无'}。" + ) + + # Part1-4 + part1=[]; part2=[]; part3=[]; part4=[] + for mk, mcn, _ in MONTHS: + base_v = svc_map[mk][a]["base"] + extra_v = svc_map[mk][a]["extra"] + rev_v = revenue_map[mk][a] + rech_v = recharge_map[mk][a] + + base_all = {x: svc_map[mk][x]["base"] for x in assistants} + extra_all = {x: svc_map[mk][x]["extra"] for x in assistants} + rev_all = {x: revenue_map[mk][x] for x in assistants} + rech_all = {x: recharge_map[mk][x] for x in assistants} + + base_rank = dense_rank_desc(base_all).get(a, 0) + extra_rank = dense_rank_desc(extra_all).get(a, 0) + rev_rank = dense_rank_desc(rev_all).get(a, 0) + rech_rank = dense_rank_desc(rech_all).get(a, 0) + + base_da, base_dm = calc_diff(base_all, base_v) + extra_da, extra_dm = calc_diff(extra_all, extra_v) + rev_da, rev_dm = calc_diff(rev_all, rev_v) + rech_da, rech_dm = calc_diff(rech_all, rech_v) + + part1.append([mcn, fmt_hours(base_v,2), base_rank or "", fmt_hours(base_da,2), fmt_hours(base_dm,2)]) + part2.append([mcn, fmt_hours(extra_v,2), extra_rank or "", fmt_hours(extra_da,2), fmt_hours(extra_dm,2)]) + part3.append([mcn, fmt_money(rev_v), rev_rank or "", fmt_money(rev_da), fmt_money(rev_dm)]) + part4.append([mcn, fmt_money(rech_v), rech_rank or "", fmt_money(rech_da), fmt_money(rech_dm)]) + + # Part5 rows + part5=[] + for i, mid in enumerate(top_members, start=1): + def h_pair(month_key: str) -> str: + v = svc_idx.get(a, {}).get(mid, {}).get(month_key, {}) + return f"{fmt_hours(v.get('base',Decimal('0')),1)} / {fmt_hours(v.get('extra',Decimal('0')),1)}" + def rev_m(month_key: str) -> Decimal: + return rev_idx.get(a, {}).get(mid, {}).get(month_key, Decimal('0')) + def rech_m(month_key: str) -> Decimal: + return rech_idx.get(a, {}).get(mid, {}).get(month_key, Decimal('0')) + name = member_name.get(mid) or str(mid) + part5.append([ + i, + name, + h_pair('2025-12'), fmt_money(rev_m('2025-12')), fmt_money(rech_m('2025-12')), + h_pair('2025-11'), fmt_money(rev_m('2025-11')), fmt_money(rech_m('2025-11')), + h_pair('2025-10'), fmt_money(rev_m('2025-10')), fmt_money(rech_m('2025-10')), + ]) + + title = f"助教详情:{a}(2025年10-12月)" + desc = ( + "本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。" + "均值/中位数差值对比集合为当月该指标>0的助教。" + "充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。" + ) + + rows=[] + rows += [["一、基础课业绩"], ["说明:" + assistant_review], []] + rows += [["月份", "基础课业绩", "基础课业绩", "基础课业绩", "基础课业绩"], ["月份", "小时数", "排名", "平均值差值小时数", "中位数值差值小时数"]] + rows += part1 + rows += [[], ["二、附加课业绩"], ["说明:附加课=order_assistant_type=2。"], []] + rows += [["月份", "附加课业绩", "附加课业绩", "附加课业绩", "附加课业绩"], ["月份", "小时数", "排名", "平均值差值小时数", "中位数值差值小时数"]] + rows += part2 + rows += [[], ["三、客户消费业绩"], ["说明:订单台费+助教+商品应付金额全额计入订单内助教。"], []] + rows += [["月份", "客户消费业绩", "客户消费业绩", "客户消费业绩", "客户消费业绩"], ["月份", "合计元", "排名", "平均值差值元", "中位数值差值元"]] + rows += part3 + rows += [[], ["四、客户充值业绩"], ["说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。"], []] + rows += [["月份", "客户充值业绩", "客户充值业绩", "客户充值业绩", "客户充值业绩"], ["月份", "合计元", "排名", "平均值差值元", "中位数值差值元"]] + rows += part4 + rows += [[], ["五、头部客户(按12月消费业绩排序,Top100)"], ["说明:基础/附加课时=基础h/附加h。"], []] + rows += [["排名", "客户名称", "12月", "12月", "12月", "11月", "11月", "11月", "10月", "10月", "10月"], + ["排名", "客户名称", "基础/附加课时", "消费业绩(元)", "客户充值(元)", "基础/附加课时", "消费业绩(元)", "客户充值(元)", "基础/附加课时", "消费业绩(元)", "客户充值(元)"]] + rows += part5 + + write_csv_sections(csv_path, title, desc, rows) + write_md( + md_path, + title, + "按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。", + desc + "\n" + assistant_review, + [ + SqlBlock("服务时长(助教-客户-月份)", sql_svc), + SqlBlock("客户流水(助教-客户-月份)", sql_rev), + SqlBlock("充值归因(助教-客户-月份)", sql_rech), + ], + ) + + print(f"完成:{OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/etl_billiards/docs/table_2025-12-19/_generate_tables.py b/etl_billiards/docs/table_2025-12-19/_generate_tables.py new file mode 100644 index 0000000..5bcbf63 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/_generate_tables.py @@ -0,0 +1,1087 @@ +# -*- coding: utf-8 -*- +"""生成 2025年10-12月 报表(CSV + MD)。 + +输出目录:etl_billiards/docs/table_2025-12-19 + +重要口径(已按需求固化): +- 门店:site_id=2790685415443269 +- 月份切割:北京时间(+08)当月1日 00:00:00 +- 消费/流水:应付金额(不扣优惠),= 台费(dwd_table_fee_log.ledger_amount) + 助教(dwd_assistant_service_log.ledger_amount) + 商品(dwd_store_goods_sale.ledger_amount) +- 助教时长:income_seconds(计费秒数)换算小时 +- 优惠:台费调账 dwd_table_fee_adjust.ledger_amount + 会员折扣 dwd_table_fee_log.member_discount_amount +- 多助教/充值归因:全额复制计入(会导致汇总>门店总额),在表格说明中提示 +""" + +from __future__ import annotations + +import csv +import re +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from typing import Any + +import psycopg2 +import psycopg2.extras + + +SITE_ID = 2790685415443269 +TZ = "Asia/Shanghai" + +WIN_OCT = ("2025-10-01 00:00:00+08", "2025-11-01 00:00:00+08") +WIN_NOV = ("2025-11-01 00:00:00+08", "2025-12-01 00:00:00+08") +WIN_DEC = ("2025-12-01 00:00:00+08", "2026-01-01 00:00:00+08") +WIN_ALL = (WIN_OCT[0], WIN_DEC[1]) + +MONTHS = [ + ("2025-10", "10月", WIN_OCT), + ("2025-11", "11月", WIN_NOV), + ("2025-12", "12月", WIN_DEC), +] + +REPO_ROOT = Path(__file__).resolve().parents[3] +ENV_PATH = REPO_ROOT / "etl_billiards" / ".env" +OUT_DIR = Path(__file__).resolve().parent + + +@dataclass(frozen=True) +class SqlBlock: + title: str + sql: str + + +def read_pg_dsn() -> str: + text = ENV_PATH.read_text(encoding="utf-8") + m = re.search(r"^PG_DSN=(.+)$", text, re.M) + if not m: + raise RuntimeError(f"未在 {ENV_PATH} 中找到 PG_DSN") + return m.group(1).strip() + + +def conn(): + return psycopg2.connect(read_pg_dsn(), connect_timeout=10) + + +def sanitize_filename(name: str) -> str: + name = name.strip() + name = re.sub(r"[<>:\"/\\|?*]+", "_", name) + name = re.sub(r"\s+", " ", name) + return name + + +def mask_phone(phone: str | None) -> str: + if not phone: + return "" + digits = re.sub(r"\D", "", phone) + if len(digits) < 7: + return phone + return re.sub(r"^(\d{3})\d{4}(\d+)$", r"\1****\2", digits) + + +def d(v: Any) -> Decimal: + if v is None: + return Decimal("0") + if isinstance(v, Decimal): + return v + return Decimal(str(v)) + + +def fmt_money(v: Any) -> str: + return f"{d(v):.2f}" + + +def fmt_hours(v: Any, digits: int = 1) -> str: + q = Decimal("1").scaleb(-digits) + return f"{d(v).quantize(q):f}h" + + +def write_csv(path: Path, title: str, description: str, header_rows: list[list[str]], rows: list[list[Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow([title]) + w.writerow([description]) + w.writerow([]) # 说明与表头之间空 1 行 + for hr in header_rows: + w.writerow(hr) + for r in rows: + w.writerow(["" if v is None else v for v in r]) + + +def write_md(path: Path, title: str, thinking: str, description: str, sql_blocks: list[SqlBlock]) -> None: + parts: list[str] = [] + parts.append(f"# {title}\n") + parts.append("## 思考过程\n") + parts.append(thinking.strip() + "\n") + parts.append("\n## 查询说明\n") + parts.append(description.strip() + "\n") + parts.append("\n## SQL\n") + for b in sql_blocks: + parts.append(f"\n### {b.title}\n") + parts.append("```sql\n") + parts.append(b.sql.strip() + "\n") + parts.append("```\n") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(parts), encoding="utf-8") + + +def fetch_all(cur, sql: str, params: dict[str, Any]) -> list[dict[str, Any]]: + cur.execute(sql, params) + return list(cur.fetchall()) + + +def month_case(ts_expr: str) -> str: + parts = [] + for month_key, _, (ws, we) in MONTHS: + parts.append( + f"when {ts_expr} >= '{ws}'::timestamptz and {ts_expr} < '{we}'::timestamptz then '{month_key}'" + ) + return "case " + " ".join(parts) + " else null end" + + +def sql_order_base(window_start: str, window_end: str) -> str: + return f""" +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '{window_start}'::timestamptz + and tfl.start_use_time < '{window_end}'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) +""" + + +def build_finance_discount(cur) -> None: + title = "2025年10-12月 财务优惠(会员折扣+台费调账)分布" + desc = ( + "优惠=会员折扣(dwd_table_fee_log.member_discount_amount)+台费调账(dwd_table_fee_adjust.ledger_amount)," + "按订单归集后汇总到客户(member_id),按订单最早开台时间切月;不含团购抵扣等其它优惠。" + ) + thinking = "用台费订单为基准关联调账表,再按客户+月份汇总,输出“谁享受了优惠”及金额分布。" + + sql = f""" +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + sum(tfl.member_discount_amount) as member_discount_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +adjusts as ( + select + tfa.order_settle_id, + sum(tfa.ledger_amount) as adjust_amount + from billiards_dwd.dwd_table_fee_adjust tfa + join base_orders bo on bo.order_settle_id = tfa.order_settle_id + where tfa.site_id = %(site_id)s + and coalesce(tfa.is_delete,0) = 0 + group by tfa.order_settle_id +) +, x as ( + select + bo.member_id, + {month_case('bo.order_start_time')} as month_key, + coalesce(bo.member_discount_amount,0) as member_discount_amount, + coalesce(a.adjust_amount,0) as adjust_amount + from base_orders bo + left join adjusts a on a.order_settle_id = bo.order_settle_id +) +select + member_id, + month_key, + sum(member_discount_amount) as member_discount_sum, + sum(adjust_amount) as adjust_sum +from x +where month_key is not null +group by member_id, month_key; +""" + + rows = fetch_all( + cur, + sql, + {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]}, + ) + + member_ids = sorted({r["member_id"] for r in rows if r["member_id"] not in (None, 0)}) + cur.execute( + """ +select member_id, nickname, mobile, member_card_grade_name +from billiards_dwd.dim_member +where scd2_is_current=1 and member_id = any(%(ids)s) +""", + {"ids": member_ids}, + ) + member_map = {r["member_id"]: {"name": (r.get("nickname") or ""), "mobile": (r.get("mobile") or ""), "vip": (r.get("member_card_grade_name") or "")} for r in cur.fetchall()} + + per_member: dict[int, dict[str, Decimal]] = {} + for r in rows: + mid = r["member_id"] + if not mid or mid == 0: + continue + per_member.setdefault(mid, {}) + mk = r["month_key"] + per_member[mid][f"{mk}_member"] = d(r["member_discount_sum"]) + per_member[mid][f"{mk}_adjust"] = d(r["adjust_sum"]) + + out_rows: list[list[Any]] = [] + for mid, info_d in per_member.items(): + info = member_map.get(mid, {"name": "", "mobile": "", "vip": ""}) + row: list[Any] = [info["name"], mask_phone(info["mobile"]), info["vip"]] + total = Decimal("0") + for mk, _, _ in MONTHS: + md = info_d.get(f"{mk}_member", Decimal("0")) + ad = info_d.get(f"{mk}_adjust", Decimal("0")) + row += [fmt_money(md), fmt_money(ad), fmt_money(md + ad)] + total += md + ad + row.append(fmt_money(total)) + out_rows.append(row) + + out_rows.sort(key=lambda r: Decimal(r[-1]), reverse=True) + + csv_path = OUT_DIR / "财务_优惠分布_2025年10-12月.csv" + md_path = OUT_DIR / "财务_优惠分布_2025年10-12月.md" + + write_csv( + csv_path, + title, + desc, + [ + [ + "客户名称", + "手机号(脱敏)", + "VIP卡/等级", + "10月", + "10月", + "10月", + "11月", + "11月", + "11月", + "12月", + "12月", + "12月", + "10-12月", + ], + [ + "客户名称", + "手机号(脱敏)", + "VIP卡/等级", + "会员折扣(元)", + "台费调账(元)", + "合计优惠(元)", + "会员折扣(元)", + "台费调账(元)", + "合计优惠(元)", + "会员折扣(元)", + "台费调账(元)", + "合计优惠(元)", + "合计优惠(元)", + ], + ], + out_rows, + ) + + write_md(md_path, title, thinking, desc, [SqlBlock("优惠分布(客户+月份)", sql)]) + + +def build_customer_top100(cur) -> list[int]: + title_total = "2025年10-12月 客户消费能力Top100(总表)" + title_split = "2025年10-12月 客户消费能力Top100(分表)" + + desc_total = ( + "消费=台费(dwd_table_fee_log.ledger_amount)+助教(dwd_assistant_service_log.ledger_amount)+商品(dwd_store_goods_sale.ledger_amount),均为应付金额(不扣优惠),以台费订单为基准串联;" + "充值=充值支付流水(dwd_payment.relate_type=5, pay_status=2, pay_amount>0)按支付时间切月;" + "储值卡未使用金额=当前有效储值卡余额之和(dim_member_card_account.balance, member_card_type_name='储值卡');" + "喜爱助教=基础课时长+附加课时长*1.5(income_seconds换算小时),总表按10-12月汇总Top5。" + ) + desc_split = "与总表同口径;分表仅计算12月喜爱助教Top5,并展示10-12月各月消费/充值。" + + thinking = ( + "以台费订单为基准汇总三类明细,满足应付金额口径,并输出Top100客户的消费/充值/助教偏好。" + "按你的要求,先生成评价为空的版本,再在脚本末尾回填评价。" + ) + + sql_top100 = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + """ +select + o.member_id, + sum(o.order_amount) as consume_total, + count(*) as order_cnt +from orders o +where o.member_id is not null and o.member_id <> 0 +group by o.member_id +order by consume_total desc +limit 100; +""" + + sql_monthly_consume = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +, x as ( + select + o.member_id, + {month_case('o.order_start_time')} as month_key, + o.order_amount + from orders o + where o.member_id is not null and o.member_id <> 0 +) +select + member_id, + month_key, + sum(order_amount) as consume_sum +from x +where month_key is not null +group by member_id, month_key; +""" + + sql_monthly_recharge = f""" +with pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id = p.relate_id + where p.site_id = %(site_id)s + and p.relate_type = 5 + and p.pay_status = 2 + and p.pay_amount > 0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +) +, x as ( + select + member_id, + {month_case('pay_time')} as month_key, + pay_amount + from pay +) +select + member_id, + month_key, + sum(pay_amount) as recharge_sum +from x +where month_key is not null +group by member_id, month_key; +""" + + sql_fav_all = f""" +with x as ( + select + asl.tenant_member_id as member_id, + asl.nickname as assistant_nickname, + sum(case when asl.order_assistant_type=1 then asl.income_seconds else asl.income_seconds*1.5 end) / 3600.0 as weighted_hours + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0)=0 + and asl.tenant_member_id is not null and asl.tenant_member_id <> 0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + group by asl.tenant_member_id, asl.nickname +), +ranked as ( + select *, row_number() over(partition by member_id order by weighted_hours desc) as rn + from x +) +select + member_id, + string_agg(assistant_nickname || '(' || to_char(round(weighted_hours::numeric, 1), 'FM999999990.0') || 'h)', '、' order by weighted_hours desc) as fav5 +from ranked +where rn <= 5 +group by member_id; +""" + + sql_stored_value = """ +select + tenant_member_id as member_id, + sum(balance) as stored_value_balance +from billiards_dwd.dim_member_card_account +where scd2_is_current=1 + and coalesce(is_delete,0)=0 + and member_card_type_name='储值卡' + and tenant_member_id = any(%(ids)s) +group by tenant_member_id; +""" + + # 评价字段:画像查询(仅用于生成评价文本,不影响Top100口径) + sql_review_profile = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +select + o.member_id, + count(*) as orders, + avg(o.order_amount) as avg_order, + sum(o.table_use_seconds)/3600.0 as play_hours, + avg(o.table_use_seconds)/3600.0 as avg_play_hours, + min((o.order_start_time at time zone '{TZ}')::date) as first_visit_day, + max((o.order_start_time at time zone '{TZ}')::date) as last_visit_day, + count(distinct (o.order_start_time at time zone '{TZ}')::date) as visit_days, + sum(case when o.order_start_time >= '{WIN_OCT[0]}'::timestamptz and o.order_start_time < '{WIN_OCT[1]}'::timestamptz then 1 else 0 end) as orders_oct, + sum(case when o.order_start_time >= '{WIN_NOV[0]}'::timestamptz and o.order_start_time < '{WIN_NOV[1]}'::timestamptz then 1 else 0 end) as orders_nov, + sum(case when o.order_start_time >= '{WIN_DEC[0]}'::timestamptz and o.order_start_time < '{WIN_DEC[1]}'::timestamptz then 1 else 0 end) as orders_dec +from orders o +where o.member_id = any(%(ids)s) +group by o.member_id; +""" + + sql_review_time = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +, t as ( + select + o.member_id, + extract(hour from (o.order_start_time at time zone '{TZ}')) + + extract(minute from (o.order_start_time at time zone '{TZ}'))/60.0 + + extract(second from (o.order_start_time at time zone '{TZ}'))/3600.0 as arrive_h, + extract(hour from (o.order_end_time at time zone '{TZ}')) + + extract(minute from (o.order_end_time at time zone '{TZ}'))/60.0 + + extract(second from (o.order_end_time at time zone '{TZ}'))/3600.0 as leave_h_raw + from orders o + where o.member_id = any(%(ids)s) +), +tt as ( + select + member_id, + arrive_h, + case when leave_h_raw < arrive_h then leave_h_raw + 24 else leave_h_raw end as leave_h + from t +) +select + member_id, + avg(arrive_h) as arrive_avg_h, + percentile_cont(0.5) within group (order by arrive_h) as arrive_med_h, + avg(leave_h) as leave_avg_h, + percentile_cont(0.5) within group (order by leave_h) as leave_med_h +from tt +group by member_id; +""" + + sql_review_pref = """ +select + tfl.member_id, + coalesce(tfl.site_table_area_name,'') as site_table_area_name, + sum(tfl.real_table_use_seconds)/3600.0 as hours +from billiards_dwd.dwd_table_fee_log tfl +where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz and tfl.start_use_time < %(window_end)s::timestamptz + and tfl.member_id = any(%(ids)s) +group by tfl.member_id, site_table_area_name; +""" + + sql_review_goods = """ +with base_orders as ( + select order_settle_id, max(member_id) as member_id + from billiards_dwd.dwd_table_fee_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz and start_use_time < %(window_end)s::timestamptz + group by order_settle_id +) +select + bo.member_id, + g.ledger_name, + sum(g.ledger_count) as qty, + sum(g.ledger_amount) as amount +from base_orders bo +join billiards_dwd.dwd_store_goods_sale g on g.order_settle_id = bo.order_settle_id +where g.site_id=%(site_id)s and coalesce(g.is_delete,0)=0 + and bo.member_id = any(%(ids)s) +group by bo.member_id, g.ledger_name; +""" + + sql_review_visits = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +select + o.member_id, + (o.order_start_time at time zone '{TZ}')::date as visit_date, + count(*) as orders, + sum(o.order_amount) as amount +from orders o +where o.member_id = any(%(ids)s) +group by o.member_id, visit_date +order by o.member_id, visit_date; +""" + + sql_fav_dec = sql_fav_all.replace("%(window_start)s", "'2025-12-01 00:00:00+08'").replace( + "%(window_end)s", "'2026-01-01 00:00:00+08'" + ) + + top100 = fetch_all(cur, sql_top100, {"site_id": SITE_ID}) + top_ids = [r["member_id"] for r in top100] + + cur.execute( + """ +select member_id, nickname, mobile, member_card_grade_name +from billiards_dwd.dim_member +where scd2_is_current=1 and member_id = any(%(ids)s) +""", + {"ids": top_ids}, + ) + info_map = {r["member_id"]: {"name": (r.get("nickname") or ""), "mobile": (r.get("mobile") or ""), "vip": (r.get("member_card_grade_name") or "")} for r in cur.fetchall()} + + balances = fetch_all(cur, sql_stored_value, {"ids": top_ids}) + balance_map = {r["member_id"]: d(r["stored_value_balance"]) for r in balances} + + monthly_consume = fetch_all(cur, sql_monthly_consume, {"site_id": SITE_ID}) + monthly_recharge = fetch_all(cur, sql_monthly_recharge, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]}) + fav_all = fetch_all(cur, sql_fav_all, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1]}) + fav_dec = fetch_all(cur, sql_fav_dec, {"site_id": SITE_ID}) + + consume_map: dict[int, dict[str, Decimal]] = {} + for r in monthly_consume: + consume_map.setdefault(r["member_id"], {})[r["month_key"]] = d(r["consume_sum"]) + + recharge_map: dict[int, dict[str, Decimal]] = {} + for r in monthly_recharge: + recharge_map.setdefault(r["member_id"], {})[r["month_key"]] = d(r["recharge_sum"]) + + fav_all_map = {r["member_id"]: (r["fav5"] or "") for r in fav_all} + fav_dec_map = {r["member_id"]: (r["fav5"] or "") for r in fav_dec} + + rows_total: list[list[Any]] = [] + rows_split: list[list[Any]] = [] + + for idx, r in enumerate(top100, start=1): + mid = r["member_id"] + info = info_map.get(mid, {"name": "", "mobile": "", "vip": ""}) + total_rech = sum(recharge_map.get(mid, {}).values()) if recharge_map.get(mid) else Decimal("0") + rows_total.append([ + idx, + info["name"], + mask_phone(info["mobile"]), + fav_all_map.get(mid, ""), + fmt_money(r["consume_total"]), + fmt_money(total_rech), + fmt_money(balance_map.get(mid, Decimal("0"))), + "", # 评价后续回填 + ]) + + c = consume_map.get(mid, {}) + rc = recharge_map.get(mid, {}) + rows_split.append([ + idx, + info["name"], + mask_phone(info["mobile"]), + fav_dec_map.get(mid, ""), + fmt_money(c.get("2025-12", Decimal("0"))), + fmt_money(rc.get("2025-12", Decimal("0"))), + fmt_money(c.get("2025-11", Decimal("0"))), + fmt_money(rc.get("2025-11", Decimal("0"))), + fmt_money(c.get("2025-10", Decimal("0"))), + fmt_money(rc.get("2025-10", Decimal("0"))), + ]) + + csv_total = OUT_DIR / "客户_Top100_2025年10-12月_总表.csv" + md_total = OUT_DIR / "客户_Top100_2025年10-12月_总表.md" + write_csv( + csv_total, + title_total, + desc_total, + [ + ["排名", "客户名称", "电话号码", "10月-12月", "10月-12月", "10月-12月", "当前", "评价"], + ["排名", "客户名称", "电话号码", "喜爱助教昵称", "总消费(元)", "总充值(元)", "储值卡未使用金额(元)", "评价"], + ], + rows_total, + ) + write_md( + md_total, + title_total, + thinking, + desc_total, + [ + SqlBlock("Top100(按消费总额)", sql_top100), + SqlBlock("按月消费汇总", sql_monthly_consume), + SqlBlock("按月充值汇总", sql_monthly_recharge), + SqlBlock("储值卡未使用金额(当前余额汇总)", sql_stored_value), + SqlBlock("喜爱助教Top5(10-12月)", sql_fav_all), + SqlBlock("评价画像:订单/时长/到店日期", sql_review_profile), + SqlBlock("评价画像:到店/离店时间(小时)", sql_review_time), + SqlBlock("评价画像:球台分区偏好(按时长)", sql_review_pref), + SqlBlock("评价画像:商品明细(名称+数量)", sql_review_goods), + SqlBlock("评价画像:到店日期明细(用于周期/近期分析)", sql_review_visits), + ], + ) + + csv_split = OUT_DIR / "客户_Top100_2025年10-12月_分表.csv" + md_split = OUT_DIR / "客户_Top100_2025年10-12月_分表.md" + write_csv( + csv_split, + title_split, + desc_split, + [ + ["排名", "客户名称", "电话号码", "12月", "12月", "12月", "11月", "11月", "10月", "10月"], + ["排名", "客户名称", "电话号码", "喜爱助教昵称", "消费(元)", "充值(元)", "消费(元)", "充值(元)", "消费(元)", "充值(元)"], + ], + rows_split, + ) + write_md( + md_split, + title_split, + thinking, + desc_split, + [ + SqlBlock("Top100(按消费总额)", sql_top100), + SqlBlock("按月消费汇总", sql_monthly_consume), + SqlBlock("按月充值汇总", sql_monthly_recharge), + SqlBlock("喜爱助教Top5(仅12月)", sql_fav_dec), + ], + ) + + return top_ids + + +def backfill_customer_reviews(cur, top_ids: list[int]) -> None: + def csv_safe_text(text: str) -> str: + return (text or "").replace(",", ",").replace("\r", " ").replace("\n", " ").strip() + + def fmt_clock_time(v: Any, *, prefix_next_day: bool) -> str: + if v is None: + return "" + minutes = int((d(v) * 60).to_integral_value(rounding="ROUND_HALF_UP")) + is_next_day = minutes >= 24 * 60 + minutes = minutes % (24 * 60) + hh = minutes // 60 + mm = minutes % 60 + base = f"{hh:02d}:{mm:02d}" + if is_next_day and prefix_next_day: + return f"次日{base}" + return base + + def norm_area(name: str | None) -> str: + s = (name or "").strip() + if not s: + return "未知" + m = re.search(r"([A-Z])区", s) + if m: + return f"{m.group(1)}区" + if "斯诺克" in s: + return "S区/斯诺克" + if "麻将" in s: + return "麻将" + if "K包" in s or "VIP" in s or "包厢" in s: + return "包厢" + if "团建" in s: + return "团建" + return s + + # 画像(订单维度:订单、时长、到离店时间、到店日期分布) + sql_profile = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +, order_ext as ( + select + o.*, + extract(epoch from (o.order_end_time - o.order_start_time)) as stay_seconds + from orders o +) +select + o.member_id, + count(*) as orders, + avg(o.order_amount) as avg_order, + sum(o.table_use_seconds)/3600.0 as play_hours, + avg(o.table_use_seconds)/3600.0 as avg_play_hours, + sum(case when o.order_start_time >= '{WIN_DEC[0]}'::timestamptz and o.order_start_time < '{WIN_DEC[1]}'::timestamptz then coalesce(o.table_use_seconds,0) else 0 end)/3600.0 as play_hours_dec, + min((o.order_start_time at time zone '{TZ}')::date) as first_visit_day, + max((o.order_start_time at time zone '{TZ}')::date) as last_visit_day, + count(distinct (o.order_start_time at time zone '{TZ}')::date) as visit_days, + count(distinct case when o.order_start_time >= '{WIN_DEC[0]}'::timestamptz and o.order_start_time < '{WIN_DEC[1]}'::timestamptz then (o.order_start_time at time zone '{TZ}')::date else null end) as visit_days_dec, + sum(case when o.order_start_time >= '{WIN_OCT[0]}'::timestamptz and o.order_start_time < '{WIN_OCT[1]}'::timestamptz then 1 else 0 end) as orders_oct, + sum(case when o.order_start_time >= '{WIN_NOV[0]}'::timestamptz and o.order_start_time < '{WIN_NOV[1]}'::timestamptz then 1 else 0 end) as orders_nov, + sum(case when o.order_start_time >= '{WIN_DEC[0]}'::timestamptz and o.order_start_time < '{WIN_DEC[1]}'::timestamptz then 1 else 0 end) as orders_dec, + sum(coalesce(o.stay_seconds,0))/3600.0 as stay_hours_total, + sum(case when o.order_start_time >= '{WIN_DEC[0]}'::timestamptz and o.order_start_time < '{WIN_DEC[1]}'::timestamptz then coalesce(o.stay_seconds,0) else 0 end)/3600.0 as stay_hours_dec +from order_ext o +where o.member_id = any(%(ids)s) +group by o.member_id; +""" + + sql_time = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +, t as ( + select + o.member_id, + extract(hour from (o.order_start_time at time zone '{TZ}')) + + extract(minute from (o.order_start_time at time zone '{TZ}'))/60.0 + + extract(second from (o.order_start_time at time zone '{TZ}'))/3600.0 as arrive_h, + extract(hour from (o.order_end_time at time zone '{TZ}')) + + extract(minute from (o.order_end_time at time zone '{TZ}'))/60.0 + + extract(second from (o.order_end_time at time zone '{TZ}'))/3600.0 as leave_h_raw + from orders o + where o.member_id = any(%(ids)s) +), +tt as ( + select + member_id, + arrive_h, + case when leave_h_raw < arrive_h then leave_h_raw + 24 else leave_h_raw end as leave_h + from t +) +select + member_id, + avg(arrive_h) as arrive_avg_h, + percentile_cont(0.5) within group (order by arrive_h) as arrive_med_h, + avg(leave_h) as leave_avg_h, + percentile_cont(0.5) within group (order by leave_h) as leave_med_h +from tt +group by member_id; +""" + + prof = fetch_all(cur, sql_profile, {"site_id": SITE_ID, "ids": top_ids}) + prof_map = {r["member_id"]: r for r in prof} + + times = fetch_all(cur, sql_time, {"site_id": SITE_ID, "ids": top_ids}) + time_map = {r["member_id"]: r for r in times} + + # 偏好(按球台分区小时) + sql_pref = f""" +select + tfl.member_id, + coalesce(tfl.site_table_area_name,'') as site_table_area_name, + sum(tfl.real_table_use_seconds)/3600.0 as hours +from billiards_dwd.dwd_table_fee_log tfl +where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz and tfl.start_use_time < %(window_end)s::timestamptz + and tfl.member_id = any(%(ids)s) +group by tfl.member_id, site_table_area_name; +""" + + prefs = fetch_all( + cur, + sql_pref, + {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1], "ids": top_ids}, + ) + pref_map: dict[int, list[dict[str, Any]]] = {} + for r in prefs: + pref_map.setdefault(r["member_id"], []).append(r) + + # 商品(名称+数量) + sql_food = """ +with base_orders as ( + select order_settle_id, max(member_id) as member_id + from billiards_dwd.dwd_table_fee_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz and start_use_time < %(window_end)s::timestamptz + group by order_settle_id +) +select + bo.member_id, + g.ledger_name, + sum(g.ledger_count) as qty, + sum(g.ledger_amount) as amount +from base_orders bo +join billiards_dwd.dwd_store_goods_sale g on g.order_settle_id = bo.order_settle_id +where g.site_id=%(site_id)s and coalesce(g.is_delete,0)=0 + and bo.member_id = any(%(ids)s) +group by bo.member_id, g.ledger_name; +""" + + foods = fetch_all( + cur, + sql_food, + {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1], "ids": top_ids}, + ) + food_map: dict[int, list[dict[str, Any]]] = {} + for r in foods: + food_map.setdefault(r["member_id"], []).append(r) + + sql_visits = sql_order_base(WIN_ALL[0], WIN_ALL[1]) + f""" +select + o.member_id, + (o.order_start_time at time zone '{TZ}')::date as visit_date, + count(*) as orders, + sum(o.order_amount) as amount +from orders o +where o.member_id = any(%(ids)s) +group by o.member_id, visit_date +order by o.member_id, visit_date; +""" + visits = fetch_all(cur, sql_visits, {"site_id": SITE_ID, "ids": top_ids}) + visit_map: dict[int, list[dict[str, Any]]] = {} + for r in visits: + visit_map.setdefault(r["member_id"], []).append(r) + + # 喜爱助教(10-12月) + sql_fav = f""" +with x as ( + select + asl.tenant_member_id as member_id, + asl.nickname as assistant_nickname, + sum(case when asl.order_assistant_type=1 then asl.income_seconds else asl.income_seconds*1.5 end) / 3600.0 as weighted_hours + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0)=0 + and asl.tenant_member_id = any(%(ids)s) + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + group by asl.tenant_member_id, asl.nickname +), +ranked as ( + select *, row_number() over(partition by member_id order by weighted_hours desc) as rn + from x +) +select + member_id, + string_agg(assistant_nickname || '(' || to_char(round(weighted_hours::numeric, 1), 'FM999999990.0') || 'h)', '、' order by weighted_hours desc) as fav5 +from ranked +where rn <= 5 +group by member_id; +""" + + favs = fetch_all(cur, sql_fav, {"site_id": SITE_ID, "window_start": WIN_ALL[0], "window_end": WIN_ALL[1], "ids": top_ids}) + fav_map = {r["member_id"]: (r["fav5"] or "") for r in favs} + + # 到店周期中位(天)与排名:以 Top100 为比较集合;值越小表示到店越频繁,排名越靠前 + gap_med_all: dict[int, int] = {} + gap_med_dec: dict[int, int] = {} + for mid in top_ids: + vrows = visit_map.get(mid, []) + dates_all = [r.get("visit_date") for r in vrows if r.get("visit_date")] + gaps_all: list[int] = [] + for d1, d2 in zip(dates_all, dates_all[1:]): + gaps_all.append((d2 - d1).days) + if gaps_all: + gaps_sorted = sorted(gaps_all) + gap_med_all[mid] = gaps_sorted[len(gaps_sorted) // 2] + + dates_dec = [d for d in dates_all if d.year == 2025 and d.month == 12] + gaps_dec: list[int] = [] + for d1, d2 in zip(dates_dec, dates_dec[1:]): + gaps_dec.append((d2 - d1).days) + if gaps_dec: + gaps_sorted = sorted(gaps_dec) + gap_med_dec[mid] = gaps_sorted[len(gaps_sorted) // 2] + + def dense_rank(values: list[tuple[int, Any]], *, reverse: bool) -> dict[int, int]: + sorted_vals = sorted(values, key=lambda kv: kv[1], reverse=reverse) + out: dict[int, int] = {} + last_v = None + rank = 0 + for mid, v in sorted_vals: + if last_v is None or v != last_v: + rank += 1 + last_v = v + out[mid] = rank + return out + + gap_rank_all = dense_rank([(mid, v) for mid, v in gap_med_all.items()], reverse=False) + gap_rank_dec = dense_rank([(mid, v) for mid, v in gap_med_dec.items()], reverse=False) + + # 在店时长:用台费使用时长(real_table_use_seconds 汇总到 orders.table_use_seconds)作为“在店时长”近似 + play_rank_all = dense_rank( + [(mid, d(prof_map.get(mid, {}).get("play_hours"))) for mid in top_ids if d(prof_map.get(mid, {}).get("play_hours")) > 0], + reverse=True, + ) + play_rank_dec = dense_rank( + [(mid, d(prof_map.get(mid, {}).get("play_hours_dec"))) for mid in top_ids if d(prof_map.get(mid, {}).get("play_hours_dec")) > 0], + reverse=True, + ) + + def build_review( + mid: int, + rank: int, + consume_total: Decimal, + recharge_total: Decimal, + stored_value_balance: Decimal, + ) -> str: + p = prof_map.get(mid) or {} + orders = int(p.get("orders") or 0) + if orders <= 0: + return "" + avg_order = d(p.get("avg_order")) + play_h = d(p.get("play_hours")) + avg_play_h = d(p.get("avg_play_hours")) + + # 偏好:球台分区Top(按时长) + areas_raw = pref_map.get(mid, []) + area_hours: dict[str, Decimal] = {} + for r in areas_raw: + label = norm_area(r.get("site_table_area_name")) + area_hours[label] = area_hours.get(label, Decimal("0")) + d(r.get("hours")) + area_items = sorted(area_hours.items(), key=lambda kv: kv[1], reverse=True) + area_total = sum((v for _, v in area_items), Decimal("0")) + pref_text = "" + if area_total > 0 and area_items: + top_parts = [] + for label, hours in area_items[:4]: + pct = (hours / area_total * 100).quantize(Decimal("1.0")) + top_parts.append(f"{label}({pct}%)") + pref_text = "、".join(top_parts) + + # 时间:到店/离店(小时) + t = time_map.get(mid) or {} + time_text = "" + if t: + a_avg = fmt_clock_time(t.get("arrive_avg_h"), prefix_next_day=False) + a_med = fmt_clock_time(t.get("arrive_med_h"), prefix_next_day=False) + l_avg = fmt_clock_time(t.get("leave_avg_h"), prefix_next_day=True) + l_med = fmt_clock_time(t.get("leave_med_h"), prefix_next_day=True) + if a_avg and a_med and l_avg and l_med: + time_text = f"到店均值{a_avg} 中位{a_med};离店均值{l_avg} 中位{l_med}" + + # 商品:名称+数量+金额 + foods_ = food_map.get(mid, []) + foods_.sort(key=lambda r: d(r["amount"]), reverse=True) + goods_total = sum((d(r["amount"]) for r in foods_), Decimal("0")) + goods_text = "" + if foods_: + parts = [] + for r in foods_[:6]: + name = csv_safe_text(str(r.get("ledger_name") or "")) + qty = d(r.get("qty")) + amt = d(r.get("amount")) + if not name: + continue + qty_i = int(qty) if qty == qty.to_integral_value() else qty + parts.append(f"{name}×{qty_i}(¥{amt:.2f})") + if parts: + goods_text = "、".join(parts) + if consume_total > 0 and goods_total > 0: + ratio = (goods_total / consume_total * 100).quantize(Decimal("1.0")) + goods_text = f"{goods_text}(商品合计¥{goods_total:.2f} 占比{ratio}%)" + + # 到店周期/近期分布(按到店日期) + vrows = visit_map.get(mid, []) + visit_dates = [r.get("visit_date") for r in vrows if r.get("visit_date")] + last_day = p.get("last_visit_day") + visit_days = int(p.get("visit_days") or 0) + visit_days_dec = int(p.get("visit_days_dec") or 0) + orders_dec = int(p.get("orders_dec") or 0) + + # 趋势:按月订单数粗略判断(仅Top100客户画像用途) + orders_oct = int(p.get("orders_oct") or 0) + orders_nov = int(p.get("orders_nov") or 0) + orders_dec2 = int(p.get("orders_dec") or 0) + if orders_dec2 > orders_nov >= orders_oct: + trend = "10-12月到店频次上升,12月更活跃" + elif orders_oct > orders_nov > orders_dec2: + trend = "10-12月到店频次下降,建议重点唤醒" + elif orders_dec2 >= orders_oct and orders_dec2 >= orders_nov: + trend = "12月为高峰月,具备加深运营空间" + else: + trend = "到店频次波动,建议按常用时段做稳定触达" + + # 运营建议(尽量简短、可执行) + advice_parts: list[str] = [] + if stored_value_balance <= 0 and recharge_total > 0: + advice_parts.append("关注是否为临时充值型,建议引导储值梯度与权益") + if stored_value_balance > 0 and stored_value_balance < Decimal("500"): + advice_parts.append("储值余额偏低,建议在其常用时段做补能引导") + if stored_value_balance >= Decimal("10000"): + advice_parts.append("储值余额充足,可提供包厢/团建档期与专属权益") + if pref_text and ("包厢" in pref_text or "团建" in pref_text): + advice_parts.append("重点维护包厢/团建需求,提前锁档与套餐化") + if goods_total >= Decimal("2000"): + advice_parts.append("商品贡献高,可做常购商品补货提醒与组合促销") + if not advice_parts: + advice_parts.append("保持常用时段的稳定服务供给,提升复购粘性") + advice = ";".join(advice_parts[:3]) + + # 综合(按你给的格式组织;排名基于Top100集合) + gap_rank_all_text = f"#{gap_rank_all.get(mid)}" if gap_rank_all.get(mid) else "—" + gap_rank_dec_text = f"#{gap_rank_dec.get(mid)}" if gap_rank_dec.get(mid) else "—" + play_rank_all_text = f"#{play_rank_all.get(mid)}" if play_rank_all.get(mid) else "—" + play_rank_dec_text = f"#{play_rank_dec.get(mid)}" if play_rank_dec.get(mid) else "—" + + composite_lines = [] + composite_lines.append( + f"10-12月到店消费{visit_days}天/{orders}次,到店周期中位{gap_rank_all_text},消费排名#{rank},在店时长{play_rank_all_text}" + ) + composite_lines.append( + f"12月到店消费{visit_days_dec}天/{orders_dec}次,到店周期中位{gap_rank_dec_text},消费排名#{rank},在店时长{play_rank_dec_text}" + ) + if last_day: + composite_lines.append(f"最近到店{last_day}") + composite_lines.append(f"趋势:{trend}") + composite_lines.append(f"围客与客户运营建议:{advice}") + + parts = [ + f"订单:{orders}单,平均单次¥{avg_order:.2f}", + f"打球:{fmt_hours(play_h,1)},平均单次{fmt_hours(avg_play_h,1)}", + ] + if pref_text: + parts.append(f"偏好:{pref_text}") + if time_text: + parts.append(f"时间:{time_text}") + if goods_text: + parts.append(f"商品:{goods_text}") + parts.append("综合:" + ";".join([x for x in composite_lines if x])) + + return csv_safe_text(";".join([p for p in parts if p])) + + # 回写 CSV(保留原有总消费/总充值/储值余额/助教偏好,只更新评价列) + csv_total = OUT_DIR / "客户_Top100_2025年10-12月_总表.csv" + if not csv_total.exists(): + return + + lines = csv_total.read_text(encoding="utf-8").splitlines() + # CSV:前3行是标题、说明、空行;接着2行表头;后面是数据 + head = lines[:5] + data_lines = lines[5:] + + # 解析数据行(简单 split,避免引号复杂情况:本脚本生成的行不含逗号) + new_data = [] + for idx, line in enumerate(data_lines, start=1): + cols = line.split(",") + if len(cols) < 8: + new_data.append(line) + continue + # 按排名映射 member_id + rank = int(cols[0]) + mid = top_ids[rank - 1] if 0 < rank <= len(top_ids) else None + if mid: + consume_total = d(cols[4]) + recharge_total = d(cols[5]) + stored_value_balance = d(cols[6]) + cols[7] = build_review(mid, rank, consume_total, recharge_total, stored_value_balance) + new_data.append(",".join(cols)) + + csv_total.write_text("\n".join(head + new_data) + "\n", encoding="utf-8") + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + with conn() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + build_finance_discount(cur) + top_ids = build_customer_top100(cur) + backfill_customer_reviews(cur, top_ids) + + print(f"完成:{OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/etl_billiards/docs/table_2025-12-19/助教_基础课时长排行_2025年10-12月.csv b/etl_billiards/docs/table_2025-12-19/助教_基础课时长排行_2025年10-12月.csv new file mode 100644 index 0000000..3032bdc --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_基础课时长排行_2025年10-12月.csv @@ -0,0 +1,73 @@ +2025年10-12月 助教基础课时长排行榜 +口径:order_assistant_type=1,时长=income_seconds/3600(小时),按月排名。 + +月份,排名,助教昵称,指标 +2025-10,1,佳怡,139.55h +2025-10,2,璇子,120.20h +2025-10,3,婉婉,90.68h +2025-10,4,七七,64.70h +2025-10,5,小柔,63.87h +2025-10,6,球球,57.45h +2025-10,7,小敌,55.82h +2025-10,8,涛涛,50.13h +2025-10,9,周周,41.33h +2025-10,10,素素,40.45h +2025-10,11,乔西,32.60h +2025-10,12,苏苏,25.15h +2025-10,13,奈千,23.62h +2025-10,14,年糕,21.23h +2025-10,15,欣怡,19.50h +2025-10,16,饭团,16.00h +2025-10,17,Amy,11.97h +2025-10,18,姜姜,6.60h +2025-10,19,希希,5.02h +2025-10,20,悦悦,2.30h +2025-11,1,佳怡,176.25h +2025-11,2,璇子,147.92h +2025-11,3,小燕,109.28h +2025-11,4,Amy,93.53h +2025-11,5,七七,91.90h +2025-11,6,小柔,88.65h +2025-11,7,涛涛,74.40h +2025-11,8,阿清,73.48h +2025-11,9,小敌,72.90h +2025-11,10,周周,71.27h +2025-11,11,球球,66.50h +2025-11,12,婉婉,46.03h +2025-11,13,小侯,42.58h +2025-11,14,千千,38.88h +2025-11,15,年糕,35.80h +2025-11,16,柚子,35.40h +2025-11,17,素素,35.03h +2025-11,18,瑶瑶,34.25h +2025-11,19,奈千,32.83h +2025-11,20,乔西,30.57h +2025-11,21,泡芙,21.38h +2025-11,22,梦梦,19.60h +2025-11,23,苏苏,13.52h +2025-11,24,欣怡,10.33h +2025-11,25,QQ,5.17h +2025-11,26,西子,1.82h +2025-11,27,希希,1.58h +2025-12,1,小燕,159.02h +2025-12,2,佳怡,109.40h +2025-12,3,璇子,90.75h +2025-12,4,七七,77.72h +2025-12,5,阿清,66.45h +2025-12,6,周周,60.02h +2025-12,7,小柔,54.93h +2025-12,8,小侯,49.57h +2025-12,9,球球,48.58h +2025-12,10,涛涛,44.08h +2025-12,11,苏苏,43.90h +2025-12,12,千千,38.28h +2025-12,13,乔西,25.82h +2025-12,14,年糕,25.62h +2025-12,15,瑶瑶,19.48h +2025-12,16,Amy,18.08h +2025-12,17,婉婉,17.83h +2025-12,18,梦梦,16.08h +2025-12,19,素素,9.98h +2025-12,20,小敌,6.40h +2025-12,21,奈千,2.58h +2025-12,22,QQ,1.22h diff --git a/etl_billiards/docs/table_2025-12-19/助教_基础课时长排行_2025年10-12月.md b/etl_billiards/docs/table_2025-12-19/助教_基础课时长排行_2025年10-12月.md new file mode 100644 index 0000000..0cfa257 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_基础课时长排行_2025年10-12月.md @@ -0,0 +1,31 @@ +# 2025年10-12月 助教基础课时长排行榜 +## 思考过程 +按月汇总助教基础课时长,并用 dense_rank 做排名。 + +## 查询说明 +口径:order_assistant_type=1;时长=income_seconds/3600(小时)。 + +## SQL + +### 基础课时长(助教+月份汇总) +```sql +with raw as ( + select + asl.nickname as assistant, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0)=0 + and asl.order_assistant_type=1 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz +) +select + assistant, + month_key, + sum(income_seconds)/3600.0 as hours +from raw +where month_key is not null +group by assistant, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教_客户充值归因排行_2025年10-12月.csv b/etl_billiards/docs/table_2025-12-19/助教_客户充值归因排行_2025年10-12月.csv new file mode 100644 index 0000000..cc075d5 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_客户充值归因排行_2025年10-12月.csv @@ -0,0 +1,35 @@ +2025年10-12月 助教客户充值归因排行榜(全额复制口径) +口径:充值支付(dwd_payment.relate_type=5)在消费窗口±30分钟内命中且订单有助教,则全额计入助教;多助教/多订单命中会重复计入。 + +月份,排名,助教昵称,指标 +2025-10,1,璇子,34700.00 +2025-10,2,小柔,31700.00 +2025-10,3,佳怡,27000.00 +2025-10,4,婉婉,24000.00 +2025-10,5,小敌,21000.00 +2025-10,5,涛涛,21000.00 +2025-10,6,奈千,18000.00 +2025-10,7,乔西,17000.00 +2025-10,8,球球,15000.00 +2025-10,9,周周,11000.00 +2025-10,10,年糕,9000.00 +2025-10,11,七七,6000.00 +2025-10,11,素素,6000.00 +2025-10,11,苏苏,6000.00 +2025-10,12,姜姜,4000.00 +2025-10,13,Amy,3000.00 +2025-10,13,悦悦,3000.00 +2025-10,13,欣怡,3000.00 +2025-11,1,佳怡,20000.00 +2025-11,2,小柔,11000.00 +2025-11,3,璇子,10000.00 +2025-11,4,Amy,9000.00 +2025-11,4,周周,9000.00 +2025-11,4,婉婉,9000.00 +2025-11,4,球球,9000.00 +2025-11,5,小敌,8000.00 +2025-11,6,涛涛,5000.00 +2025-11,7,欣怡,4000.00 +2025-11,8,乔西,3000.00 +2025-11,8,柚子,3000.00 +2025-11,9,素素,1000.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教_客户充值归因排行_2025年10-12月.md b/etl_billiards/docs/table_2025-12-19/助教_客户充值归因排行_2025年10-12月.md new file mode 100644 index 0000000..1050c8c --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_客户充值归因排行_2025年10-12月.md @@ -0,0 +1,85 @@ +# 2025年10-12月 助教客户充值归因排行榜(全额复制口径) +## 思考过程 +按“消费窗口±30分钟”把充值支付命中到订单,再全额计入订单内助教,并按月排名。 + +## 查询说明 +注意:多助教/多订单命中按全额复制,充值会重复计入,故助教汇总可能大于门店总额。 + +## SQL + +### 充值归因(助教+月份汇总,全额复制) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select rp.pay_time, ow.order_settle_id, rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select assistant, month_key, sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教_客户流水排行_2025年10-12月.csv b/etl_billiards/docs/table_2025-12-19/助教_客户流水排行_2025年10-12月.csv new file mode 100644 index 0000000..204968c --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_客户流水排行_2025年10-12月.csv @@ -0,0 +1,51 @@ +2025年10-12月 助教客户流水排行榜(全额复制口径) +口径:客户流水=台费+助教+商品应付金额按订单归集后,全额计入订单内每位助教;多助教会导致汇总>门店总额。 + +月份,排名,助教昵称,指标 +2025-10,1,璇子,80804.14 +2025-10,2,婉婉,62187.64 +2025-10,3,小柔,52623.85 +2025-10,4,小敌,44510.29 +2025-10,5,佳怡,44466.03 +2025-10,6,七七,38810.44 +2025-10,7,奈千,38653.58 +2025-10,8,涛涛,35940.84 +2025-10,9,素素,34135.89 +2025-10,10,球球,33923.75 +2025-10,11,周周,27375.91 +2025-10,12,年糕,26289.89 +2025-10,13,乔西,17649.50 +2025-10,14,Amy,15810.80 +2025-10,15,苏苏,11236.84 +2025-10,16,饭团,7955.28 +2025-10,17,欣怡,4824.69 +2025-10,18,希希,3086.34 +2025-10,19,悦悦,2970.96 +2025-10,20,姜姜,2333.94 +2025-11,1,璇子,154486.83 +2025-11,2,Amy,121568.32 +2025-11,3,小柔,110137.94 +2025-11,4,涛涛,88677.55 +2025-11,5,七七,84500.79 +2025-11,6,佳怡,79249.31 +2025-11,7,奈千,68543.08 +2025-11,8,瑶瑶,65924.36 +2025-11,9,小敌,47986.57 +2025-11,10,球球,41907.39 +2025-11,11,梦梦,39768.09 +2025-11,12,小燕,39426.42 +2025-11,13,阿清,37302.04 +2025-11,14,婉婉,33326.32 +2025-11,15,周周,31436.74 +2025-11,16,小侯,27313.21 +2025-11,17,千千,24684.71 +2025-11,18,柚子,23234.98 +2025-11,19,素素,18707.30 +2025-11,20,年糕,15696.08 +2025-11,21,乔西,15536.78 +2025-11,22,苏苏,10254.59 +2025-11,23,泡芙,8323.03 +2025-11,24,欣怡,5157.29 +2025-11,25,QQ,1134.18 +2025-11,26,西子,303.51 +2025-11,27,希希,281.22 diff --git a/etl_billiards/docs/table_2025-12-19/助教_客户流水排行_2025年10-12月.md b/etl_billiards/docs/table_2025-12-19/助教_客户流水排行_2025年10-12月.md new file mode 100644 index 0000000..8d68e7c --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_客户流水排行_2025年10-12月.md @@ -0,0 +1,73 @@ +# 2025年10-12月 助教客户流水排行榜(全额复制口径) +## 思考过程 +先把订单应付金额汇总为 order_amount,再把该订单全额计入订单内每位助教,并按月排名。 + +## 查询说明 +注意:多助教按全额复制计入,导致助教汇总>门店总额,这是刻意口径。 + +## SQL + +### 客户流水(助教+月份汇总,全额复制) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id=g.order_settle_id + where g.site_id=%(site_id)s and coalesce(g.is_delete,0)=0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + coalesce(bo.table_amount,0)+coalesce(a.assistant_amount,0)+coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id=bo.order_settle_id + left join goods_amount g on g.order_settle_id=bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id +) +select assistant, month_key, sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教_附加课时长排行_2025年10-12月.csv b/etl_billiards/docs/table_2025-12-19/助教_附加课时长排行_2025年10-12月.csv new file mode 100644 index 0000000..902ea41 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_附加课时长排行_2025年10-12月.csv @@ -0,0 +1,37 @@ +2025年10-12月 助教附加课(超休)时长排行榜 +口径:order_assistant_type=2,超休时长=income_seconds/3600(小时),按月排名。 + +月份,排名,助教昵称,指标 +2025-10,1,球球,11.00h +2025-10,2,佳怡,9.00h +2025-10,3,璇子,8.00h +2025-10,3,苏苏,8.00h +2025-10,4,婉婉,4.00h +2025-10,5,姜姜,3.00h +2025-10,5,小敌,3.00h +2025-11,1,周周,25.00h +2025-11,1,球球,25.00h +2025-11,2,婉婉,15.00h +2025-11,3,佳怡,10.00h +2025-11,3,柚子,10.00h +2025-11,3,璇子,10.00h +2025-11,3,素素,10.00h +2025-11,4,小柔,7.00h +2025-11,4,年糕,7.00h +2025-11,5,泡芙,3.00h +2025-11,5,涛涛,3.00h +2025-11,5,瑶瑶,3.00h +2025-11,6,小燕,2.00h +2025-11,7,乔西,1.00h +2025-11,7,梦梦,1.00h +2025-12,1,七七,22.00h +2025-12,2,小燕,21.00h +2025-12,3,婉婉,15.00h +2025-12,3,小柔,15.00h +2025-12,4,璇子,14.00h +2025-12,5,周周,8.00h +2025-12,6,千千,5.00h +2025-12,6,球球,5.00h +2025-12,7,佳怡,4.00h +2025-12,8,QQ,3.00h +2025-12,9,苏苏,1.00h diff --git a/etl_billiards/docs/table_2025-12-19/助教_附加课时长排行_2025年10-12月.md b/etl_billiards/docs/table_2025-12-19/助教_附加课时长排行_2025年10-12月.md new file mode 100644 index 0000000..e8986cb --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教_附加课时长排行_2025年10-12月.md @@ -0,0 +1,31 @@ +# 2025年10-12月 助教附加课(超休)时长排行榜 +## 思考过程 +按月汇总助教附加课时长,并用 dense_rank 做排名。 + +## 查询说明 +口径:order_assistant_type=2;时长=income_seconds/3600(小时)。 + +## SQL + +### 附加课时长(助教+月份汇总) +```sql +with raw as ( + select + asl.nickname as assistant, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0)=0 + and asl.order_assistant_type=2 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz +) +select + assistant, + month_key, + sum(income_seconds)/3600.0 as hours +from raw +where month_key is not null +group by assistant, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_Amy.csv b/etl_billiards/docs/table_2025-12-19/助教详情_Amy.csv new file mode 100644 index 0000000..bad2eac --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_Amy.csv @@ -0,0 +1,56 @@ +助教详情:Amy(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础123.6h,附加0.0h;客户流水¥150692.64,充值归因¥12000.00;头部客户(12月)Top3:轩哥、明哥、江先生。 + +一、基础课业绩 +说明:评价:基础123.6h,附加0.0h;客户流水¥150692.64,充值归因¥12000.00;头部客户(12月)Top3:轩哥、明哥、江先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,11.97h,17,-32.44h,-24.56h +11月,93.53h,4,39.06h,54.65h +12月,18.08h,16,-26.73h,-23.01h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,15810.80,14,-13468.73,-14839.03 +11月,121568.32,2,77313.93,88242.00 +12月,13313.52,12,-7417.01,-327.12 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,3000.00,13,-11466.67,-10000.00 +11月,9000.00,4,1230.77,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,轩哥,8.8h / 0.0h,4500.95,0.00,36.0h / 0.0h,38175.17,0.00,4.6h / 0.0h,8281.61,0.00 +2,明哥,0.1h / 0.0h,4190.45,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,江先生,2.2h / 0.0h,3202.78,0.00,4.5h / 0.0h,1719.24,0.00,0.0h / 0.0h,0.00,0.00 +4,amy,4.8h / 0.0h,1105.90,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +5,小燕,1.0h / 0.0h,313.44,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +6,叶先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,7.4h / 0.0h,7529.19,3000.00 +7,羊,0.0h / 0.0h,0.00,0.00,4.1h / 0.0h,1017.50,1000.00,0.0h / 0.0h,0.00,0.00 +8,葛先生,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,1585.38,0.00,0.0h / 0.0h,0.00,0.00 +9,陈先生,0.0h / 0.0h,0.00,0.00,4.2h / 0.0h,2566.75,3000.00,0.0h / 0.0h,0.00,0.00 +10,李先生,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,816.28,0.00,0.0h / 0.0h,0.00,0.00 +11,蔡总,1.2h / 0.0h,0.00,0.00,40.2h / 0.0h,75063.98,5000.00,0.0h / 0.0h,0.00,0.00 +12,昌哥,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,624.02,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_Amy.md b/etl_billiards/docs/table_2025-12-19/助教详情_Amy.md new file mode 100644 index 0000000..dda6894 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_Amy.md @@ -0,0 +1,196 @@ +# 助教详情:Amy(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础123.6h,附加0.0h;客户流水¥150692.64,充值归因¥12000.00;头部客户(12月)Top3:轩哥、明哥、江先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_QQ.csv b/etl_billiards/docs/table_2025-12-19/助教详情_QQ.csv new file mode 100644 index 0000000..af1c043 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_QQ.csv @@ -0,0 +1,47 @@ +助教详情:QQ(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础6.4h,附加3.0h;客户流水¥1517.46,充值归因¥0.00;头部客户(12月)Top3:游、张先生、黄先生。 + +一、基础课业绩 +说明:评价:基础6.4h,附加3.0h;客户流水¥1517.46,充值归因¥0.00;头部客户(12月)Top3:游、张先生、黄先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,5.17h,25,-49.31h,-33.72h +12月,1.22h,22,-43.59h,-39.88h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,3.00h,8,-7.27h,-5.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,1134.18,25,-43120.21,-32192.14 +12月,383.28,21,-20347.25,-13257.36 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,游,1.2h / 0.0h,383.28,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +2,张先生,0.0h / 0.0h,0.00,0.00,2.8h / 0.0h,502.16,0.00,0.0h / 0.0h,0.00,0.00 +3,黄先生,0.0h / 3.0h,0.00,0.00,2.4h / 0.0h,632.02,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_QQ.md b/etl_billiards/docs/table_2025-12-19/助教详情_QQ.md new file mode 100644 index 0000000..040c778 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_QQ.md @@ -0,0 +1,196 @@ +# 助教详情:QQ(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础6.4h,附加3.0h;客户流水¥1517.46,充值归因¥0.00;头部客户(12月)Top3:游、张先生、黄先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_七七.csv b/etl_billiards/docs/table_2025-12-19/助教详情_七七.csv new file mode 100644 index 0000000..47420c2 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_七七.csv @@ -0,0 +1,56 @@ +助教详情:七七(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础234.3h,附加22.0h;客户流水¥187346.62,充值归因¥6000.00;头部客户(12月)Top3:蔡总、轩哥、林先生。 + +一、基础课业绩 +说明:评价:基础234.3h,附加22.0h;客户流水¥187346.62,充值归因¥6000.00;头部客户(12月)Top3:蔡总、轩哥、林先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,64.70h,4,20.29h,28.17h +11月,91.90h,5,37.42h,53.02h +12月,77.72h,4,32.91h,36.62h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,22.00h,1,11.73h,14.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,38810.44,6,9530.91,8160.61 +11月,84500.79,5,40246.40,51174.47 +12月,64035.39,2,43304.86,50394.74 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,6000.00,11,-8466.67,-7000.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,蔡总,26.6h / 0.0h,29660.33,0.00,45.7h / 0.0h,52373.96,0.00,0.0h / 0.0h,0.00,0.00 +2,轩哥,32.8h / 22.0h,27236.57,0.00,24.2h / 0.0h,21849.91,0.00,39.5h / 0.0h,29630.52,3000.00 +3,林先生,14.0h / 0.0h,3808.56,0.00,5.4h / 0.0h,1623.92,0.00,0.0h / 0.0h,0.00,0.00 +4,江先生,3.2h / 0.0h,3042.99,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +5,张先生,1.0h / 0.0h,286.94,0.00,10.9h / 0.0h,3995.10,0.00,13.2h / 0.0h,5315.81,0.00 +6,游,0.0h / 0.0h,0.00,0.00,0.2h / 0.0h,3544.42,0.00,0.0h / 0.0h,0.00,0.00 +7,罗先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.1h / 0.0h,545.55,0.00 +8,小熊,0.0h / 0.0h,0.00,0.00,1.3h / 0.0h,314.44,0.00,0.0h / 0.0h,0.00,0.00 +9,叶总,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.0h / 0.0h,862.68,0.00 +10,陶,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.2h / 0.0h,1278.72,0.00 +11,T,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.8h / 0.0h,1177.16,3000.00 +12,胡先生,0.0h / 0.0h,0.00,0.00,4.2h / 0.0h,799.04,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_七七.md b/etl_billiards/docs/table_2025-12-19/助教详情_七七.md new file mode 100644 index 0000000..4cb7dde --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_七七.md @@ -0,0 +1,196 @@ +# 助教详情:七七(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础234.3h,附加22.0h;客户流水¥187346.62,充值归因¥6000.00;头部客户(12月)Top3:蔡总、轩哥、林先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_乔西.csv b/etl_billiards/docs/table_2025-12-19/助教详情_乔西.csv new file mode 100644 index 0000000..cbd3b25 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_乔西.csv @@ -0,0 +1,59 @@ +助教详情:乔西(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础89.0h,附加1.0h;客户流水¥46219.50,充值归因¥20000.00;头部客户(12月)Top3:轩哥、T、林先生。 + +一、基础课业绩 +说明:评价:基础89.0h,附加1.0h;客户流水¥46219.50,充值归因¥20000.00;头部客户(12月)Top3:轩哥、T、林先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,32.60h,11,-11.81h,-3.93h +11月,30.57h,20,-23.91h,-8.32h +12月,25.82h,13,-18.99h,-15.28h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,1.00h,7,-7.80h,-6.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,17649.50,13,-11630.03,-13000.33 +11月,15536.78,21,-28717.61,-17789.54 +12月,13033.22,13,-7697.31,-607.42 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,17000.00,7,2533.33,4000.00 +11月,3000.00,8,-4769.23,-6000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,轩哥,8.2h / 0.0h,8244.84,0.00,8.3h / 0.0h,7623.25,0.00,9.3h / 0.0h,11503.53,8000.00 +2,T,6.8h / 0.0h,1789.02,0.00,4.4h / 0.0h,1100.80,0.00,0.0h / 0.0h,0.00,0.00 +3,林先生,4.6h / 0.0h,1369.51,0.00,5.0h / 0.0h,1645.89,0.00,0.0h / 0.0h,0.00,0.00 +4,张先生,3.3h / 0.0h,1066.81,0.00,0.0h / 0.0h,0.00,0.00,2.5h / 0.0h,489.66,0.00 +5,候,2.9h / 0.0h,563.04,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +6,罗先生,0.0h / 0.0h,0.00,0.00,1.4h / 0.0h,454.46,0.00,0.0h / 0.0h,0.00,0.00 +7,陈腾鑫,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.9h / 0.0h,162.29,3000.00 +8,葛先生,0.0h / 0.0h,0.00,0.00,2.2h / 0.0h,707.97,0.00,0.0h / 0.0h,0.00,0.00 +9,陈先生,0.0h / 0.0h,0.00,0.00,4.2h / 0.0h,2566.75,3000.00,0.0h / 0.0h,0.00,0.00 +10,陈淑涛,0.0h / 0.0h,0.00,0.00,3.4h / 0.0h,1021.49,0.00,0.0h / 0.0h,0.00,0.00 +11,周先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,7.5h / 0.0h,2726.01,0.00 +12,陈德韩,0.0h / 0.0h,0.00,0.00,0.0h / 1.0h,0.00,0.00,7.6h / 0.0h,1568.91,5000.00 +13,黄先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,300.37,0.00 +14,陈先生,0.0h / 0.0h,0.00,0.00,1.8h / 0.0h,416.17,0.00,0.0h / 0.0h,0.00,0.00 +15,方先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.2h / 0.0h,898.73,1000.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_乔西.md b/etl_billiards/docs/table_2025-12-19/助教详情_乔西.md new file mode 100644 index 0000000..1d4e6a0 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_乔西.md @@ -0,0 +1,196 @@ +# 助教详情:乔西(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础89.0h,附加1.0h;客户流水¥46219.50,充值归因¥20000.00;头部客户(12月)Top3:轩哥、T、林先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_佳怡.csv b/etl_billiards/docs/table_2025-12-19/助教详情_佳怡.csv new file mode 100644 index 0000000..f62f7b6 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_佳怡.csv @@ -0,0 +1,63 @@ +助教详情:佳怡(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础425.2h,附加23.0h;客户流水¥156229.68,充值归因¥47000.00;头部客户(12月)Top3:罗先生、周周、轩哥。 + +一、基础课业绩 +说明:评价:基础425.2h,附加23.0h;客户流水¥156229.68,充值归因¥47000.00;头部客户(12月)Top3:罗先生、周周、轩哥。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,139.55h,1,95.14h,103.02h +11月,176.25h,1,121.77h,137.37h +12月,109.40h,2,64.59h,68.31h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,9.00h,2,2.43h,1.00h +11月,10.00h,3,1.20h,3.00h +12月,4.00h,7,-6.27h,-4.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,44466.03,5,15186.50,13816.20 +11月,79249.31,6,34994.92,45922.99 +12月,32514.34,6,11783.81,18873.70 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,27000.00,3,12533.33,14000.00 +11月,20000.00,1,12230.77,11000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,罗先生,67.8h / 4.0h,16680.01,0.00,57.5h / 10.0h,12309.26,0.00,46.9h / 8.0h,12047.56,7000.00 +2,周周,13.2h / 0.0h,3866.19,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,轩哥,6.4h / 0.0h,3741.42,0.00,18.0h / 0.0h,24036.03,0.00,24.6h / 1.0h,17999.86,3000.00 +4,大G,9.5h / 0.0h,2623.97,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +5,T,1.9h / 0.0h,2053.02,0.00,5.0h / 0.0h,2490.70,0.00,8.1h / 0.0h,1323.17,3000.00 +6,林先生,4.5h / 0.0h,1720.12,0.00,10.4h / 0.0h,3269.81,0.00,0.0h / 0.0h,0.00,0.00 +7,游,3.2h / 0.0h,1307.16,0.00,10.3h / 0.0h,4754.69,0.00,0.0h / 0.0h,0.00,0.00 +8,胡先生,3.0h / 0.0h,522.45,0.00,26.4h / 0.0h,9712.14,13000.00,0.0h / 0.0h,0.00,0.00 +9,江先生,0.0h / 0.0h,0.00,0.00,3.8h / 0.0h,1374.85,0.00,0.0h / 0.0h,0.00,0.00 +10,陈先生,0.0h / 0.0h,0.00,0.00,7.6h / 0.0h,2566.75,3000.00,0.0h / 0.0h,0.00,0.00 +11,陈腾鑫,0.0h / 0.0h,0.00,0.00,19.2h / 0.0h,4276.97,1000.00,38.5h / 0.0h,7626.75,12000.00 +12,张先生,0.0h / 0.0h,0.00,0.00,4.9h / 0.0h,2233.65,0.00,0.0h / 0.0h,0.00,0.00 +13,歌神,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,361.38,0.00 +14,夏,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,455.03,0.00,5.8h / 0.0h,2595.54,1000.00 +15,小熊,0.0h / 0.0h,0.00,0.00,6.4h / 0.0h,2072.54,3000.00,6.8h / 0.0h,1213.06,1000.00 +16,蔡总,0.0h / 0.0h,0.00,0.00,4.5h / 0.0h,9696.89,0.00,0.0h / 0.0h,0.00,0.00 +17,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.7h / 0.0h,337.17,0.00 +18,贺斌,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,247.34,0.00 +19,陶,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.1h / 0.0h,714.20,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_佳怡.md b/etl_billiards/docs/table_2025-12-19/助教详情_佳怡.md new file mode 100644 index 0000000..a524aa2 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_佳怡.md @@ -0,0 +1,196 @@ +# 助教详情:佳怡(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础425.2h,附加23.0h;客户流水¥156229.68,充值归因¥47000.00;头部客户(12月)Top3:罗先生、周周、轩哥。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_千千.csv b/etl_billiards/docs/table_2025-12-19/助教详情_千千.csv new file mode 100644 index 0000000..a3f43c0 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_千千.csv @@ -0,0 +1,55 @@ +助教详情:千千(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础77.2h,附加5.0h;客户流水¥34297.90,充值归因¥0.00;头部客户(12月)Top3:张先生、周先生、陈腾鑫。 + +一、基础课业绩 +说明:评价:基础77.2h,附加5.0h;客户流水¥34297.90,充值归因¥0.00;头部客户(12月)Top3:张先生、周先生、陈腾鑫。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,38.88h,14,-15.59h,0.00h +12月,38.28h,12,-6.53h,-2.81h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,5.00h,6,-5.27h,-3.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,24684.71,17,-19569.68,-8641.61 +12月,9613.19,16,-11117.34,-4027.46 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,张先生,10.0h / 0.0h,2622.05,0.00,4.4h / 0.0h,1623.68,0.00,0.0h / 0.0h,0.00,0.00 +2,周先生,8.6h / 0.0h,1577.94,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,陈腾鑫,4.8h / 0.0h,1375.20,0.00,2.9h / 0.0h,2418.94,0.00,0.0h / 0.0h,0.00,0.00 +4,梅,3.3h / 5.0h,1356.34,0.00,5.8h / 0.0h,2007.81,0.00,0.0h / 0.0h,0.00,0.00 +5,清,3.0h / 0.0h,1128.06,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +6,黄先生,6.2h / 0.0h,1048.14,0.00,6.8h / 0.0h,1251.92,0.00,0.0h / 0.0h,0.00,0.00 +7,游,2.2h / 0.0h,505.46,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +8,葛先生,0.0h / 0.0h,0.00,0.00,14.4h / 0.0h,7500.34,0.00,0.0h / 0.0h,0.00,0.00 +9,林先生,0.0h / 0.0h,0.00,0.00,2.7h / 0.0h,499.11,0.00,0.0h / 0.0h,0.00,0.00 +10,轩哥,0.0h / 0.0h,0.00,0.00,1.2h / 0.0h,415.60,0.00,0.0h / 0.0h,0.00,0.00 +11,蔡总,0.0h / 0.0h,0.00,0.00,0.7h / 0.0h,8967.31,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_千千.md b/etl_billiards/docs/table_2025-12-19/助教详情_千千.md new file mode 100644 index 0000000..07dd33f --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_千千.md @@ -0,0 +1,196 @@ +# 助教详情:千千(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础77.2h,附加5.0h;客户流水¥34297.90,充值归因¥0.00;头部客户(12月)Top3:张先生、周先生、陈腾鑫。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_周周.csv b/etl_billiards/docs/table_2025-12-19/助教详情_周周.csv new file mode 100644 index 0000000..c5ba351 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_周周.csv @@ -0,0 +1,66 @@ +助教详情:周周(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础172.6h,附加33.0h;客户流水¥80429.10,充值归因¥20000.00;头部客户(12月)Top3:周周、明哥、T。 + +一、基础课业绩 +说明:评价:基础172.6h,附加33.0h;客户流水¥80429.10,充值归因¥20000.00;头部客户(12月)Top3:周周、明哥、T。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,41.33h,9,-3.08h,4.81h +11月,71.27h,10,16.79h,32.38h +12月,60.02h,6,15.21h,18.92h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,25.00h,1,16.20h,18.00h +12月,8.00h,5,-2.27h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,27375.91,11,-1903.62,-3273.92 +11月,31436.74,15,-12817.65,-1889.58 +12月,21616.45,7,885.92,7975.80 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,11000.00,9,-3466.67,-2000.00 +11月,9000.00,4,1230.77,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,周周,28.8h / 8.0h,8105.19,0.00,0.0h / 20.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +2,明哥,0.4h / 0.0h,4190.45,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,T,10.1h / 0.0h,2968.13,0.00,0.0h / 0.0h,0.00,0.00,4.5h / 0.0h,1300.23,0.00 +4,大G,14.8h / 0.0h,2724.15,0.00,2.8h / 0.0h,1783.61,0.00,0.0h / 0.0h,0.00,0.00 +5,罗先生,2.6h / 0.0h,1584.22,0.00,9.0h / 0.0h,2415.09,0.00,0.0h / 0.0h,0.00,0.00 +6,游,2.4h / 0.0h,1307.16,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,张先生,0.0h / 0.0h,449.03,0.00,6.3h / 0.0h,2092.46,0.00,4.1h / 0.0h,1650.08,0.00 +8,轩哥,0.7h / 0.0h,288.12,0.00,5.0h / 3.0h,2175.94,5000.00,20.2h / 0.0h,16154.38,10000.00 +9,江先生,0.0h / 0.0h,0.00,0.00,4.3h / 0.0h,3588.88,0.00,0.0h / 0.0h,0.00,0.00 +10,罗超杰,0.0h / 0.0h,0.00,0.00,1.2h / 0.0h,255.63,0.00,0.0h / 0.0h,0.00,0.00 +11,陈腾鑫,0.0h / 0.0h,0.00,0.00,4.6h / 0.0h,1210.78,0.00,0.0h / 0.0h,197.60,1000.00 +12,林总,0.0h / 0.0h,0.00,0.00,2.2h / 0.0h,439.96,0.00,0.0h / 0.0h,0.00,0.00 +13,林先生,0.0h / 0.0h,0.00,0.00,0.7h / 0.0h,747.44,0.00,0.0h / 0.0h,0.00,0.00 +14,葛先生,0.0h / 0.0h,0.00,0.00,11.0h / 0.0h,3073.27,0.00,0.0h / 0.0h,0.00,0.00 +15,羊,0.0h / 0.0h,0.00,0.00,1.4h / 0.0h,602.50,0.00,0.0h / 0.0h,0.00,0.00 +16,小熊,0.0h / 0.0h,0.00,0.00,10.7h / 0.0h,2612.37,4000.00,0.0h / 0.0h,0.00,0.00 +17,陈德韩,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.3h / 0.0h,5714.01,0.00 +18,蔡总,0.0h / 0.0h,0.00,0.00,8.2h / 0.0h,9385.22,0.00,0.0h / 0.0h,0.00,0.00 +19,万先生,0.0h / 0.0h,0.00,0.00,0.0h / 2.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +20,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.7h / 0.0h,1080.89,0.00 +21,林先生,0.0h / 0.0h,0.00,0.00,4.0h / 0.0h,1053.59,0.00,0.0h / 0.0h,0.00,0.00 +22,陶,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.4h / 0.0h,1278.72,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_周周.md b/etl_billiards/docs/table_2025-12-19/助教详情_周周.md new file mode 100644 index 0000000..68ef417 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_周周.md @@ -0,0 +1,196 @@ +# 助教详情:周周(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础172.6h,附加33.0h;客户流水¥80429.10,充值归因¥20000.00;头部客户(12月)Top3:周周、明哥、T。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_奈千.csv b/etl_billiards/docs/table_2025-12-19/助教详情_奈千.csv new file mode 100644 index 0000000..55b648d --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_奈千.csv @@ -0,0 +1,58 @@ +助教详情:奈千(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础59.0h,附加0.0h;客户流水¥107484.78,充值归因¥18000.00;头部客户(12月)Top3:轩哥、黎先生、陈先生。 + +一、基础课业绩 +说明:评价:基础59.0h,附加0.0h;客户流水¥107484.78,充值归因¥18000.00;头部客户(12月)Top3:轩哥、黎先生、陈先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,23.62h,13,-20.79h,-12.91h +11月,32.83h,19,-21.64h,-6.05h +12月,2.58h,21,-42.23h,-38.51h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,38653.58,7,9374.05,8003.75 +11月,68543.08,7,24288.69,35216.76 +12月,288.12,22,-20442.41,-13352.52 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,18000.00,6,3533.33,5000.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,轩哥,0.7h / 0.0h,288.12,0.00,4.3h / 0.0h,14349.45,0.00,6.2h / 0.0h,22341.76,13000.00 +2,黎先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.7h / 0.0h,470.19,0.00 +3,陈先生,0.0h / 0.0h,0.00,0.00,5.1h / 0.0h,1150.83,0.00,0.0h / 0.0h,0.00,0.00 +4,羊,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.4h / 0.0h,3620.75,0.00 +5,陈腾鑫,0.0h / 0.0h,0.00,0.00,2.6h / 0.0h,441.39,0.00,2.1h / 0.0h,1084.69,1000.00 +6,张先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.2h / 0.0h,474.93,0.00 +7,夏,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,2595.54,1000.00 +8,罗超,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.5h / 0.0h,1013.51,0.00 +9,罗先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,1143.87,3000.00 +10,陈德韩,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.4h / 0.0h,5714.01,0.00 +11,阿亮,0.0h / 0.0h,0.00,0.00,2.9h / 0.0h,493.02,0.00,0.0h / 0.0h,0.00,0.00 +12,曾先生,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,206.16,0.00,0.0h / 0.0h,0.00,0.00 +13,蔡总,1.8h / 0.0h,0.00,0.00,17.0h / 0.0h,51902.23,0.00,0.0h / 0.0h,0.00,0.00 +14,T,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.2h / 0.0h,194.33,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_奈千.md b/etl_billiards/docs/table_2025-12-19/助教详情_奈千.md new file mode 100644 index 0000000..25e397f --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_奈千.md @@ -0,0 +1,196 @@ +# 助教详情:奈千(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础59.0h,附加0.0h;客户流水¥107484.78,充值归因¥18000.00;头部客户(12月)Top3:轩哥、黎先生、陈先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_姜姜.csv b/etl_billiards/docs/table_2025-12-19/助教详情_姜姜.csv new file mode 100644 index 0000000..c91de92 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_姜姜.csv @@ -0,0 +1,46 @@ +助教详情:姜姜(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础6.6h,附加3.0h;客户流水¥2333.94,充值归因¥4000.00;头部客户(12月)Top3:罗先生、汪先生。 + +一、基础课业绩 +说明:评价:基础6.6h,附加3.0h;客户流水¥2333.94,充值归因¥4000.00;头部客户(12月)Top3:罗先生、汪先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,6.60h,18,-37.81h,-29.93h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,3.00h,5,-3.57h,-5.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,2333.94,20,-26945.59,-28315.89 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,4000.00,12,-10466.67,-9000.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,罗先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.5h / 0.0h,1143.87,3000.00 +2,汪先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,5.1h / 3.0h,1190.07,1000.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_姜姜.md b/etl_billiards/docs/table_2025-12-19/助教详情_姜姜.md new file mode 100644 index 0000000..3b5c045 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_姜姜.md @@ -0,0 +1,196 @@ +# 助教详情:姜姜(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础6.6h,附加3.0h;客户流水¥2333.94,充值归因¥4000.00;头部客户(12月)Top3:罗先生、汪先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_婉婉.csv b/etl_billiards/docs/table_2025-12-19/助教详情_婉婉.csv new file mode 100644 index 0000000..ece6ca8 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_婉婉.csv @@ -0,0 +1,65 @@ +助教详情:婉婉(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础154.6h,附加34.0h;客户流水¥106476.37,充值归因¥33000.00;头部客户(12月)Top3:江先生、明哥、候。 + +一、基础课业绩 +说明:评价:基础154.6h,附加34.0h;客户流水¥106476.37,充值归因¥33000.00;头部客户(12月)Top3:江先生、明哥、候。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,90.68h,3,46.27h,54.16h +11月,46.03h,12,-8.44h,7.15h +12月,17.83h,17,-26.98h,-23.26h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,4.00h,4,-2.57h,-4.00h +11月,15.00h,2,6.20h,8.00h +12月,15.00h,3,4.73h,7.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,62187.64,2,32908.11,31537.81 +11月,33326.32,14,-10928.07,0.00 +12月,10962.41,15,-9768.12,-2678.24 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,24000.00,4,9533.33,11000.00 +11月,9000.00,4,1230.77,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,江先生,4.7h / 0.0h,5889.30,0.00,9.4h / 0.0h,6947.07,5000.00,2.7h / 0.0h,1538.09,0.00 +2,明哥,11.7h / 10.0h,4822.90,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,候,1.1h / 0.0h,195.75,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +4,刘哥,0.3h / 0.0h,54.46,0.00,5.0h / 0.0h,2982.34,0.00,0.0h / 0.0h,0.00,0.00 +5,林总,0.0h / 0.0h,0.00,0.00,0.9h / 0.0h,244.48,0.00,0.0h / 0.0h,0.00,0.00 +6,羊,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.3h / 0.0h,3620.75,0.00 +7,叶先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.7h / 0.0h,1278.01,0.00 +8,邓飛,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,925.47,1000.00,0.0h / 4.0h,0.00,0.00 +9,歌神,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,6.7h / 0.0h,4718.40,0.00 +10,夏,0.0h / 0.0h,0.00,0.00,5.4h / 4.0h,2991.13,0.00,18.2h / 0.0h,11826.32,1000.00 +11,轩哥,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,45.2h / 0.0h,33043.15,16000.00 +12,罗先生,0.0h / 5.0h,0.00,0.00,7.9h / 0.0h,2086.94,0.00,3.4h / 0.0h,1143.87,3000.00 +13,蔡总,0.0h / 0.0h,0.00,0.00,0.0h / 3.0h,6196.43,0.00,0.0h / 0.0h,0.00,0.00 +14,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.8h / 0.0h,3924.18,3000.00 +15,万先生,0.0h / 0.0h,0.00,0.00,0.0h / 4.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +16,老宋,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,465.98,0.00,0.0h / 0.0h,0.00,0.00 +17,黎先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,470.19,0.00 +18,君姐,0.0h / 0.0h,0.00,0.00,4.2h / 0.0h,1864.86,0.00,0.0h / 0.0h,0.00,0.00 +19,林先生,0.0h / 0.0h,0.00,0.00,6.0h / 4.0h,2690.52,0.00,0.0h / 0.0h,0.00,0.00 +20,婉婉,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,242.81,0.00,2.8h / 0.0h,624.68,1000.00 +21,胡先生,0.0h / 0.0h,0.00,0.00,3.8h / 0.0h,5688.29,3000.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_婉婉.md b/etl_billiards/docs/table_2025-12-19/助教详情_婉婉.md new file mode 100644 index 0000000..09efb40 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_婉婉.md @@ -0,0 +1,196 @@ +# 助教详情:婉婉(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础154.6h,附加34.0h;客户流水¥106476.37,充值归因¥33000.00;头部客户(12月)Top3:江先生、明哥、候。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小侯.csv b/etl_billiards/docs/table_2025-12-19/助教详情_小侯.csv new file mode 100644 index 0000000..27942ed --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小侯.csv @@ -0,0 +1,57 @@ +助教详情:小侯(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础92.2h,附加0.0h;客户流水¥41280.98,充值归因¥0.00;头部客户(12月)Top3:张先生、陈腾鑫、李先生。 + +一、基础课业绩 +说明:评价:基础92.2h,附加0.0h;客户流水¥41280.98,充值归因¥0.00;头部客户(12月)Top3:张先生、陈腾鑫、李先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,42.58h,13,-11.89h,3.70h +12月,49.57h,8,4.76h,8.47h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,27313.21,16,-16941.18,-6013.11 +12月,13967.77,11,-6762.76,327.12 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,张先生,14.4h / 0.0h,4161.57,0.00,5.8h / 0.0h,1583.86,0.00,0.0h / 0.0h,0.00,0.00 +2,陈腾鑫,12.1h / 0.0h,3984.45,0.00,7.0h / 0.0h,2965.62,0.00,0.0h / 0.0h,0.00,0.00 +3,李先生,9.3h / 0.0h,1729.57,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +4,梅,3.3h / 0.0h,1356.34,0.00,1.4h / 0.0h,1573.10,0.00,0.0h / 0.0h,0.00,0.00 +5,清,3.0h / 0.0h,1128.06,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +6,T,3.9h / 0.0h,938.16,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,候,3.4h / 0.0h,669.62,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +8,林志铭,0.0h / 0.0h,0.00,0.00,1.2h / 0.0h,220.15,0.00,0.0h / 0.0h,0.00,0.00 +9,林先生,0.0h / 0.0h,0.00,0.00,9.7h / 0.0h,3619.37,0.00,0.0h / 0.0h,0.00,0.00 +10,艾宇民,0.0h / 0.0h,0.00,0.00,7.6h / 0.0h,3872.24,0.00,0.0h / 0.0h,0.00,0.00 +11,蔡总,0.0h / 0.0h,0.00,0.00,2.6h / 0.0h,10419.30,0.00,0.0h / 0.0h,0.00,0.00 +12,钟智豪,0.0h / 0.0h,0.00,0.00,1.8h / 0.0h,274.34,0.00,0.0h / 0.0h,0.00,0.00 +13,李先生,0.0h / 0.0h,0.00,0.00,5.6h / 0.0h,2785.23,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小侯.md b/etl_billiards/docs/table_2025-12-19/助教详情_小侯.md new file mode 100644 index 0000000..b71681b --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小侯.md @@ -0,0 +1,196 @@ +# 助教详情:小侯(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础92.2h,附加0.0h;客户流水¥41280.98,充值归因¥0.00;头部客户(12月)Top3:张先生、陈腾鑫、李先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小敌.csv b/etl_billiards/docs/table_2025-12-19/助教详情_小敌.csv new file mode 100644 index 0000000..659a0d4 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小敌.csv @@ -0,0 +1,55 @@ +助教详情:小敌(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础135.1h,附加3.0h;客户流水¥93760.82,充值归因¥29000.00;头部客户(12月)Top3:郑先生、张先生、轩哥。 + +一、基础课业绩 +说明:评价:基础135.1h,附加3.0h;客户流水¥93760.82,充值归因¥29000.00;头部客户(12月)Top3:郑先生、张先生、轩哥。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,55.82h,7,11.41h,19.29h +11月,72.90h,9,18.42h,34.02h +12月,6.40h,20,-38.41h,-34.69h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,3.00h,5,-3.57h,-5.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,44510.29,4,15230.76,13860.46 +11月,47986.57,9,3732.18,14660.25 +12月,1263.96,20,-19466.57,-12376.68 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,21000.00,5,6533.33,8000.00 +11月,8000.00,5,230.77,-1000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,郑先生,4.9h / 0.0h,814.93,0.00,27.3h / 0.0h,4745.90,0.00,0.0h / 0.0h,0.00,0.00 +2,张先生,1.5h / 0.0h,449.03,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,轩哥,0.0h / 0.0h,0.00,0.00,11.2h / 0.0h,15962.93,0.00,43.1h / 0.0h,32148.10,21000.00 +4,游,0.0h / 0.0h,0.00,0.00,3.6h / 0.0h,3791.20,0.00,0.0h / 0.0h,0.00,0.00 +5,叶先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.1h / 0.0h,3922.17,0.00 +6,李先生,0.0h / 0.0h,0.00,0.00,11.8h / 0.0h,2997.53,3000.00,0.0h / 0.0h,0.00,0.00 +7,周先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,6.6h / 0.0h,2726.01,0.00 +8,陈德韩,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.0h / 0.0h,5714.01,0.00 +9,蔡总,0.0h / 0.0h,0.00,0.00,17.2h / 0.0h,19881.95,5000.00,0.0h / 0.0h,0.00,0.00 +10,林先生,0.0h / 0.0h,0.00,0.00,1.9h / 0.0h,607.06,0.00,0.0h / 0.0h,0.00,0.00 +11,邓飛,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 3.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小敌.md b/etl_billiards/docs/table_2025-12-19/助教详情_小敌.md new file mode 100644 index 0000000..e1078be --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小敌.md @@ -0,0 +1,196 @@ +# 助教详情:小敌(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础135.1h,附加3.0h;客户流水¥93760.82,充值归因¥29000.00;头部客户(12月)Top3:郑先生、张先生、轩哥。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小柔.csv b/etl_billiards/docs/table_2025-12-19/助教详情_小柔.csv new file mode 100644 index 0000000..384edcd --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小柔.csv @@ -0,0 +1,66 @@ +助教详情:小柔(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础207.4h,附加22.0h;客户流水¥209918.77,充值归因¥42700.00;头部客户(12月)Top3:蔡总、轩哥、明哥。 + +一、基础课业绩 +说明:评价:基础207.4h,附加22.0h;客户流水¥209918.77,充值归因¥42700.00;头部客户(12月)Top3:蔡总、轩哥、明哥。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,63.87h,5,19.46h,27.34h +11月,88.65h,6,34.17h,49.77h +12月,54.93h,7,10.12h,13.84h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,7.00h,4,-1.80h,0.00h +12月,15.00h,3,4.73h,7.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,52623.85,3,23344.32,21974.02 +11月,110137.94,3,65883.55,76811.62 +12月,47156.98,3,26426.45,33516.34 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,31700.00,2,17233.33,18700.00 +11月,11000.00,2,3230.77,2000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,蔡总,30.2h / 0.0h,25658.77,0.00,62.2h / 3.0h,88451.79,5000.00,0.0h / 0.0h,0.00,0.00 +2,轩哥,8.4h / 0.0h,13202.16,0.00,2.0h / 0.0h,4130.37,0.00,35.2h / 0.0h,33211.31,23000.00 +3,明哥,7.7h / 12.0h,4190.45,0.00,5.8h / 0.0h,2258.14,0.00,0.0h / 0.0h,0.00,0.00 +4,江先生,3.2h / 0.0h,3042.99,0.00,6.1h / 0.0h,7578.00,5000.00,2.7h / 0.0h,1538.09,0.00 +5,T,2.0h / 0.0h,434.21,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +6,昌哥,1.8h / 0.0h,318.40,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,陈腾鑫,1.6h / 0.0h,310.00,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +8,罗先生,0.0h / 0.0h,0.00,0.00,4.1h / 0.0h,722.25,0.00,0.0h / 0.0h,0.00,0.00 +9,邓飛,0.0h / 0.0h,0.00,0.00,1.2h / 0.0h,925.47,1000.00,0.0h / 0.0h,0.00,0.00 +10,吴生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.8h / 0.0h,685.89,0.00 +11,歌神,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.8h / 0.0h,2789.21,0.00 +12,羊,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,383.40,0.00,0.0h / 0.0h,0.00,0.00 +13,张先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.3h / 0.0h,568.87,0.00 +14,游,0.0h / 0.0h,0.00,0.00,2.2h / 0.0h,935.84,0.00,0.0h / 0.0h,0.00,0.00 +15,陈德韩,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.0h / 0.0h,5714.01,0.00 +16,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,7.5h / 0.0h,2750.70,3000.00 +17,罗先生,0.0h / 3.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +18,万先生,0.0h / 0.0h,0.00,0.00,0.0h / 4.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +19,林先生,0.0h / 0.0h,0.00,0.00,3.2h / 0.0h,2398.29,0.00,0.0h / 0.0h,0.00,0.00 +20,陶,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.7h / 0.0h,362.17,0.00 +21,叶总,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.8h / 0.0h,5003.60,5700.00 +22,胡先生,0.0h / 0.0h,0.00,0.00,0.1h / 0.0h,2354.39,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小柔.md b/etl_billiards/docs/table_2025-12-19/助教详情_小柔.md new file mode 100644 index 0000000..9247805 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小柔.md @@ -0,0 +1,196 @@ +# 助教详情:小柔(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础207.4h,附加22.0h;客户流水¥209918.77,充值归因¥42700.00;头部客户(12月)Top3:蔡总、轩哥、明哥。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小柳.csv b/etl_billiards/docs/table_2025-12-19/助教详情_小柳.csv new file mode 100644 index 0000000..0a44a03 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小柳.csv @@ -0,0 +1,44 @@ +助教详情:小柳(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础0.0h,附加0.0h;客户流水¥0.00,充值归因¥0.00;头部客户(12月)Top3:无。 + +一、基础课业绩 +说明:评价:基础0.0h,附加0.0h;客户流水¥0.00,充值归因¥0.00;头部客户(12月)Top3:无。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小柳.md b/etl_billiards/docs/table_2025-12-19/助教详情_小柳.md new file mode 100644 index 0000000..8872c16 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小柳.md @@ -0,0 +1,196 @@ +# 助教详情:小柳(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础0.0h,附加0.0h;客户流水¥0.00,充值归因¥0.00;头部客户(12月)Top3:无。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小燕.csv b/etl_billiards/docs/table_2025-12-19/助教详情_小燕.csv new file mode 100644 index 0000000..eca0b93 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小燕.csv @@ -0,0 +1,48 @@ +助教详情:小燕(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础268.3h,附加23.0h;客户流水¥77172.28,充值归因¥0.00;头部客户(12月)Top3:葛先生、小燕、梅。 + +一、基础课业绩 +说明:评价:基础268.3h,附加23.0h;客户流水¥77172.28,充值归因¥0.00;头部客户(12月)Top3:葛先生、小燕、梅。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,109.28h,3,54.81h,70.40h +12月,159.02h,1,114.21h,117.92h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,2.00h,6,-6.80h,-5.00h +12月,21.00h,2,10.73h,13.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,39426.42,12,-4827.97,6100.10 +12月,37745.86,5,17015.33,24105.22 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,葛先生,80.8h / 16.0h,22323.38,0.00,53.5h / 0.0h,17941.19,0.00,0.0h / 0.0h,0.00,0.00 +2,小燕,78.2h / 5.0h,15422.48,0.00,51.4h / 2.0h,10944.82,0.00,0.0h / 0.0h,0.00,0.00 +3,梅,0.0h / 0.0h,0.00,0.00,3.8h / 0.0h,1573.10,0.00,0.0h / 0.0h,0.00,0.00 +4,蔡总,0.0h / 0.0h,0.00,0.00,0.7h / 0.0h,8967.31,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_小燕.md b/etl_billiards/docs/table_2025-12-19/助教详情_小燕.md new file mode 100644 index 0000000..af5c276 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_小燕.md @@ -0,0 +1,196 @@ +# 助教详情:小燕(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础268.3h,附加23.0h;客户流水¥77172.28,充值归因¥0.00;头部客户(12月)Top3:葛先生、小燕、梅。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_希希.csv b/etl_billiards/docs/table_2025-12-19/助教详情_希希.csv new file mode 100644 index 0000000..bc73ed6 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_希希.csv @@ -0,0 +1,47 @@ +助教详情:希希(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础6.6h,附加0.0h;客户流水¥3367.56,充值归因¥0.00;头部客户(12月)Top3:陈腾鑫、歌神、郭先生。 + +一、基础课业绩 +说明:评价:基础6.6h,附加0.0h;客户流水¥3367.56,充值归因¥0.00;头部客户(12月)Top3:陈腾鑫、歌神、郭先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,5.02h,19,-39.39h,-31.51h +11月,1.58h,27,-52.89h,-37.30h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,3086.34,18,-26193.19,-27563.49 +11月,281.22,27,-43973.17,-33045.10 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,陈腾鑫,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,359.28,0.00 +2,歌神,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.0h / 0.0h,2727.06,0.00 +3,郭先生,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,281.22,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_希希.md b/etl_billiards/docs/table_2025-12-19/助教详情_希希.md new file mode 100644 index 0000000..7a68a55 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_希希.md @@ -0,0 +1,196 @@ +# 助教详情:希希(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础6.6h,附加0.0h;客户流水¥3367.56,充值归因¥0.00;头部客户(12月)Top3:陈腾鑫、歌神、郭先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_年糕.csv b/etl_billiards/docs/table_2025-12-19/助教详情_年糕.csv new file mode 100644 index 0000000..e37cfbe --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_年糕.csv @@ -0,0 +1,63 @@ +助教详情:年糕(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础82.6h,附加7.0h;客户流水¥59830.16,充值归因¥9000.00;头部客户(12月)Top3:葛先生、明哥、蔡总。 + +一、基础课业绩 +说明:评价:基础82.6h,附加7.0h;客户流水¥59830.16,充值归因¥9000.00;头部客户(12月)Top3:葛先生、明哥、蔡总。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,21.23h,14,-23.18h,-15.29h +11月,35.80h,15,-18.68h,-3.08h +12月,25.62h,14,-19.19h,-15.48h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,7.00h,4,-1.80h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,26289.89,12,-2989.64,-4359.94 +11月,15696.08,20,-28558.31,-17630.24 +12月,17844.19,9,-2886.34,4203.54 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,9000.00,10,-5466.67,-4000.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,葛先生,2.6h / 0.0h,5551.79,0.00,6.7h / 0.0h,3777.09,0.00,0.0h / 0.0h,0.00,0.00 +2,明哥,4.6h / 0.0h,4190.45,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,蔡总,6.0h / 0.0h,2130.39,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +4,叶先生,2.6h / 0.0h,1558.40,0.00,1.8h / 0.0h,711.79,0.00,5.3h / 0.0h,3607.02,3000.00 +5,君姐,2.2h / 0.0h,1414.23,0.00,6.0h / 0.0h,1864.86,0.00,0.0h / 0.0h,0.00,0.00 +6,林先生,0.9h / 0.0h,1369.51,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,张先生,2.5h / 0.0h,540.07,0.00,4.2h / 0.0h,1007.52,0.00,2.1h / 0.0h,596.24,0.00 +8,潘先生,2.0h / 0.0h,516.93,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +9,常总,1.7h / 0.0h,460.52,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +10,小燕,0.5h / 0.0h,111.90,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +11,谢俊,0.0h / 0.0h,0.00,0.00,4.5h / 0.0h,794.53,0.00,0.0h / 0.0h,0.00,0.00 +12,歌神,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.6h / 0.0h,1929.19,0.00 +13,夏,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.2h / 0.0h,4670.88,0.00 +14,轩哥,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,7.2h / 0.0h,12643.27,3000.00 +15,罗先生,0.0h / 0.0h,0.00,0.00,7.6h / 0.0h,2400.67,0.00,0.0h / 0.0h,0.00,0.00 +16,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.8h / 0.0h,2843.29,3000.00 +17,万先生,0.0h / 0.0h,0.00,0.00,0.0h / 7.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +18,胡先生,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,2354.39,0.00,0.0h / 0.0h,0.00,0.00 +19,李先生,0.0h / 0.0h,0.00,0.00,4.2h / 0.0h,2785.23,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_年糕.md b/etl_billiards/docs/table_2025-12-19/助教详情_年糕.md new file mode 100644 index 0000000..6d5df8b --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_年糕.md @@ -0,0 +1,196 @@ +# 助教详情:年糕(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础82.6h,附加7.0h;客户流水¥59830.16,充值归因¥9000.00;头部客户(12月)Top3:葛先生、明哥、蔡总。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_悦悦.csv b/etl_billiards/docs/table_2025-12-19/助教详情_悦悦.csv new file mode 100644 index 0000000..4787f35 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_悦悦.csv @@ -0,0 +1,46 @@ +助教详情:悦悦(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础2.3h,附加0.0h;客户流水¥2970.96,充值归因¥3000.00;头部客户(12月)Top3:小宇、轩哥。 + +一、基础课业绩 +说明:评价:基础2.3h,附加0.0h;客户流水¥2970.96,充值归因¥3000.00;头部客户(12月)Top3:小宇、轩哥。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,2.30h,20,-42.11h,-34.23h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,2970.96,19,-26308.57,-27678.87 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,3000.00,13,-11466.67,-10000.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,小宇,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.4h / 0.0h,78.52,0.00 +2,轩哥,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.9h / 0.0h,2892.44,3000.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_悦悦.md b/etl_billiards/docs/table_2025-12-19/助教详情_悦悦.md new file mode 100644 index 0000000..ef5b8b0 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_悦悦.md @@ -0,0 +1,196 @@ +# 助教详情:悦悦(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础2.3h,附加0.0h;客户流水¥2970.96,充值归因¥3000.00;头部客户(12月)Top3:小宇、轩哥。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_柚子.csv b/etl_billiards/docs/table_2025-12-19/助教详情_柚子.csv new file mode 100644 index 0000000..546d754 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_柚子.csv @@ -0,0 +1,56 @@ +助教详情:柚子(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础35.4h,附加10.0h;客户流水¥23234.98,充值归因¥3000.00;头部客户(12月)Top3:陈先生、羊、葛先生。 + +一、基础课业绩 +说明:评价:基础35.4h,附加10.0h;客户流水¥23234.98,充值归因¥3000.00;头部客户(12月)Top3:陈先生、羊、葛先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,35.40h,16,-19.08h,-3.48h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,10.00h,3,1.20h,3.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,23234.98,18,-21019.41,-10091.34 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,3000.00,8,-4769.23,-6000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,陈先生,0.0h / 0.0h,0.00,0.00,4.4h / 0.0h,1150.83,0.00,0.0h / 0.0h,0.00,0.00 +2,羊,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,429.33,0.00,0.0h / 0.0h,0.00,0.00 +3,葛先生,0.0h / 0.0h,0.00,0.00,3.4h / 0.0h,3052.28,0.00,0.0h / 0.0h,0.00,0.00 +4,陈腾鑫,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,472.03,0.00,0.0h / 0.0h,0.00,0.00 +5,张先生,0.0h / 0.0h,0.00,0.00,3.2h / 0.0h,941.37,0.00,0.0h / 0.0h,0.00,0.00 +6,夏,0.0h / 0.0h,0.00,0.00,0.2h / 7.0h,2991.13,0.00,0.0h / 0.0h,0.00,0.00 +7,轩哥,0.0h / 0.0h,0.00,0.00,0.2h / 0.0h,4484.68,0.00,0.0h / 0.0h,0.00,0.00 +8,胡先生,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,5688.29,3000.00,0.0h / 0.0h,0.00,0.00 +9,李先生,0.0h / 0.0h,0.00,0.00,7.8h / 0.0h,1712.78,0.00,0.0h / 0.0h,0.00,0.00 +10,牛先生,0.0h / 0.0h,0.00,0.00,10.4h / 0.0h,1887.48,0.00,0.0h / 0.0h,0.00,0.00 +11,阿亮,0.0h / 0.0h,0.00,0.00,2.4h / 0.0h,424.78,0.00,0.0h / 0.0h,0.00,0.00 +12,万先生,0.0h / 0.0h,0.00,0.00,0.0h / 3.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_柚子.md b/etl_billiards/docs/table_2025-12-19/助教详情_柚子.md new file mode 100644 index 0000000..15b80ae --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_柚子.md @@ -0,0 +1,196 @@ +# 助教详情:柚子(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础35.4h,附加10.0h;客户流水¥23234.98,充值归因¥3000.00;头部客户(12月)Top3:陈先生、羊、葛先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_梦梦.csv b/etl_billiards/docs/table_2025-12-19/助教详情_梦梦.csv new file mode 100644 index 0000000..068ad17 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_梦梦.csv @@ -0,0 +1,51 @@ +助教详情:梦梦(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础35.7h,附加1.0h;客户流水¥45991.89,充值归因¥0.00;头部客户(12月)Top3:葛先生、阿亮、蔡总。 + +一、基础课业绩 +说明:评价:基础35.7h,附加1.0h;客户流水¥45991.89,充值归因¥0.00;头部客户(12月)Top3:葛先生、阿亮、蔡总。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,19.60h,22,-34.88h,-19.28h +12月,16.08h,18,-28.73h,-25.01h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,1.00h,7,-7.80h,-6.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,39768.09,11,-4486.30,6441.77 +12月,6223.80,18,-14506.73,-7416.84 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,葛先生,2.9h / 0.0h,5551.79,0.00,4.4h / 0.0h,1884.66,0.00,0.0h / 0.0h,0.00,0.00 +2,阿亮,2.7h / 0.0h,495.46,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,蔡总,10.5h / 0.0h,176.55,0.00,7.0h / 1.0h,34274.25,0.00,0.0h / 0.0h,0.00,0.00 +4,轩哥,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,363.25,0.00,0.0h / 0.0h,0.00,0.00 +5,羊,0.0h / 0.0h,0.00,0.00,0.6h / 0.0h,104.77,0.00,0.0h / 0.0h,0.00,0.00 +6,林先生,0.0h / 0.0h,0.00,0.00,4.3h / 0.0h,2461.85,0.00,0.0h / 0.0h,0.00,0.00 +7,张先生,0.0h / 0.0h,0.00,0.00,2.3h / 0.0h,679.31,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_梦梦.md b/etl_billiards/docs/table_2025-12-19/助教详情_梦梦.md new file mode 100644 index 0000000..0068571 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_梦梦.md @@ -0,0 +1,196 @@ +# 助教详情:梦梦(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础35.7h,附加1.0h;客户流水¥45991.89,充值归因¥0.00;头部客户(12月)Top3:葛先生、阿亮、蔡总。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_楚楚.csv b/etl_billiards/docs/table_2025-12-19/助教详情_楚楚.csv new file mode 100644 index 0000000..6b3c3fa --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_楚楚.csv @@ -0,0 +1,44 @@ +助教详情:楚楚(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础0.0h,附加0.0h;客户流水¥0.00,充值归因¥0.00;头部客户(12月)Top3:无。 + +一、基础课业绩 +说明:评价:基础0.0h,附加0.0h;客户流水¥0.00,充值归因¥0.00;头部客户(12月)Top3:无。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_楚楚.md b/etl_billiards/docs/table_2025-12-19/助教详情_楚楚.md new file mode 100644 index 0000000..d1aacfc --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_楚楚.md @@ -0,0 +1,196 @@ +# 助教详情:楚楚(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础0.0h,附加0.0h;客户流水¥0.00,充值归因¥0.00;头部客户(12月)Top3:无。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_欣怡.csv b/etl_billiards/docs/table_2025-12-19/助教详情_欣怡.csv new file mode 100644 index 0000000..312e2b0 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_欣怡.csv @@ -0,0 +1,52 @@ +助教详情:欣怡(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础29.8h,附加0.0h;客户流水¥9981.98,充值归因¥7000.00;头部客户(12月)Top3:老宋、张先生、轩哥。 + +一、基础课业绩 +说明:评价:基础29.8h,附加0.0h;客户流水¥9981.98,充值归因¥7000.00;头部客户(12月)Top3:老宋、张先生、轩哥。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,19.50h,15,-24.91h,-17.03h +11月,10.33h,24,-44.14h,-28.55h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,4824.69,17,-24454.84,-25825.14 +11月,5157.29,24,-39097.10,-28169.03 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,3000.00,13,-11466.67,-10000.00 +11月,4000.00,7,-3769.23,-5000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,老宋,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.5h / 0.0h,75.99,0.00 +2,张先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.4h / 0.0h,570.76,0.00 +3,轩哥,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.4h / 0.0h,408.24,0.00 +4,羊,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,364.70,0.00,0.0h / 0.0h,0.00,0.00 +5,小熊,0.0h / 0.0h,0.00,0.00,7.8h / 0.0h,2438.20,4000.00,0.0h / 0.0h,0.00,0.00 +6,陶,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.9h / 0.0h,904.50,0.00 +7,T,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,10.3h / 0.0h,2865.20,3000.00 +8,胡先生,0.0h / 0.0h,0.00,0.00,0.4h / 0.0h,2354.39,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_欣怡.md b/etl_billiards/docs/table_2025-12-19/助教详情_欣怡.md new file mode 100644 index 0000000..55c8a47 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_欣怡.md @@ -0,0 +1,196 @@ +# 助教详情:欣怡(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础29.8h,附加0.0h;客户流水¥9981.98,充值归因¥7000.00;头部客户(12月)Top3:老宋、张先生、轩哥。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_泡芙.csv b/etl_billiards/docs/table_2025-12-19/助教详情_泡芙.csv new file mode 100644 index 0000000..29e1380 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_泡芙.csv @@ -0,0 +1,48 @@ +助教详情:泡芙(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础21.4h,附加3.0h;客户流水¥8323.03,充值归因¥0.00;头部客户(12月)Top3:夏、艾宇民、羊。 + +一、基础课业绩 +说明:评价:基础21.4h,附加3.0h;客户流水¥8323.03,充值归因¥0.00;头部客户(12月)Top3:夏、艾宇民、羊。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,21.38h,21,-33.09h,-17.50h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,3.00h,5,-5.80h,-4.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,8323.03,23,-35931.36,-25003.29 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,夏,0.0h / 0.0h,0.00,0.00,2.4h / 0.0h,501.87,0.00,0.0h / 0.0h,0.00,0.00 +2,艾宇民,0.0h / 0.0h,0.00,0.00,17.5h / 3.0h,5037.44,0.00,0.0h / 0.0h,0.00,0.00 +3,羊,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,429.33,0.00,0.0h / 0.0h,0.00,0.00 +4,胡先生,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,2354.39,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_泡芙.md b/etl_billiards/docs/table_2025-12-19/助教详情_泡芙.md new file mode 100644 index 0000000..f47a20a --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_泡芙.md @@ -0,0 +1,196 @@ +# 助教详情:泡芙(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础21.4h,附加3.0h;客户流水¥8323.03,充值归因¥0.00;头部客户(12月)Top3:夏、艾宇民、羊。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_涛涛.csv b/etl_billiards/docs/table_2025-12-19/助教详情_涛涛.csv new file mode 100644 index 0000000..e365ba9 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_涛涛.csv @@ -0,0 +1,60 @@ +助教详情:涛涛(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础168.6h,附加3.0h;客户流水¥166962.41,充值归因¥26000.00;头部客户(12月)Top3:蔡总、轩哥、葛先生。 + +一、基础课业绩 +说明:评价:基础168.6h,附加3.0h;客户流水¥166962.41,充值归因¥26000.00;头部客户(12月)Top3:蔡总、轩哥、葛先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,50.13h,8,5.72h,13.61h +11月,74.40h,7,19.92h,35.52h +12月,44.08h,10,-0.73h,2.99h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,3.00h,5,-5.80h,-4.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,35940.84,8,6661.31,5291.01 +11月,88677.55,4,44423.16,55351.23 +12月,42344.02,4,21613.49,28703.38 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,21000.00,5,6533.33,8000.00 +11月,5000.00,6,-2769.23,-4000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,蔡总,15.1h / 0.0h,18914.63,0.00,28.4h / 0.0h,57785.21,0.00,0.0h / 0.0h,0.00,0.00 +2,轩哥,10.8h / 0.0h,14490.25,0.00,21.8h / 0.0h,22186.78,0.00,40.1h / 0.0h,30415.18,15000.00 +3,葛先生,5.8h / 0.0h,5551.79,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +4,君姐,2.2h / 0.0h,1414.23,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +5,候,3.1h / 0.0h,563.70,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +6,阿亮,2.9h / 0.0h,538.07,0.00,1.7h / 3.0h,300.53,0.00,0.0h / 0.0h,0.00,0.00 +7,罗先生,2.3h / 0.0h,524.41,0.00,2.3h / 0.0h,844.76,0.00,6.0h / 0.0h,1773.80,3000.00 +8,张先生,1.9h / 0.0h,346.94,0.00,1.8h / 0.0h,325.27,0.00,0.0h / 0.0h,0.00,0.00 +9,江先生,0.0h / 0.0h,0.00,0.00,1.8h / 0.0h,2095.41,5000.00,0.0h / 0.0h,0.00,0.00 +10,叶先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.1h / 0.0h,3607.02,3000.00 +11,吴生,0.0h / 0.0h,0.00,0.00,2.2h / 0.0h,537.96,0.00,0.0h / 0.0h,0.00,0.00 +12,陈淑涛,0.0h / 0.0h,0.00,0.00,4.4h / 0.0h,1176.77,0.00,0.0h / 0.0h,0.00,0.00 +13,李先生,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,779.48,0.00,0.0h / 0.0h,0.00,0.00 +14,明哥,0.0h / 0.0h,0.00,0.00,5.4h / 0.0h,2258.14,0.00,0.0h / 0.0h,0.00,0.00 +15,冯先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.9h / 0.0h,144.84,0.00 +16,胡先生,0.0h / 0.0h,0.00,0.00,2.7h / 0.0h,387.24,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_涛涛.md b/etl_billiards/docs/table_2025-12-19/助教详情_涛涛.md new file mode 100644 index 0000000..cc57443 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_涛涛.md @@ -0,0 +1,196 @@ +# 助教详情:涛涛(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础168.6h,附加3.0h;客户流水¥166962.41,充值归因¥26000.00;头部客户(12月)Top3:蔡总、轩哥、葛先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_球球.csv b/etl_billiards/docs/table_2025-12-19/助教详情_球球.csv new file mode 100644 index 0000000..ae1ce76 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_球球.csv @@ -0,0 +1,74 @@ +助教详情:球球(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础172.5h,附加41.0h;客户流水¥95647.82,充值归因¥24000.00;头部客户(12月)Top3:葛先生、周周、T。 + +一、基础课业绩 +说明:评价:基础172.5h,附加41.0h;客户流水¥95647.82,充值归因¥24000.00;头部客户(12月)Top3:葛先生、周周、T。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,57.45h,6,13.04h,20.92h +11月,66.50h,11,12.02h,27.62h +12月,48.58h,9,3.77h,7.49h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,11.00h,1,4.43h,3.00h +11月,25.00h,1,16.20h,18.00h +12月,5.00h,6,-5.27h,-3.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,33923.75,10,4644.22,3273.92 +11月,41907.39,10,-2347.00,8581.07 +12月,19816.68,8,-913.85,6176.04 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,15000.00,8,533.33,2000.00 +11月,9000.00,4,1230.77,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,葛先生,1.1h / 0.0h,5551.79,0.00,0.9h / 0.0h,3052.28,0.00,0.0h / 0.0h,0.00,0.00 +2,周周,15.2h / 0.0h,4161.75,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,T,10.7h / 0.0h,3327.26,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +4,蔡总,8.5h / 0.0h,2130.39,0.00,3.4h / 0.0h,6196.43,0.00,0.0h / 0.0h,0.00,0.00 +5,罗先生,2.3h / 0.0h,1584.22,0.00,0.0h / 0.0h,0.00,0.00,1.4h / 0.0h,514.68,0.00 +6,候,5.3h / 0.0h,926.59,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,张先生,3.0h / 0.0h,876.46,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +8,李先生,0.0h / 0.0h,703.83,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +9,大G,1.8h / 0.0h,467.10,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +10,黄先生,0.6h / 5.0h,87.29,0.00,4.4h / 18.0h,828.62,0.00,11.0h / 9.0h,1608.13,0.00 +11,邓飛,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,925.47,1000.00,0.0h / 0.0h,0.00,0.00 +12,罗超杰,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,176.14,0.00 +13,陈腾鑫,0.0h / 0.0h,0.00,0.00,25.1h / 0.0h,5150.83,0.00,6.1h / 2.0h,980.14,0.00 +14,叶先生,0.0h / 0.0h,0.00,0.00,6.7h / 0.0h,3013.86,0.00,0.0h / 0.0h,0.00,0.00 +15,歌神,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.6h / 0.0h,1929.19,0.00 +16,羊,0.0h / 0.0h,0.00,0.00,2.2h / 0.0h,383.40,0.00,3.1h / 0.0h,3620.75,0.00 +17,夏,0.0h / 0.0h,0.00,0.00,2.0h / 4.0h,370.12,0.00,2.5h / 0.0h,455.12,0.00 +18,轩哥,0.0h / 0.0h,0.00,0.00,11.2h / 0.0h,12063.93,5000.00,9.1h / 0.0h,9207.48,6000.00 +19,罗超,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.9h / 0.0h,2385.30,3000.00 +20,小熊,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.4h / 0.0h,709.18,0.00 +21,陈德韩,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.8h / 0.0h,5714.01,0.00 +22,刘哥,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,2982.34,0.00,0.0h / 0.0h,0.00,0.00 +23,桂先生,0.0h / 0.0h,0.00,0.00,2.6h / 0.0h,826.02,0.00,0.0h / 0.0h,0.00,0.00 +24,万先生,0.0h / 0.0h,0.00,0.00,0.0h / 3.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +25,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.8h / 0.0h,2843.29,3000.00 +26,胡先生,0.0h / 0.0h,0.00,0.00,3.9h / 0.0h,5688.29,3000.00,0.0h / 0.0h,0.00,0.00 +27,江先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.7h / 0.0h,2740.35,3000.00 +28,小宇,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.5h / 0.0h,318.98,0.00 +29,陶,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.7h / 0.0h,721.01,0.00 +30,小燕,0.0h / 0.0h,0.00,0.00,1.4h / 0.0h,425.80,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_球球.md b/etl_billiards/docs/table_2025-12-19/助教详情_球球.md new file mode 100644 index 0000000..de1e3ee --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_球球.md @@ -0,0 +1,196 @@ +# 助教详情:球球(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础172.5h,附加41.0h;客户流水¥95647.82,充值归因¥24000.00;头部客户(12月)Top3:葛先生、周周、T。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_瑶瑶.csv b/etl_billiards/docs/table_2025-12-19/助教详情_瑶瑶.csv new file mode 100644 index 0000000..aef2879 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_瑶瑶.csv @@ -0,0 +1,49 @@ +助教详情:瑶瑶(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础53.7h,附加3.0h;客户流水¥72216.91,充值归因¥0.00;头部客户(12月)Top3:蔡总、轩哥、陈世。 + +一、基础课业绩 +说明:评价:基础53.7h,附加3.0h;客户流水¥72216.91,充值归因¥0.00;头部客户(12月)Top3:蔡总、轩哥、陈世。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,34.25h,18,-20.23h,-4.63h +12月,19.48h,15,-25.33h,-21.61h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,3.00h,5,-5.80h,-4.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,65924.36,8,21669.97,32598.04 +12月,6292.55,17,-14437.98,-7348.10 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,蔡总,19.5h / 0.0h,6292.55,0.00,20.3h / 2.0h,53735.58,0.00,0.0h / 0.0h,0.00,0.00 +2,轩哥,0.0h / 0.0h,0.00,0.00,9.3h / 1.0h,8137.22,0.00,0.0h / 0.0h,0.00,0.00 +3,陈世,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,139.15,0.00,0.0h / 0.0h,0.00,0.00 +4,游,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,3544.42,0.00,0.0h / 0.0h,0.00,0.00 +5,林先生,0.0h / 0.0h,0.00,0.00,1.9h / 0.0h,367.99,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_瑶瑶.md b/etl_billiards/docs/table_2025-12-19/助教详情_瑶瑶.md new file mode 100644 index 0000000..5a7271b --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_瑶瑶.md @@ -0,0 +1,196 @@ +# 助教详情:瑶瑶(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础53.7h,附加3.0h;客户流水¥72216.91,充值归因¥0.00;头部客户(12月)Top3:蔡总、轩哥、陈世。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_璇子.csv b/etl_billiards/docs/table_2025-12-19/助教详情_璇子.csv new file mode 100644 index 0000000..cead2b0 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_璇子.csv @@ -0,0 +1,56 @@ +助教详情:璇子(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础358.9h,附加32.0h;客户流水¥301070.23,充值归因¥44700.00;头部客户(12月)Top3:轩哥、蔡总、江先生。 + +一、基础课业绩 +说明:评价:基础358.9h,附加32.0h;客户流水¥301070.23,充值归因¥44700.00;头部客户(12月)Top3:轩哥、蔡总、江先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,120.20h,2,75.79h,83.67h +11月,147.92h,2,93.44h,109.03h +12月,90.75h,3,45.94h,49.66h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,8.00h,3,1.43h,0.00h +11月,10.00h,3,1.20h,3.00h +12月,14.00h,4,3.73h,6.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,80804.14,1,51524.61,50154.31 +11月,154486.83,1,110232.44,121160.51 +12月,65779.26,1,45048.73,52138.62 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,34700.00,1,20233.33,21700.00 +11月,10000.00,3,2230.77,1000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,轩哥,37.7h / 0.0h,28122.15,0.00,50.3h / 0.0h,46514.85,0.00,64.6h / 0.0h,53866.23,23000.00 +2,蔡总,18.3h / 0.0h,21216.97,0.00,49.9h / 0.0h,84757.28,5000.00,0.0h / 0.0h,0.00,0.00 +3,江先生,17.7h / 14.0h,10018.73,0.00,29.6h / 10.0h,14700.83,5000.00,15.2h / 8.0h,5637.50,3000.00 +4,林先生,9.6h / 0.0h,3351.61,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +5,罗先生,5.2h / 0.0h,1655.57,0.00,5.1h / 0.0h,1718.68,0.00,2.8h / 0.0h,1087.21,0.00 +6,君姐,2.2h / 0.0h,1414.23,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,游,0.0h / 0.0h,0.00,0.00,3.1h / 0.0h,3544.42,0.00,0.0h / 0.0h,0.00,0.00 +8,羊,0.0h / 0.0h,0.00,0.00,5.0h / 0.0h,1017.12,0.00,0.0h / 0.0h,0.00,0.00 +9,张先生,0.0h / 0.0h,0.00,0.00,4.9h / 0.0h,2233.65,0.00,5.4h / 0.0h,3211.91,0.00 +10,夏,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,7.6h / 0.0h,6452.71,0.00 +11,罗超,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.6h / 0.0h,2385.30,3000.00 +12,叶总,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,22.0h / 0.0h,8163.28,5700.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_璇子.md b/etl_billiards/docs/table_2025-12-19/助教详情_璇子.md new file mode 100644 index 0000000..30e4c11 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_璇子.md @@ -0,0 +1,196 @@ +# 助教详情:璇子(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础358.9h,附加32.0h;客户流水¥301070.23,充值归因¥44700.00;头部客户(12月)Top3:轩哥、蔡总、江先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_素素.csv b/etl_billiards/docs/table_2025-12-19/助教详情_素素.csv new file mode 100644 index 0000000..ce40957 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_素素.csv @@ -0,0 +1,64 @@ +助教详情:素素(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础85.5h,附加10.0h;客户流水¥55755.41,充值归因¥7000.00;头部客户(12月)Top3:叶先生、周先生、轩哥。 + +一、基础课业绩 +说明:评价:基础85.5h,附加10.0h;客户流水¥55755.41,充值归因¥7000.00;头部客户(12月)Top3:叶先生、周先生、轩哥。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,40.45h,10,-3.96h,3.92h +11月,35.03h,17,-19.44h,-3.85h +12月,9.98h,19,-34.83h,-31.11h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,10.00h,3,1.20h,3.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,34135.89,9,4856.36,3486.06 +11月,18707.30,19,-25547.09,-14619.02 +12月,2912.22,19,-17818.31,-10728.42 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,6000.00,11,-8466.67,-7000.00 +11月,1000.00,9,-6769.23,-8000.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,叶先生,3.0h / 0.0h,1558.40,0.00,7.0h / 3.0h,3725.65,0.00,15.1h / 0.0h,8807.20,3000.00 +2,周先生,6.2h / 0.0h,1065.70,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,轩哥,0.8h / 0.0h,288.12,0.00,0.0h / 0.0h,0.00,0.00,3.6h / 0.0h,8972.50,0.00 +4,邓飛,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,925.47,1000.00,0.0h / 0.0h,0.00,0.00 +5,罗超杰,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.9h / 0.0h,396.94,0.00 +6,陈腾鑫,0.0h / 0.0h,0.00,0.00,0.4h / 0.0h,152.18,0.00,2.1h / 0.0h,542.06,0.00 +7,谢俊,0.0h / 0.0h,0.00,0.00,2.4h / 0.0h,417.75,0.00,0.0h / 0.0h,0.00,0.00 +8,罗先生,0.0h / 0.0h,0.00,0.00,4.7h / 0.0h,861.57,0.00,0.0h / 0.0h,0.00,0.00 +9,葛先生,0.0h / 0.0h,0.00,0.00,9.1h / 0.0h,4668.26,0.00,0.0h / 0.0h,0.00,0.00 +10,歌神,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,3.9h / 0.0h,2789.21,0.00 +11,羊,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,4.3h / 0.0h,3620.75,0.00 +12,张先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,1.1h / 0.0h,449.93,0.00 +13,都先生,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,269.64,0.00,0.0h / 0.0h,0.00,0.00 +14,夏,0.0h / 0.0h,0.00,0.00,0.8h / 4.0h,2991.13,0.00,0.0h / 0.0h,0.00,0.00 +15,罗先生,0.0h / 0.0h,0.00,0.00,4.3h / 0.0h,1856.86,0.00,0.0h / 0.0h,0.00,0.00 +16,陈德韩,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,5.5h / 0.0h,5714.01,0.00 +17,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.8h / 0.0h,2843.29,3000.00 +18,万先生,0.0h / 0.0h,0.00,0.00,0.0h / 3.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +19,阿亮,0.0h / 0.0h,0.00,0.00,3.1h / 0.0h,484.40,0.00,0.0h / 0.0h,0.00,0.00 +20,胡先生,0.0h / 0.0h,0.00,0.00,0.8h / 0.0h,2354.39,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_素素.md b/etl_billiards/docs/table_2025-12-19/助教详情_素素.md new file mode 100644 index 0000000..5480ae0 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_素素.md @@ -0,0 +1,196 @@ +# 助教详情:素素(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础85.5h,附加10.0h;客户流水¥55755.41,充值归因¥7000.00;头部客户(12月)Top3:叶先生、周先生、轩哥。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_苏苏.csv b/etl_billiards/docs/table_2025-12-19/助教详情_苏苏.csv new file mode 100644 index 0000000..07c011a --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_苏苏.csv @@ -0,0 +1,58 @@ +助教详情:苏苏(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础82.6h,附加9.0h;客户流水¥33952.79,充值归因¥6000.00;头部客户(12月)Top3:罗先生、T、林先生。 + +一、基础课业绩 +说明:评价:基础82.6h,附加9.0h;客户流水¥33952.79,充值归因¥6000.00;头部客户(12月)Top3:罗先生、T、林先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,25.15h,12,-19.26h,-11.38h +11月,13.52h,23,-40.96h,-25.37h +12月,43.90h,11,-0.91h,2.81h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,8.00h,3,1.43h,0.00h +11月,0.00h,,0.00h,0.00h +12月,1.00h,9,-9.27h,-7.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,11236.84,15,-18042.69,-19412.99 +11月,10254.59,22,-33999.80,-23071.73 +12月,12461.36,14,-8269.17,-1179.28 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,6000.00,11,-8466.67,-7000.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,罗先生,19.6h / 0.0h,6061.87,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +2,T,4.5h / 0.0h,1429.89,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +3,林先生,4.9h / 0.0h,1369.86,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +4,黄先生,8.1h / 1.0h,1357.90,0.00,4.0h / 0.0h,674.36,0.00,11.2h / 4.0h,1960.96,0.00 +5,张先生,3.9h / 0.0h,1163.40,0.00,0.0h / 0.0h,0.00,0.00,6.4h / 0.0h,3604.02,0.00 +6,葛先生,2.5h / 0.0h,1004.77,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,候,0.4h / 0.0h,73.67,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +8,罗超,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.6h / 0.0h,2385.30,3000.00 +9,罗超杰,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.1h / 0.0h,443.27,0.00 +10,蔡总,0.0h / 0.0h,0.00,0.00,4.7h / 0.0h,6557.92,0.00,0.0h / 0.0h,0.00,0.00 +11,吕先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,2.8h / 0.0h,2843.29,3000.00 +12,林先生,0.0h / 0.0h,0.00,0.00,3.1h / 0.0h,2398.29,0.00,0.0h / 0.0h,0.00,0.00 +13,邓飛,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 4.0h,0.00,0.00 +14,昌哥,0.0h / 0.0h,0.00,0.00,1.6h / 0.0h,624.02,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_苏苏.md b/etl_billiards/docs/table_2025-12-19/助教详情_苏苏.md new file mode 100644 index 0000000..3d21174 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_苏苏.md @@ -0,0 +1,196 @@ +# 助教详情:苏苏(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础82.6h,附加9.0h;客户流水¥33952.79,充值归因¥6000.00;头部客户(12月)Top3:罗先生、T、林先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_西子.csv b/etl_billiards/docs/table_2025-12-19/助教详情_西子.csv new file mode 100644 index 0000000..2e02cd3 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_西子.csv @@ -0,0 +1,45 @@ +助教详情:西子(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础1.8h,附加0.0h;客户流水¥303.51,充值归因¥0.00;头部客户(12月)Top3:张先生。 + +一、基础课业绩 +说明:评价:基础1.8h,附加0.0h;客户流水¥303.51,充值归因¥0.00;头部客户(12月)Top3:张先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,1.82h,26,-52.66h,-37.07h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,303.51,26,-43950.88,-33022.81 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,张先生,0.0h / 0.0h,0.00,0.00,1.8h / 0.0h,303.51,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_西子.md b/etl_billiards/docs/table_2025-12-19/助教详情_西子.md new file mode 100644 index 0000000..43e0809 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_西子.md @@ -0,0 +1,196 @@ +# 助教详情:西子(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础1.8h,附加0.0h;客户流水¥303.51,充值归因¥0.00;头部客户(12月)Top3:张先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_阿清.csv b/etl_billiards/docs/table_2025-12-19/助教详情_阿清.csv new file mode 100644 index 0000000..3c1cd86 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_阿清.csv @@ -0,0 +1,55 @@ +助教详情:阿清(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础139.9h,附加0.0h;客户流水¥53805.04,充值归因¥0.00;头部客户(12月)Top3:陈腾鑫、葛先生、梅。 + +一、基础课业绩 +说明:评价:基础139.9h,附加0.0h;客户流水¥53805.04,充值归因¥0.00;头部客户(12月)Top3:陈腾鑫、葛先生、梅。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,73.48h,8,19.01h,34.60h +12月,66.45h,5,21.64h,25.36h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,37302.04,13,-6952.35,3975.72 +12月,16503.00,10,-4227.53,2862.36 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,陈腾鑫,34.9h / 0.0h,7428.12,0.00,44.4h / 0.0h,10029.58,0.00,0.0h / 0.0h,0.00,0.00 +2,葛先生,7.9h / 0.0h,2831.40,0.00,19.7h / 0.0h,7141.04,0.00,0.0h / 0.0h,0.00,0.00 +3,梅,7.5h / 0.0h,2086.99,0.00,3.3h / 0.0h,1573.10,0.00,0.0h / 0.0h,0.00,0.00 +4,张先生,7.2h / 0.0h,1794.40,0.00,1.0h / 0.0h,679.31,0.00,0.0h / 0.0h,0.00,0.00 +5,清,3.0h / 0.0h,1128.06,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +6,候,4.2h / 0.0h,773.51,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +7,常总,1.7h / 0.0h,460.52,0.00,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00 +8,轩哥,0.0h / 0.0h,0.00,0.00,1.0h / 0.0h,10078.85,0.00,0.0h / 0.0h,0.00,0.00 +9,黄先生,0.0h / 0.0h,0.00,0.00,0.7h / 0.0h,382.08,0.00,0.0h / 0.0h,0.00,0.00 +10,蔡总,0.0h / 0.0h,0.00,0.00,1.5h / 0.0h,6557.92,0.00,0.0h / 0.0h,0.00,0.00 +11,小燕,0.0h / 0.0h,0.00,0.00,2.0h / 0.0h,860.16,0.00,0.0h / 0.0h,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_阿清.md b/etl_billiards/docs/table_2025-12-19/助教详情_阿清.md new file mode 100644 index 0000000..0c79f34 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_阿清.md @@ -0,0 +1,196 @@ +# 助教详情:阿清(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础139.9h,附加0.0h;客户流水¥53805.04,充值归因¥0.00;头部客户(12月)Top3:陈腾鑫、葛先生、梅。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_饭团.csv b/etl_billiards/docs/table_2025-12-19/助教详情_饭团.csv new file mode 100644 index 0000000..4a01a9e --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_饭团.csv @@ -0,0 +1,46 @@ +助教详情:饭团(2025年10-12月) +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。评价:基础16.0h,附加0.0h;客户流水¥7955.28,充值归因¥0.00;头部客户(12月)Top3:轩哥、张先生。 + +一、基础课业绩 +说明:评价:基础16.0h,附加0.0h;客户流水¥7955.28,充值归因¥0.00;头部客户(12月)Top3:轩哥、张先生。 + +月份,基础课业绩,基础课业绩,基础课业绩,基础课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,16.00h,16,-28.41h,-20.53h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +二、附加课业绩 +说明:附加课=order_assistant_type=2。 + +月份,附加课业绩,附加课业绩,附加课业绩,附加课业绩 +月份,小时数,排名,平均值差值小时数,中位数值差值小时数 +10月,0.00h,,0.00h,0.00h +11月,0.00h,,0.00h,0.00h +12月,0.00h,,0.00h,0.00h + +三、客户消费业绩 +说明:订单台费+助教+商品应付金额全额计入订单内助教。 + +月份,客户消费业绩,客户消费业绩,客户消费业绩,客户消费业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,7955.28,16,-21324.25,-22694.55 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +四、客户充值业绩 +说明:充值命中消费窗口±30分钟且有助教则归因;全额复制。 + +月份,客户充值业绩,客户充值业绩,客户充值业绩,客户充值业绩 +月份,合计元,排名,平均值差值元,中位数值差值元 +10月,0.00,,0.00,0.00 +11月,0.00,,0.00,0.00 +12月,0.00,,0.00,0.00 + +五、头部客户(按12月消费业绩排序,Top100) +说明:基础/附加课时=基础h/附加h。 + +排名,客户名称,12月,12月,12月,11月,11月,11月,10月,10月,10月 +排名,客户名称,基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元),基础/附加课时,消费业绩(元),客户充值(元) +1,轩哥,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,6.9h / 0.0h,5089.50,0.00 +2,张先生,0.0h / 0.0h,0.00,0.00,0.0h / 0.0h,0.00,0.00,9.1h / 0.0h,2865.78,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/助教详情_饭团.md b/etl_billiards/docs/table_2025-12-19/助教详情_饭团.md new file mode 100644 index 0000000..e47ee2e --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/助教详情_饭团.md @@ -0,0 +1,196 @@ +# 助教详情:饭团(2025年10-12月) +## 思考过程 +按模板拆分5部分输出;月度排名采用dense_rank;均值/中位数在当月该指标>0助教集合上计算。 + +## 查询说明 +本表包含5个部分:基础课业绩、附加课业绩、客户消费业绩、客户充值业绩、头部客户情况。均值/中位数差值对比集合为当月该指标>0的助教。充值/客户流水多助教与多订单命中均按全额复制计入,故汇总可能大于门店总额。 +评价:基础16.0h,附加0.0h;客户流水¥7955.28,充值归因¥0.00;头部客户(12月)Top3:轩哥、张先生。 + +## SQL + +### 服务时长(助教-客户-月份) +```sql +with raw as ( + select + asl.nickname as assistant, + asl.tenant_member_id as member_id, + case when asl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when asl.start_use_time >= '2025-11-01 00:00:00+08'::timestamptz and asl.start_use_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + asl.order_assistant_type, + asl.income_seconds + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + and asl.tenant_member_id is not null and asl.tenant_member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(case when order_assistant_type=1 then income_seconds else 0 end)/3600.0 as base_hours, + sum(case when order_assistant_type=2 then income_seconds else 0 end)/3600.0 as extra_hours +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 客户流水(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_amount as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + bo.order_start_time, + bo.order_end_time, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_amount a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +raw as ( + select + ao.assistant, + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + join assistant_orders ao on ao.order_settle_id=o.order_settle_id + where o.member_id is not null and o.member_id<>0 +) +select + assistant, + member_id, + month_key, + sum(order_amount) as revenue_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` + +### 充值归因(助教-客户-月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as table_start_time, + max(tfl.ledger_end_time) as table_end_time + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +assistant_time as ( + select + asl.order_settle_id, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id=asl.order_settle_id + where asl.site_id=%(site_id)s and coalesce(asl.is_delete,0)=0 + group by asl.order_settle_id +), +order_windows as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.table_start_time, coalesce(at.assistant_start_time, bo.table_start_time)) as win_start, + greatest(bo.table_end_time, coalesce(at.assistant_end_time, bo.table_end_time)) as win_end + from base_orders bo + left join assistant_time at on at.order_settle_id=bo.order_settle_id + where bo.member_id is not null and bo.member_id<>0 +), +assistant_orders as ( + select distinct order_settle_id, nickname as assistant + from billiards_dwd.dwd_assistant_service_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz + and start_use_time < %(window_end)s::timestamptz +), +recharge_pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id=p.relate_id + where p.site_id=%(site_id)s + and p.relate_type=5 + and p.pay_status=2 + and p.pay_amount>0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +), +matched as ( + select + rp.pay_time, + ow.order_settle_id, + ow.member_id, + rp.pay_amount + from recharge_pay rp + join order_windows ow + on ow.member_id=rp.member_id + and rp.pay_time >= ow.win_start - interval '30 minutes' + and rp.pay_time <= ow.win_end + interval '30 minutes' +), +raw as ( + select + ao.assistant, + m.member_id, + case when m.pay_time >= '2025-10-01 00:00:00+08'::timestamptz and m.pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when m.pay_time >= '2025-11-01 00:00:00+08'::timestamptz and m.pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when m.pay_time >= '2025-12-01 00:00:00+08'::timestamptz and m.pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + m.pay_amount + from matched m + join assistant_orders ao on ao.order_settle_id=m.order_settle_id +) +select + assistant, + member_id, + month_key, + sum(pay_amount) as recharge_amount +from raw +where month_key is not null +group by assistant, member_id, month_key; +``` diff --git a/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_分表.csv b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_分表.csv new file mode 100644 index 0000000..d0a15d3 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_分表.csv @@ -0,0 +1,105 @@ +2025年10-12月 客户消费能力Top100(分表) +与总表同口径;分表仅计算12月喜爱助教Top5,并展示10-12月各月消费/充值。 + +排名,客户名称,电话号码,12月,12月,12月,11月,11月,10月,10月 +排名,客户名称,电话号码,喜爱助教昵称,消费(元),充值(元),消费(元),充值(元),消费(元),充值(元) + 1,轩哥,188****7530,七七(65.8h)、璇子(37.7h)、涛涛(10.8h)、Amy(8.8h)、小柔(8.4h),29170.33,0.00,51318.11,13000.00,72647.61,53000.00 + 2,蔡总,159****8893,小柔(30.2h)、七七(26.7h)、瑶瑶(19.5h)、璇子(18.3h)、涛涛(15.1h),34327.09,0.00,115512.37,5000.00,0.00,0.00 + 3,葛先生,138****8071,小燕(104.8h)、阿清(7.9h)、涛涛(5.9h)、梦梦(2.9h)、年糕(2.6h),23237.78,0.00,24991.55,0.00,0.00,0.00 + 4,罗先生,139****6996,佳怡(73.8h)、苏苏(19.6h)、璇子(5.3h)、周周(2.6h)、球球(2.3h),17515.75,0.00,16177.44,0.00,12880.69,10000.00 + 5,陈腾鑫,178****8218,阿清(34.9h)、小侯(12.1h)、千千(4.9h)、小柔(1.7h),10701.83,0.00,19428.27,1000.00,9945.66,8000.00 + 6,江先生,188****4838,璇子(38.7h)、婉婉(4.7h)、小柔(3.2h)、七七(3.2h)、Amy(2.2h),10050.26,0.00,14700.83,5000.00,5721.32,6000.00 + 7,小燕,178****1334,小燕(85.7h)、Amy(1.0h)、年糕(0.5h),17306.66,0.00,12582.23,0.00,0.00,0.00 + 8,张先生,139****8852,小侯(14.5h)、千千(10.0h)、阿清(7.2h)、苏苏(3.9h)、乔西(3.3h),7533.67,0.00,8692.62,0.00,9878.04,0.00 + 9,曾巧明,186****1488,,6331.51,0.00,8821.59,0.00,9677.06,0.00 + 10,黄生,136****9719,,4181.24,0.00,6353.05,0.00,7871.94,0.00 + 11,夏,191****2851,,0.00,0.00,4318.15,6000.00,12281.44,11000.00 + 12,叶先生,138****9539,素素(3.1h)、年糕(2.6h),1658.40,0.00,3725.65,0.00,9107.20,6307.00 + 13,T,180****9962,球球(10.7h)、周周(10.1h)、乔西(6.9h)、苏苏(4.5h)、小侯(3.9h),6777.66,0.00,2490.70,0.00,4382.70,3000.00 + 14,林先生,133****1070,七七(14.0h)、璇子(9.6h)、苏苏(4.9h)、乔西(4.6h)、佳怡(4.5h),9468.05,0.00,3469.81,0.00,0.00,0.00 + 15,曾丹烨,139****3242,,2485.41,0.00,5168.56,0.00,4702.36,3000.00 + 16,胡先生,186****3391,佳怡(3.0h),522.45,0.00,10898.42,8000.00,0.00,0.00 + 17,艾宇民,150****9958,,1179.56,0.00,7185.78,0.00,2397.73,0.00 + 18,谢俊,186****5198,,969.66,0.00,4819.27,0.00,4193.93,0.00 + 19,周周,198****8725,周周(40.8h)、球球(15.2h)、佳怡(13.2h),8905.19,0.00,0.00,0.00,0.00,0.00 + 20,叶总,137****3287,,0.00,0.00,0.00,0.00,8163.28,5700.00 + 21,羊,187****5094,,0.00,0.00,4534.79,16000.00,3620.75,48000.00 + 22,游,172****6666,佳怡(3.2h)、周周(2.4h)、千千(2.2h)、QQ(1.2h),2795.90,0.00,5001.47,0.00,0.00,0.00 + 23,桂先生,166****7275,,606.24,0.00,4788.94,0.00,2283.11,985.00 + 24,明哥,166****0999,婉婉(26.7h)、小柔(25.7h)、年糕(4.6h)、周周(0.4h)、Amy(0.1h),5322.90,0.00,2258.14,0.00,0.00,0.00 + 25,陈德韩,134****7864,,20.00,0.00,0.00,0.00,7282.92,5000.00 + 26,小熊,139****0145,,0.00,0.00,5213.64,4000.00,1922.24,1000.00 + 27,黄先生,135****3507,苏苏(9.6h)、千千(6.2h),2506.04,0.00,1926.28,0.00,2620.40,3000.00 + 28,吕先生,155****0663,,0.00,0.00,0.00,0.00,7012.05,6000.00 + 29,郑先生,159****4331,小敌(4.9h),1614.93,0.00,4823.14,0.00,0.00,0.00 + 30,歌神,188****2164,,0.00,0.00,0.00,0.00,5877.65,5000.00 + 31,罗先生,139****9222,婉婉(7.5h)、小柔(4.5h),0.00,0.00,4687.61,0.00,1143.87,3000.00 + 32,大G,186****4598,周周(14.9h)、佳怡(9.5h)、球球(1.9h),3591.07,0.00,1783.61,0.00,0.00,0.00 + 33,林先生,137****8785,,480.91,0.00,4486.47,0.00,0.00,0.00 + 34,梅,136****4552,千千(10.8h)、阿清(7.5h)、小侯(3.3h),2483.11,0.00,2107.81,0.00,0.00,0.00 + 35,李先生,134****4343,小侯(9.3h)、球球(0.0h),2395.25,0.00,2084.52,0.00,0.00,0.00 + 36,陶,189****2151,,0.00,0.00,0.00,0.00,4096.07,6000.00 + 37,陈先生,159****2829,,0.00,0.00,3717.58,3000.00,100.00,0.00 + 38,候,131****0323,球球(5.3h)、阿清(4.3h)、小侯(3.4h)、涛涛(3.1h)、乔西(2.9h),3765.88,0.00,0.00,0.00,0.00,0.00 + 39,黄先生,158****2109,球球(8.1h)、QQ(4.5h),300.15,0.00,1660.64,0.00,1608.13,3000.00 + 40,孟紫龙,176****3741,,2888.95,0.00,603.78,0.00,0.00,0.00 + 41,罗超,137****0990,,0.00,0.00,0.00,0.00,3398.81,3000.00 + 42,君姐,166****4594,璇子(2.3h)、涛涛(2.2h)、年糕(2.2h),1414.23,0.00,1947.72,0.00,0.00,0.00 + 43,吴生,136****3341,,368.03,0.00,1721.17,0.00,1164.50,0.00 + 44,刘哥,135****0020,婉婉(0.3h),54.46,0.00,2982.34,0.00,0.00,0.00 + 45,李先生,131****4000,,0.00,0.00,2997.53,3000.00,0.00,0.00 + 46,阿亮,159****2628,涛涛(2.9h)、梦梦(2.7h),1133.53,0.00,1802.73,0.00,0.00,0.00 + 47,李先生,186****8308,,0.00,0.00,2785.23,0.00,0.00,0.00 + 48,周先生,173****7775,,0.00,0.00,0.00,0.00,2726.01,0.00 + 49,林先生,159****0021,,0.00,0.00,2690.52,0.00,0.00,0.00 + 50,周先生,193****6822,千千(8.6h)、素素(6.2h),2643.64,0.00,0.00,0.00,0.00,0.00 + 51,林先生,188****0332,,0.00,0.00,2612.01,0.00,0.00,0.00 + 52,牛先生,152****5159,,0.00,0.00,2187.48,0.00,0.00,0.00 + 53,孟紫龙(该会员已注销),176****37411,,0.00,0.00,300.00,0.00,1831.72,0.00 + 54,罗超杰,137****8012,,0.00,0.00,423.70,0.00,1160.35,0.00 + 55,汪先生,139****6339,,0.00,0.00,0.00,0.00,1390.07,1000.00 + 56,桂先生(该会员已注销),166****72751,,0.00,0.00,0.00,0.00,1370.81,0.00 + 57,方先生,153****3185,,0.00,0.00,414.57,0.00,898.73,1000.00 + 58,陈淑涛,132****5485,,0.00,0.00,1276.77,0.00,0.00,0.00 + 59,清,130****3087,阿清(3.0h)、千千(3.0h)、小侯(3.0h),1128.06,0.00,0.00,0.00,0.00,0.00 + 60,amy,137****8221,Amy(4.8h),1105.90,0.00,0.00,0.00,0.00,0.00 + 61,曾先生,133****1235,,193.90,0.00,700.09,0.00,97.35,0.00 + 62,昌哥,137****1229,小柔(1.8h),318.40,0.00,624.02,0.00,0.00,0.00 + 63,李,131****9882,,0.00,0.00,320.99,0.00,606.45,0.00 + 64,邓飛,136****9597,,0.00,0.00,925.47,1000.00,0.00,1345.00 + 65,陈世,134****1938,,0.00,0.00,502.07,0.00,419.02,0.00 + 66,婉婉,183****2742,,0.00,0.00,242.81,0.00,624.68,1000.00 + 67,贺斌,150****0885,,0.00,0.00,108.29,0.00,662.27,0.00 + 68,陈泽斌,132****6060,,100.00,0.00,500.00,0.00,100.00,0.00 + 69,林总,138****1180,,0.00,0.00,684.44,0.00,0.00,0.00 + 70,卢广贤,186****6220,,128.86,0.00,288.51,0.00,263.07,0.00 + 71,王龙,186****8011,,669.58,0.00,0.00,0.00,0.00,0.00 + 72,王先生,185****1125,,183.75,0.00,416.93,0.00,0.00,0.00 + 73,潘先生,186****0511,年糕(2.0h),564.93,0.00,0.00,0.00,0.00,0.00 + 74,老宋,138****4554,,0.00,0.00,465.98,0.00,75.99,0.00 + 75,郭先生,156****5001,,0.00,0.00,281.22,0.00,237.13,0.00 + 76,孙启明,137****6325,,0.00,0.00,200.00,0.00,300.00,0.00 + 77,王先生,136****0168,,100.00,0.00,400.00,0.00,0.00,0.00 + 78,黎先生,133****0983,,0.00,0.00,0.00,0.00,470.19,0.00 + 79,常总,185****7188,阿清(1.7h)、年糕(1.7h),460.52,0.00,0.00,0.00,0.00,0.00 + 80,张丹逸,136****6637,,100.00,0.00,0.00,0.00,339.36,0.00 + 81,陈先生,186****8238,,0.00,0.00,416.17,0.00,0.00,0.00 + 82,陈先生,138****3964,,100.00,0.00,0.00,0.00,300.00,0.00 + 83,张先生,136****4528,,298.35,0.00,100.00,0.00,0.00,0.00 + 84,小宇,187****8077,,0.00,0.00,0.00,0.00,397.50,0.00 + 85,黄先生,191****8219,,0.00,0.00,364.33,0.00,0.00,0.00 + 86,刘女士,177****7538,,0.00,0.00,0.00,0.00,362.58,0.00 + 87,魏先生,137****6862,,319.39,0.00,0.00,0.00,0.00,0.00 + 88,杜先生,188****4705,,0.00,0.00,207.47,0.00,100.00,0.00 + 89,刘先生,137****2930,,300.00,0.00,0.00,0.00,0.00,0.00 + 90,陈先生,133****6117,,0.00,0.00,300.00,0.00,0.00,0.00 + 91,潘先生,176****7964,,300.00,0.00,0.00,0.00,0.00,0.00 + 92,钟智豪,188****2803,,0.00,0.00,274.34,0.00,0.00,0.00 + 93,都先生,138****7796,,0.00,0.00,269.64,0.00,0.00,0.00 + 94,林志铭,135****4233,,0.00,0.00,267.96,0.00,0.00,0.00 + 95,方先生,158****6447,,148.00,0.00,100.00,0.00,0.00,0.00 + 96,李先生,176****5124,,0.00,0.00,0.00,0.00,244.00,0.00 + 97,钟先生,132****3438,,0.00,0.00,0.00,0.00,239.37,0.00 + 98,杨,130****5960,,232.00,0.00,0.00,0.00,0.00,0.00 + 99,黄国磊,131****3045,,100.00,0.00,100.00,0.00,25.33,0.00 + 100,周先生,159****9997,,100.00,0.00,100.00,0.00,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_分表.md b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_分表.md new file mode 100644 index 0000000..8c2578b --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_分表.md @@ -0,0 +1,199 @@ +# 2025年10-12月 客户消费能力Top100(分表) +## 思考过程 +以台费订单为基准汇总三类明细,满足应付金额口径,并输出Top100客户的消费/充值/助教偏好。按你的要求,先生成评价为空的版本,再在脚本末尾回填评价。 + +## 查询说明 +与总表同口径;分表仅计算12月喜爱助教Top5,并展示10-12月各月消费/充值。 + +## SQL + +### Top100(按消费总额) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +select + o.member_id, + sum(o.order_amount) as consume_total, + count(*) as order_cnt +from orders o +where o.member_id is not null and o.member_id <> 0 +group by o.member_id +order by consume_total desc +limit 100; +``` + +### 按月消费汇总 +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, x as ( + select + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + where o.member_id is not null and o.member_id <> 0 +) +select + member_id, + month_key, + sum(order_amount) as consume_sum +from x +where month_key is not null +group by member_id, month_key; +``` + +### 按月充值汇总 +```sql +with pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id = p.relate_id + where p.site_id = %(site_id)s + and p.relate_type = 5 + and p.pay_status = 2 + and p.pay_amount > 0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +) +, x as ( + select + member_id, + case when pay_time >= '2025-10-01 00:00:00+08'::timestamptz and pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when pay_time >= '2025-11-01 00:00:00+08'::timestamptz and pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when pay_time >= '2025-12-01 00:00:00+08'::timestamptz and pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + pay_amount + from pay +) +select + member_id, + month_key, + sum(pay_amount) as recharge_sum +from x +where month_key is not null +group by member_id, month_key; +``` + +### 喜爱助教Top5(仅12月) +```sql +with x as ( + select + asl.tenant_member_id as member_id, + asl.nickname as assistant_nickname, + sum(case when asl.order_assistant_type=1 then asl.income_seconds else asl.income_seconds*1.5 end) / 3600.0 as weighted_hours + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0)=0 + and asl.tenant_member_id is not null and asl.tenant_member_id <> 0 + and asl.start_use_time >= '2025-12-01 00:00:00+08'::timestamptz + and asl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by asl.tenant_member_id, asl.nickname +), +ranked as ( + select *, row_number() over(partition by member_id order by weighted_hours desc) as rn + from x +) +select + member_id, + string_agg(assistant_nickname || '(' || to_char(round(weighted_hours::numeric, 1), 'FM999999990.0') || 'h)', '、' order by weighted_hours desc) as fav5 +from ranked +where rn <= 5 +group by member_id; +``` diff --git a/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_总表.csv b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_总表.csv new file mode 100644 index 0000000..eea0105 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_总表.csv @@ -0,0 +1,105 @@ +2025年10-12月 客户消费能力Top100(总表) +"消费=台费(dwd_table_fee_log.ledger_amount)+助教(dwd_assistant_service_log.ledger_amount)+商品(dwd_store_goods_sale.ledger_amount),均为应付金额(不扣优惠),以台费订单为基准串联;充值=充值支付流水(dwd_payment.relate_type=5, pay_status=2, pay_amount>0)按支付时间切月;储值卡未使用金额=当前有效储值卡余额之和(dim_member_card_account.balance, member_card_type_name='储值卡');喜爱助教=基础课时长+附加课时长*1.5(income_seconds换算小时),总表按10-12月汇总Top5。" + +排名,客户名称,电话号码,10月-12月,10月-12月,10月-12月,当前,评价 +排名,客户名称,电话号码,喜爱助教昵称,总消费(元),总充值(元),储值卡未使用金额(元),评价 +1,轩哥,188****7530,璇子(152.6h)、七七(129.6h)、涛涛(72.7h)、小敌(54.3h)、佳怡(50.6h),153136.05,66000.00,6050.80,订单:53单,平均单次¥2889.36;打球:279.0h,平均单次5.3h;偏好:666(54.4%)、发财(13.7%)、包厢(6.5%)、A区(5.9%);时间:到店均值16:43 中位19:21;离店均值22:31 中位次日01:03;商品:荷花双中支×56(¥3808.00)、细和天下×21(¥2625.00)、100 和成天下×13(¥1495.00)、农夫山泉苏打水×211(¥1266.00)、红牛×101(¥1010.00)、双中支中华×14(¥1008.00)(商品合计¥26812.00 占比17.5%);综合:10-12月到店消费40天/53次,到店周期中位#1,消费排名#1,在店时长#3;12月到店消费8天/9次,到店周期中位#1,消费排名#1,在店时长#7;最近到店2025-12-18;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化;商品贡献高,可做常购商品补货提醒与组合促销 +2,蔡总,159****8893,小柔(97.0h)、七七(72.4h)、璇子(68.2h)、涛涛(43.5h)、瑶瑶(42.8h),149839.46,5000.00,2016.18,订单:23单,平均单次¥6514.76;打球:180.4h,平均单次7.8h;偏好:发财(79.2%)、666(6.5%)、A区(4.3%)、补时长(2.8%);时间:到店均值18:05 中位20:12;离店均值次日02:58 中位次日04:48;商品:钻石荷花×100(¥4685.00)、细和天下×30(¥3750.00)、粗和天下×24(¥3000.00)、100 和成天下×20(¥2300.00)、双中支中华×31(¥2232.00)、荷花双中支×29(¥2008.00)(商品合计¥37968.00 占比25.3%);综合:10-12月到店消费22天/23次,到店周期中位#2,消费排名#2,在店时长#8;12月到店消费8天/8次,到店周期中位#1,消费排名#2,在店时长#10;最近到店2025-12-15;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:商品贡献高,可做常购商品补货提醒与组合促销 +3,葛先生,138****8071,小燕(158.3h)、阿清(27.6h)、千千(14.4h)、周周(11.0h)、年糕(9.4h),48229.33,0.00,9011.19,订单:42单,平均单次¥1148.32;打球:180.6h,平均单次4.3h;偏好:TV台(43.8%)、A区(21.2%)、包厢(20.6%)、S区/斯诺克(6.7%);时间:到店均值14:01 中位19:40;离店均值18:19 中位21:54;商品:百威235毫升×72(¥1080.00)、卡士×33(¥726.00)、风花雪月×36(¥576.00)、细荷花×7(¥370.00)、蜂蜜水×36(¥360.00)、东方树叶×39(¥312.00)(商品合计¥5432.00 占比11.3%);综合:10-12月到店消费25天/42次,到店周期中位#1,消费排名#3,在店时长#7;12月到店消费11天/24次,到店周期中位#1,消费排名#3,在店时长#2;最近到店2025-12-19;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化;商品贡献高,可做常购商品补货提醒与组合促销 +4,罗先生,139****6996,佳怡(205.2h)、苏苏(19.6h)、璇子(13.2h)、周周(11.6h)、涛涛(10.5h),46573.88,10000.00,585.35,订单:86单,平均单次¥541.56;打球:212.7h,平均单次2.5h;偏好:TV台(39.2%)、C区(31.8%)、M7(7.8%)、麻将(5.8%);时间:到店均值15:24 中位18:18;离店均值18:05 中位20:27;商品:百威235毫升×94(¥1410.00)、东方树叶×39(¥312.00)、细荷花×6(¥300.00)、荷花双中支×4(¥276.00)、中支芙蓉王×6(¥228.00)、哇哈哈矿泉水×38(¥190.00)(商品合计¥4390.00 占比9.4%);综合:10-12月到店消费59天/86次,到店周期中位#1,消费排名#4,在店时长#5;12月到店消费17天/25次,到店周期中位#1,消费排名#4,在店时长#5;最近到店2025-12-18;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:商品贡献高,可做常购商品补货提醒与组合促销 +5,陈腾鑫,178****8218,阿清(79.2h)、佳怡(57.7h)、球球(34.2h)、小侯(19.1h)、千千(7.7h),40075.76,9000.00,0.00,订单:93单,平均单次¥430.92;打球:207.0h,平均单次2.2h;偏好:麻将(25.0%)、C区(18.7%)、TV台(13.6%)、包厢(11.4%);时间:到店均值17:33 中位20:09;离店均值19:45 中位22:28;商品:百威235毫升×27(¥405.00)、东方树叶×50(¥400.00)、哇哈哈矿泉水×41(¥205.00)、荷花双中支×2(¥144.00)、地道肠×27(¥135.00)、哈啤×12(¥120.00)(商品合计¥2468.00 占比6.2%);综合:10-12月到店消费49天/93次,到店周期中位#1,消费排名#5,在店时长#6;12月到店消费11天/20次,到店周期中位#1,消费排名#5,在店时长#8;最近到店2025-12-13;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化;商品贡献高,可做常购商品补货提醒与组合促销 +6,江先生,188****4838,璇子(110.5h)、婉婉(16.8h)、小柔(12.0h)、Amy(6.7h)、周周(4.3h),30472.41,11000.00,0.00,订单:24单,平均单次¥1269.68;打球:65.6h,平均单次2.7h;偏好:包厢(44.5%)、888(15.3%)、M8(12.8%)、B区(10.3%);时间:到店均值16:05 中位19:47;离店均值18:53 中位21:24;商品:百威235毫升×480(¥7200.00)、风花雪月×28(¥448.00)、钻石荷花×7(¥330.00)、东方树叶×11(¥88.00)、双中支中华×1(¥72.00)、荷花双中支×1(¥68.00)(商品合计¥8943.00 占比29.3%);综合:10-12月到店消费21天/24次,到店周期中位#3,消费排名#6,在店时长#14;12月到店消费5天/6次,到店周期中位#3,消费排名#6,在店时长#18;最近到店2025-12-13;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化;商品贡献高,可做常购商品补货提醒与组合促销 +7,小燕,178****1334,小燕(140.1h)、阿清(2.0h)、球球(1.4h)、Amy(1.0h)、年糕(0.5h),29888.89,0.00,1802.64,订单:62单,平均单次¥482.08;打球:148.0h,平均单次2.4h;偏好:A区(55.2%)、麻将(20.7%)、S区/斯诺克(14.7%)、补时长(4.3%);时间:到店均值15:22 中位19:05;离店均值17:50 中位21:43;商品:东方树叶×28(¥224.00)、双中支中华×2(¥144.00)、钻石荷花×2(¥90.00)、荷花双中支×1(¥68.00)、蜂蜜水×6(¥60.00)、百威235毫升×3(¥45.00)(商品合计¥863.00 占比2.9%);综合:10-12月到店消费27天/62次,到店周期中位#1,消费排名#7,在店时长#10;12月到店消费15天/30次,到店周期中位#1,消费排名#7,在店时长#3;最近到店2025-12-18;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +8,张先生,139****8852,七七(25.1h)、小侯(20.2h)、千千(14.4h)、周周(10.4h)、璇子(10.3h),26104.33,0.00,1942.41,订单:46单,平均单次¥567.49;打球:105.7h,平均单次2.3h;偏好:C区(76.3%)、666(8.8%)、麻将(5.7%)、补时长(4.8%);时间:到店均值18:43 中位18:47;离店均值21:25 中位21:12;商品:东方树叶×28(¥224.00)、哇哈哈矿泉水×41(¥205.00)、钻石荷花×3(¥135.00)、细和天下×1(¥125.00)、双中支中华×1(¥72.00)、农夫山泉苏打水×11(¥66.00)(商品合计¥1205.00 占比4.6%);综合:10-12月到店消费40天/46次,到店周期中位#1,消费排名#8,在店时长#13;12月到店消费13天/13次,到店周期中位#1,消费排名#8,在店时长#13;最近到店2025-12-18;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +9,曾巧明,186****1488,,24830.16,0.00,0.00,订单:59单,平均单次¥420.85;打球:404.3h,平均单次6.9h;偏好:B区(66.4%)、C区(32.8%)、A区(0.8%);时间:到店均值17:01 中位16:13;离店均值23:52 中位次日00:12;商品:跨越贵烟×1(¥30.00)、地道肠×4(¥20.00)、东方树叶×2(¥16.00)、可乐×2(¥10.00)、哇哈哈矿泉水×2(¥10.00)(商品合计¥86.00 占比0.3%);综合:10-12月到店消费54天/59次,到店周期中位#1,消费排名#9,在店时长#1;12月到店消费15天/16次,到店周期中位#1,消费排名#9,在店时长#1;最近到店2025-12-18;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +10,黄生,136****9719,,18406.23,0.00,0.00,订单:36单,平均单次¥511.28;打球:298.2h,平均单次8.3h;偏好:B区(64.4%)、C区(35.6%);时间:到店均值13:13 中位12:53;离店均值21:30 中位22:04;商品:地道肠×8(¥40.00)、可乐×2(¥10.00)(商品合计¥50.00 占比0.3%);综合:10-12月到店消费36天/36次,到店周期中位#1,消费排名#10,在店时长#2;12月到店消费7天/7次,到店周期中位#3,消费排名#10,在店时长#4;最近到店2025-12-18;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +11,夏,191****2851,婉婉(29.7h)、柚子(10.7h)、球球(10.6h)、佳怡(7.8h)、璇子(7.6h),16599.59,17000.00,0.00,订单:10单,平均单次¥1659.96;打球:40.5h,平均单次4.1h;偏好:888(72.1%)、TV台(22.1%)、包厢(5.8%)、补时长(0.0%);时间:到店均值18:28 中位20:36;离店均值22:43 中位次日01:10;商品:百威235毫升×247(¥3705.00)、细荷花×5(¥250.00)、荷花双中支×3(¥204.00)、清洁费150×1(¥150.00)、三只松鼠开心果×4(¥120.00)、鸡翅三个一份×6(¥108.00)(商品合计¥5162.90 占比31.1%);综合:10-12月到店消费9天/10次,到店周期中位#5,消费排名#11,在店时长#19;12月到店消费0天/0次,到店周期中位—,消费排名#11,在店时长—;最近到店2025-11-06;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化;商品贡献高,可做常购商品补货提醒与组合促销 +12,叶先生,138****9539,素素(29.7h)、年糕(9.7h)、Amy(7.4h)、球球(6.7h)、婉婉(3.7h),14491.25,6307.00,0.00,订单:8单,平均单次¥1811.41;打球:27.2h,平均单次3.4h;偏好:B区(30.9%)、888(24.0%)、C区(17.8%)、包厢(16.3%);时间:到店均值18:38 中位20:59;离店均值次日00:04 中位次日02:12;商品:蓝妹×96(¥1728.00)、钻石荷花×9(¥405.00)、50 和成天下×6(¥390.00)、鸡翅三个一份×14(¥252.00)、100 和成天下×2(¥230.00)、透明袋无穷鸡翅×10(¥200.00)(商品合计¥4880.00 占比33.7%);综合:10-12月到店消费8天/8次,到店周期中位#2,消费排名#12,在店时长#27;12月到店消费2天/2次,到店周期中位#1,消费排名#12,在店时长#37;最近到店2025-12-06;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化;商品贡献高,可做常购商品补货提醒与组合促销 +13,T,180****9962,佳怡(15.0h)、周周(14.7h)、乔西(11.2h)、球球(10.7h)、欣怡(10.3h),13651.06,3000.00,849.68,订单:16单,平均单次¥853.19;打球:55.6h,平均单次3.5h;偏好:麻将(50.8%)、M8(17.0%)、包厢(16.0%)、A区(9.2%);时间:到店均值18:05 中位19:34;离店均值21:23 中位22:01;商品:荷花双中支×4(¥288.00)、钻石荷花×4(¥190.00)、软荷花×3(¥174.00)、哇哈哈矿泉水×26(¥130.00)、红牛×10(¥100.00)、跨越贵烟×3(¥86.00)(商品合计¥1689.00 占比12.4%);综合:10-12月到店消费12天/16次,到店周期中位#1,消费排名#13,在店时长#16;12月到店消费5天/8次,到店周期中位#1,消费排名#13,在店时长#14;最近到店2025-12-17;趋势:12月为高峰月,具备加深运营空间;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +14,林先生,133****1070,七七(19.4h)、佳怡(14.9h)、璇子(9.6h)、乔西(9.6h)、苏苏(4.9h),12937.86,0.00,158.79,订单:14单,平均单次¥924.13;打球:44.4h,平均单次3.2h;偏好:麻将(33.6%)、M8(31.5%)、M7(21.5%)、补时长(11.4%);时间:到店均值12:18 中位17:34;离店均值15:50 中位次日01:15;商品:荷花双中支×5(¥360.00)、细荷花×5(¥260.00)、细和天下×2(¥250.00)、钻石荷花×5(¥250.00)、双中支中华×3(¥216.00)、粗和天下×1(¥125.00)(商品合计¥2246.00 占比17.4%);综合:10-12月到店消费13天/14次,到店周期中位#1,消费排名#14,在店时长#17;12月到店消费10天/11次,到店周期中位#1,消费排名#14,在店时长#12;最近到店2025-12-13;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;商品贡献高,可做常购商品补货提醒与组合促销 +15,曾丹烨,139****3242,,12356.33,3000.00,6639.14,订单:54单,平均单次¥228.82;打球:256.7h,平均单次4.8h;偏好:麻将(100.0%);时间:到店均值16:05 中位17:32;离店均值20:29 中位22:06;商品:合味道泡面×1(¥12.00)、地道肠×2(¥10.00)、东鹏特饮×1(¥7.00)、喜之郎果冻×1(¥5.00)(商品合计¥34.00 占比0.3%);综合:10-12月到店消费44天/54次,到店周期中位#1,消费排名#15,在店时长#4;12月到店消费9天/11次,到店周期中位#2,消费排名#15,在店时长#6;最近到店2025-12-16;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +16,胡先生,186****3391,佳怡(29.4h)、七七(4.2h)、球球(3.9h)、婉婉(3.9h)、涛涛(2.7h),11420.87,8000.00,0.00,订单:9单,平均单次¥1268.99;打球:39.8h,平均单次4.4h;偏好:包厢(44.2%)、A区(23.1%)、888(11.9%)、麻将(10.7%);时间:到店均值13:36 中位17:09;离店均值17:14 中位20:36;商品:百威235毫升×70(¥1050.00)、钻石荷花×5(¥225.00)、鸡翅三个一份×4(¥72.00)、蜂蜜水×7(¥70.00)、50 和成天下×1(¥65.00)、软荷花×1(¥58.00)(商品合计¥1900.00 占比16.6%);综合:10-12月到店消费5天/9次,到店周期中位#6,消费排名#16,在店时长#20;12月到店消费1天/1次,到店周期中位—,消费排名#16,在店时长#41;最近到店2025-12-01;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化 +17,艾宇民,150****9958,泡芙(22.0h)、小侯(7.6h),10763.07,0.00,0.00,订单:44单,平均单次¥244.62;打球:112.4h,平均单次2.6h;偏好:B区(82.1%)、C区(12.7%)、包厢(5.2%);时间:到店均值16:20 中位16:06;离店均值18:50 中位18:03;商品:百威235毫升×30(¥450.00)、地道肠×13(¥65.00)、细荷花×1(¥50.00)、中支芙蓉王×1(¥38.00)、乖媳妇山椒泡爪×1(¥25.00)、哇哈哈矿泉水×5(¥25.00)(商品合计¥808.00 占比7.5%);综合:10-12月到店消费41天/44次,到店周期中位#1,消费排名#17,在店时长#12;12月到店消费8天/10次,到店周期中位#2,消费排名#17,在店时长#16;最近到店2025-12-17;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +18,谢俊,186****5198,年糕(4.5h)、素素(2.4h),9982.86,0.00,0.00,订单:46单,平均单次¥217.02;打球:159.5h,平均单次3.5h;偏好:B区(100.0%);时间:到店均值19:31 中位20:12;离店均值22:59 中位23:48;商品:钻石荷花×1(¥45.00)(商品合计¥45.00 占比0.5%);综合:10-12月到店消费43天/46次,到店周期中位#1,消费排名#18,在店时长#9;12月到店消费4天/6次,到店周期中位#5,消费排名#18,在店时长#19;最近到店2025-12-17;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +19,周周,198****8725,周周(70.8h)、球球(15.2h)、佳怡(13.2h),8905.19,0.00,0.00,订单:10单,平均单次¥890.52;打球:35.9h,平均单次3.6h;偏好:麻将(59.3%)、M7(19.6%)、补时长(19.6%)、A区(1.5%);时间:到店均值11:40 中位14:30;离店均值17:04 中位次日00:34;商品:哇哈哈AD钙奶×13(¥104.00)、跨越贵烟×3(¥90.00)、细荷花×1(¥55.00)、红利群×2(¥52.00)、钻石荷花×1(¥50.00)、哇哈哈矿泉水×7(¥35.00)(商品合计¥685.00 占比7.7%);综合:10-12月到店消费8天/10次,到店周期中位#1,消费排名#19,在店时长#22;12月到店消费8天/10次,到店周期中位#1,消费排名#19,在店时长#11;最近到店2025-12-17;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +20,叶总,137****3287,璇子(22.0h)、小柔(3.8h)、七七(3.0h),8163.28,5700.00,0.00,订单:5单,平均单次¥1632.66;打球:23.4h,平均单次4.7h;偏好:包厢(53.5%)、888(25.5%)、麻将(12.9%)、B区(8.2%);时间:到店均值21:36 中位21:10;离店均值次日02:18 中位次日02:08;商品:百威235毫升×132(¥1980.00)、荷花双中支×3(¥204.00)、软荷花×1(¥58.00)、跨越贵烟×2(¥56.00)、细荷花×1(¥50.00)、钻石荷花×1(¥45.00)(商品合计¥2487.00 占比30.5%);综合:10-12月到店消费5天/5次,到店周期中位#2,消费排名#20,在店时长#31;12月到店消费0天/0次,到店周期中位—,消费排名#20,在店时长—;最近到店2025-10-27;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化;商品贡献高,可做常购商品补货提醒与组合促销 +21,羊,187****5094,球球(5.3h)、璇子(5.0h)、素素(4.3h)、婉婉(4.3h)、Amy(4.1h),8155.54,64000.00,0.00,订单:13单,平均单次¥627.35;打球:35.7h,平均单次2.7h;偏好:麻将(64.8%)、TV台(14.7%)、B区(10.3%)、888(9.7%);时间:到店均值18:13 中位19:52;离店均值21:16 中位22:36;商品:蓝妹×54(¥972.00)、钻石荷花×4(¥180.00)、中支芙蓉王×2(¥76.00)、无穷烤小腿×3(¥60.00)、跨越贵烟×2(¥56.00)、鸡翅三个一份×3(¥54.00)(商品合计¥2072.00 占比25.4%);综合:10-12月到店消费10天/13次,到店周期中位#1,消费排名#21,在店时长#23;12月到店消费0天/0次,到店周期中位—,消费排名#21,在店时长—;最近到店2025-11-23;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;商品贡献高,可做常购商品补货提醒与组合促销 +22,游,172****6666,佳怡(13.5h)、小敌(3.6h)、璇子(3.1h)、周周(2.4h)、千千(2.2h),7797.37,0.00,0.00,订单:13单,平均单次¥599.80;打球:24.1h,平均单次1.9h;偏好:包厢(52.1%)、666(31.7%)、补时长(8.5%)、S区/斯诺克(7.7%);时间:到店均值19:13 中位18:51;离店均值21:14 中位20:36;商品:双中支中华×3(¥216.00)、红牛×17(¥170.00)、跨越贵烟×5(¥140.00)、荷花双中支×2(¥136.00)、50枸杞槟榔×2(¥130.00)、100 和成天下×1(¥115.00)(商品合计¥1406.00 占比18.0%);综合:10-12月到店消费9天/13次,到店周期中位#2,消费排名#22,在店时长#29;12月到店消费6天/9次,到店周期中位#1,消费排名#22,在店时长#27;最近到店2025-12-13;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +23,桂先生,166****7275,球球(2.6h),7678.29,985.00,0.00,订单:21单,平均单次¥365.63;打球:117.4h,平均单次5.6h;偏好:B区(79.3%)、C区(20.7%);时间:到店均值18:37 中位19:01;离店均值次日00:12 中位次日00:13;商品:地道肠×17(¥85.00)、哇哈哈矿泉水×15(¥75.00)、可乐×13(¥65.00)、红牛×4(¥40.00)、蜂蜜水×3(¥30.00)、东方树叶×3(¥24.00)(商品合计¥373.00 占比4.9%);综合:10-12月到店消费21天/21次,到店周期中位#2,消费排名#23,在店时长#11;12月到店消费2天/2次,到店周期中位#9,消费排名#23,在店时长#26;最近到店2025-12-16;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益 +24,明哥,166****0999,小柔(31.6h)、婉婉(26.7h)、涛涛(5.4h)、年糕(4.6h)、周周(0.4h),7581.04,0.00,954.64,订单:4单,平均单次¥1895.26;打球:21.1h,平均单次5.3h;偏好:包厢(43.0%)、A区(26.0%)、补时长(23.8%)、C区(5.2%);时间:到店均值10:22 中位10:15;离店均值13:40 中位13:49;商品:百威235毫升×78(¥1170.00)、硬中华×2(¥100.00)、地道肠×9(¥45.00)、东方树叶×4(¥32.00)、鱼蛋×6(¥30.00)、蜂蜜水×2(¥20.00)(商品合计¥1447.00 占比19.1%);综合:10-12月到店消费4天/4次,到店周期中位#5,消费排名#24,在店时长#35;12月到店消费3天/3次,到店周期中位#5,消费排名#24,在店时长#20;最近到店2025-12-10;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +25,陈德韩,134****7864,乔西(9.1h)、素素(5.5h)、奈千(4.4h)、周周(4.3h)、小敌(4.0h),7302.92,5000.00,20.11,订单:4单,平均单次¥1825.73;打球:19.6h,平均单次4.9h;偏好:包厢(66.6%)、888(21.3%)、麻将(12.1%)、补时长(0.0%);时间:到店均值18:53 中位18:48;离店均值21:23 中位21:56;商品:百威235毫升×85(¥1275.00)、卡士×9(¥198.00)、无穷爱辣烤鸡爪×3(¥60.00)、鸡翅三个一份×2(¥36.00)、鱼蛋×6(¥30.00)、鱼豆腐×2(¥30.00)(商品合计¥1898.00 占比26.0%);综合:10-12月到店消费4天/4次,到店周期中位#15,消费排名#25,在店时长#38;12月到店消费1天/1次,到店周期中位—,消费排名#25,在店时长—;最近到店2025-12-01;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +26,小熊,139****0145,佳怡(13.2h)、周周(10.7h)、欣怡(7.9h)、球球(4.4h)、七七(1.3h),7135.88,5000.00,0.00,订单:9单,平均单次¥792.88;打球:32.7h,平均单次3.6h;偏好:麻将(82.3%)、包厢(9.2%)、TV台(8.5%)、补时长(0.0%);时间:到店均值14:59 中位18:25;离店均值19:25 中位23:36;商品:软荷花×3(¥174.00)、100 和成天下×1(¥115.00)、中支芙蓉王×3(¥114.00)、钻石荷花×2(¥90.00)、炫赫门小南京×3(¥78.00)、荷花双中支×1(¥68.00)(商品合计¥1223.00 占比17.1%);综合:10-12月到店消费6天/9次,到店周期中位#2,消费排名#26,在店时长#25;12月到店消费0天/0次,到店周期中位—,消费排名#26,在店时长—;最近到店2025-11-05;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化 +27,黄先生,135****3507,苏苏(30.8h)、千千(13.1h)、乔西(1.6h)、阿清(0.7h),7052.72,3000.00,1639.43,订单:22单,平均单次¥320.58;打球:39.4h,平均单次1.8h;偏好:B区(80.5%)、A区(17.0%)、C区(2.2%)、补时长(0.4%);时间:到店均值18:32 中位19:27;离店均值20:30 中位21:28;商品:东方树叶×6(¥48.00)、钻石荷花×1(¥45.00)、哇哈哈AD钙奶×4(¥32.00)、东鹏特饮×3(¥21.00)、无穷烤小腿×1(¥20.00)、综合蔬果干×1(¥15.00)(商品合计¥225.00 占比3.2%);综合:10-12月到店消费20天/22次,到店周期中位#3,消费排名#27,在店时长#21;12月到店消费7天/7次,到店周期中位#2,消费排名#27,在店时长#22;最近到店2025-12-16;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +28,吕先生,155****0663,小柔(7.5h)、周周(3.7h)、素素(2.8h)、婉婉(2.8h)、苏苏(2.8h),7012.05,6000.00,0.00,订单:4单,平均单次¥1753.01;打球:17.0h,平均单次4.2h;偏好:666(66.5%)、888(18.2%)、麻将(15.3%);时间:到店均值14:20 中位18:05;离店均值18:34 中位21:28;商品:蓝妹×35(¥630.00)、软中华×3(¥210.00)、红牛×17(¥170.00)、软荷花×2(¥116.00)、鸡翅三个一份×4(¥72.00)、地道肠×10(¥50.00)(商品合计¥1819.00 占比25.9%);综合:10-12月到店消费4天/4次,到店周期中位#1,消费排名#28,在店时长#41;12月到店消费0天/0次,到店周期中位—,消费排名#28,在店时长—;最近到店2025-10-20;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益 +29,郑先生,159****4331,小敌(32.1h),6438.07,0.00,0.00,订单:16单,平均单次¥402.38;打球:42.2h,平均单次2.6h;偏好:C区(63.7%)、补时长(14.4%)、B区(12.9%)、A区(9.0%);时间:到店均值15:29 中位20:24;离店均值19:22 中位次日00:03;商品:农夫山泉苏打水×8(¥48.00)、蜂蜜水×4(¥40.00)、地道肠×7(¥35.00)、一次性手套×5(¥10.00)、东方树叶×1(¥8.00)、哇哈哈矿泉水×1(¥5.00)(商品合计¥156.00 占比2.4%);综合:10-12月到店消费13天/16次,到店周期中位#1,消费排名#29,在店时长#18;12月到店消费5天/7次,到店周期中位#1,消费排名#29,在店时长#25;最近到店2025-12-12;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +30,歌神,188****2164,婉婉(6.7h)、希希(4.0h)、素素(3.9h)、小柔(3.8h)、年糕(2.6h),5877.65,5000.00,0.00,订单:4单,平均单次¥1469.41;打球:12.8h,平均单次3.2h;偏好:888(43.0%)、包厢(41.0%)、麻将(16.0%);时间:到店均值22:20 中位22:39;离店均值次日01:34 中位次日01:19;商品:百威235毫升×90(¥1350.00)、鸡翅三个一份×5(¥90.00)、焦糖瓜子×3(¥45.00)、鱼蛋×9(¥45.00)、三只松鼠开心果×1(¥30.00)、地道肠×6(¥30.00)(商品合计¥1687.00 占比28.7%);综合:10-12月到店消费4天/4次,到店周期中位#4,消费排名#30,在店时长#46;12月到店消费0天/0次,到店周期中位—,消费排名#30,在店时长—;最近到店2025-10-24;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益;重点维护包厢/团建需求,提前锁档与套餐化 +31,罗先生,139****9222,婉婉(18.8h)、年糕(7.7h)、小柔(4.5h)、素素(4.3h)、姜姜(1.5h),5831.48,3000.00,310.31,订单:5单,平均单次¥1166.30;打球:20.0h,平均单次4.0h;偏好:包厢(72.1%)、888(17.8%)、补时长(10.2%);时间:到店均值16:46 中位21:04;离店均值次日01:10 中位次日00:07;商品:科罗娜啤酒275ml×26(¥468.00)、蓝妹×18(¥324.00)、鸡翅三个一份×2(¥36.00)、三只松鼠开心果×1(¥30.00)、地道肠×5(¥25.00)、焦糖瓜子×1(¥15.00)(商品合计¥924.00 占比15.8%);综合:10-12月到店消费5天/5次,到店周期中位#14,消费排名#31,在店时长#37;12月到店消费0天/0次,到店周期中位—,消费排名#31,在店时长—;最近到店2025-11-27;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +32,大G,186****4598,周周(17.7h)、佳怡(9.5h)、球球(1.9h),5374.68,0.00,0.00,订单:5单,平均单次¥1074.94;打球:21.6h,平均单次4.3h;偏好:麻将(47.3%)、M7(33.2%)、A区(19.4%)、补时长(0.1%);时间:到店均值17:16 中位20:28;离店均值21:48 中位次日00:04;商品:荷花双中支×2(¥144.00)、哇哈哈AD钙奶×9(¥72.00)、无穷烤小腿×3(¥60.00)、芙蓉王×2(¥56.00)、红利群×2(¥52.00)、蜂蜜水×4(¥40.00)(商品合计¥748.00 占比13.9%);综合:10-12月到店消费5天/5次,到店周期中位#1,消费排名#32,在店时长#34;12月到店消费4天/4次,到店周期中位#1,消费排名#32,在店时长#24;最近到店2025-12-07;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +33,林先生,137****8785,小侯(9.7h)、梦梦(4.3h)、千千(2.7h)、瑶瑶(1.9h)、周周(0.7h),4967.38,0.00,0.00,订单:7单,平均单次¥709.63;打球:18.7h,平均单次2.7h;偏好:C区(76.5%)、包厢(23.4%)、补时长(0.0%);时间:到店均值17:58 中位19:48;离店均值20:49 中位23:39;商品:百威235毫升×30(¥450.00)、50枸杞槟榔×1(¥65.00)、东方树叶×5(¥40.00)、蜂蜜水×4(¥40.00)、哇哈哈矿泉水×7(¥35.00)、酒鬼花生×4(¥32.00)(商品合计¥887.00 占比17.9%);综合:10-12月到店消费6天/7次,到店周期中位#3,消费排名#33,在店时长#40;12月到店消费2天/2次,到店周期中位#4,消费排名#33,在店时长#34;最近到店2025-12-06;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +34,梅,136****4552,千千(16.6h)、阿清(10.8h)、小侯(4.7h)、小燕(3.8h),4590.92,0.00,2050.00,订单:8单,平均单次¥573.86;打球:20.4h,平均单次2.5h;偏好:麻将(77.0%)、C区(12.1%)、补时长(10.8%);时间:到店均值14:47 中位20:54;离店均值20:02 中位23:31;商品:硬中华×1(¥50.00)、轻上椰子水×2(¥24.00)、哇哈哈矿泉水×2(¥10.00)(商品合计¥84.00 占比1.8%);综合:10-12月到店消费7天/8次,到店周期中位#5,消费排名#34,在店时长#36;12月到店消费5天/5次,到店周期中位#5,消费排名#34,在店时长#23;最近到店2025-12-19;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +35,李先生,134****4343,小侯(9.3h)、柚子(7.8h)、球球(0.0h),4479.77,0.00,2433.01,订单:8单,平均单次¥559.97;打球:32.1h,平均单次4.0h;偏好:C区(73.4%)、包厢(26.6%);时间:到店均值19:34 中位20:19;离店均值23:35 中位23:13;商品:热水可续杯×13(¥39.00)、农夫山泉苏打水×5(¥30.00)、水果脆×1(¥30.00)、跨越贵烟×1(¥30.00)、哇哈哈矿泉水×4(¥20.00)、水溶C×1(¥8.00)(商品合计¥185.00 占比4.1%);综合:10-12月到店消费8天/8次,到店周期中位#3,消费排名#35,在店时长#26;12月到店消费5天/5次,到店周期中位#3,消费排名#35,在店时长#17;最近到店2025-12-18;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +36,陶,189****2151,欣怡(4.9h)、球球(4.7h)、周周(4.4h)、七七(4.2h)、佳怡(4.1h),4096.07,6000.00,8.82,订单:7单,平均单次¥585.15;打球:21.7h,平均单次3.1h;偏好:麻将(61.0%)、A区(13.3%)、包厢(9.7%)、C区(8.1%);时间:到店均值20:49 中位21:49;离店均值23:55 中位次日01:19;商品:荷花双中支×1(¥68.00)、红牛×5(¥50.00)、轻上椰子水×4(¥48.00)、哇哈哈矿泉水×8(¥40.00)、软玉溪×1(¥28.00)、炫赫门小南京×1(¥26.00)(商品合计¥399.00 占比9.7%);综合:10-12月到店消费6天/7次,到店周期中位#2,消费排名#36,在店时长#33;12月到店消费0天/0次,到店周期中位—,消费排名#36,在店时长—;最近到店2025-10-26;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +37,陈先生,159****2829,佳怡(7.6h)、奈千(5.1h)、柚子(4.4h)、乔西(4.2h)、Amy(4.2h),3817.58,3000.00,0.55,订单:3单,平均单次¥1272.53;打球:7.6h,平均单次2.5h;偏好:麻将(54.5%)、TV台(45.4%)、补时长(0.1%)、A区(0.0%);时间:到店均值13:21 中位19:06;离店均值18:07 中位次日00:40;商品:百威235毫升×10(¥150.00)、钻石荷花×1(¥45.00)、跨越贵烟×1(¥28.00)、无穷爱辣烤鸡爪×1(¥20.00)、白桦树汁×1(¥12.00)、椰汁×1(¥8.00)(商品合计¥277.00 占比7.3%);综合:10-12月到店消费3天/3次,到店周期中位#19,消费排名#37,在店时长#57;12月到店消费0天/0次,到店周期中位—,消费排名#37,在店时长—;最近到店2025-11-07;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +38,候,131****0323,球球(5.3h)、阿清(4.3h)、小侯(3.4h)、涛涛(3.1h)、乔西(2.9h),3765.88,0.00,0.00,订单:8单,平均单次¥470.74;打球:22.0h,平均单次2.7h;偏好:TV台(66.3%)、A区(28.3%)、C区(5.4%);时间:到店均值20:08 中位19:43;离店均值22:53 中位22:54;商品:冰红茶×8(¥48.00)、地道肠×4(¥20.00)、鱼蛋×4(¥20.00)、可乐×2(¥10.00)、哇米诺豆奶×1(¥10.00)、红牛×1(¥10.00)(商品合计¥161.00 占比4.3%);综合:10-12月到店消费5天/8次,到店周期中位#1,消费排名#38,在店时长#32;12月到店消费5天/8次,到店周期中位#1,消费排名#38,在店时长#15;最近到店2025-12-17;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +39,黄先生,158****2109,球球(64.0h)、QQ(6.9h),3568.92,3000.00,420.01,订单:10单,平均单次¥356.89;打球:23.4h,平均单次2.3h;偏好:A区(60.1%)、包厢(27.0%)、补时长(12.9%);时间:到店均值04:58 中位01:43;离店均值06:06 中位03:20;商品:蓝妹×4(¥72.00)、跨越贵烟×1(¥28.00)、火腿肠×2(¥10.00)、东方树叶×1(¥8.00)、卫龙魔芋爽×1(¥8.00)、农夫山泉苏打水×1(¥6.00)(商品合计¥142.00 占比4.0%);综合:10-12月到店消费8天/10次,到店周期中位#4,消费排名#39,在店时长#30;12月到店消费3天/4次,到店周期中位#8,消费排名#39,在店时长#45;最近到店2025-12-17;趋势:12月为高峰月,具备加深运营空间;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +40,孟紫龙,176****3741,,3492.73,0.00,0.00,订单:13单,平均单次¥268.67;打球:57.6h,平均单次4.4h;偏好:B区(100.0%);时间:到店均值18:44 中位18:50;离店均值23:10 中位23:35;商品:哇哈哈矿泉水×9(¥45.00)、东方树叶×5(¥40.00)、地道肠×7(¥35.00)、可乐×2(¥10.00)、维他柠檬茶×1(¥8.00)、脉动×1(¥8.00)(商品合计¥153.00 占比4.4%);综合:10-12月到店消费12天/13次,到店周期中位#1,消费排名#40,在店时长#15;12月到店消费10天/11次,到店周期中位#1,消费排名#40,在店时长#9;最近到店2025-12-18;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +41,罗超,137****0990,球球(2.9h)、璇子(2.7h)、苏苏(2.6h)、奈千(2.5h),3398.81,3000.00,101.19,订单:2单,平均单次¥1699.40;打球:7.3h,平均单次3.7h;偏好:888(78.8%)、麻将(21.2%);时间:到店均值20:57 中位20:57;离店均值次日00:37 中位次日00:37;商品:科罗娜啤酒275ml×49(¥882.00)、荷花双中支×1(¥68.00)、百威235毫升×3(¥45.00)、掼蛋扑克×4(¥32.00)、跨越贵烟×1(¥28.00)、东方树叶×3(¥24.00)(商品合计¥1115.00 占比32.8%);综合:10-12月到店消费2天/2次,到店周期中位#16,消费排名#41,在店时长#59;12月到店消费0天/0次,到店周期中位—,消费排名#41,在店时长—;最近到店2025-10-24;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +42,君姐,166****4594,年糕(8.2h)、婉婉(4.3h)、璇子(2.3h)、涛涛(2.2h),3361.95,0.00,272.15,订单:3单,平均单次¥1120.65;打球:12.5h,平均单次4.2h;偏好:666(30.5%)、M8(25.3%)、M7(17.7%)、包厢(13.9%);时间:到店均值15:38 中位16:21;离店均值20:33 中位21:15;商品:哇哈哈矿泉水×20(¥100.00)、卡士×2(¥44.00)、东方树叶×5(¥40.00)、麻将房茶位费×1(¥40.00)、小果盘×1(¥37.90)、软玉溪×1(¥28.00)(商品合计¥319.90 占比9.5%);综合:10-12月到店消费3天/3次,到店周期中位#5,消费排名#42,在店时长#47;12月到店消费1天/1次,到店周期中位—,消费排名#42,在店时长#31;最近到店2025-12-02;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +43,吴生,136****3341,小柔(2.8h)、涛涛(2.2h),3253.70,0.00,3680.65,订单:15单,平均单次¥216.91;打球:34.3h,平均单次2.3h;偏好:C区(86.1%)、B区(7.0%)、包厢(6.7%)、补时长(0.1%);时间:到店均值19:01 中位18:43;离店均值21:14 中位21:25;商品:哇哈哈矿泉水×18(¥90.00)、双中支中华×1(¥72.00)、阿萨姆×1(¥8.00)(商品合计¥170.00 占比5.2%);综合:10-12月到店消费12天/15次,到店周期中位#6,消费排名#43,在店时长#24;12月到店消费2天/3次,到店周期中位#5,消费排名#43,在店时长#35;最近到店2025-12-16;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +44,刘哥,135****0020,婉婉(5.3h)、球球(2.1h),3036.80,0.00,371.51,订单:2单,平均单次¥1518.40;打球:5.0h,平均单次2.5h;偏好:包厢(93.4%)、S区/斯诺克(6.6%)、补时长(0.0%);时间:到店均值11:33 中位11:33;离店均值12:15 中位12:15;商品:百威235毫升×79(¥1185.00)、中支芙蓉王×2(¥76.00)、地道肠×6(¥30.00)、鱼蛋×6(¥30.00)、红利群×1(¥26.00)、红烧牛肉面×2(¥24.00)(商品合计¥1417.00 占比46.7%);综合:10-12月到店消费2天/2次,到店周期中位#20,消费排名#44,在店时长#66;12月到店消费1天/1次,到店周期中位—,消费排名#44,在店时长#49;最近到店2025-12-17;趋势:12月为高峰月,具备加深运营空间;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +45,李先生,131****4000,小敌(11.8h)、涛涛(2.1h)、Amy(1.1h),2997.53,3000.00,563.47,订单:4单,平均单次¥749.38;打球:11.8h,平均单次3.0h;偏好:包厢(100.0%);时间:到店均值16:24 中位20:21;离店均值19:21 中位23:11;商品:轻上椰子水×10(¥120.00)、科罗娜啤酒275ml×6(¥108.00)、跨越贵烟×1(¥28.00)、地道肠×2(¥10.00)、海之言×1(¥8.00)、茶兀×1(¥8.00)(商品合计¥298.00 占比9.9%);综合:10-12月到店消费3天/4次,到店周期中位#5,消费排名#45,在店时长#48;12月到店消费0天/0次,到店周期中位—,消费排名#45,在店时长—;最近到店2025-11-07;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +46,阿亮,159****2628,涛涛(9.1h)、素素(3.1h)、奈千(2.9h)、梦梦(2.7h)、柚子(2.4h),2936.26,0.00,612.33,订单:8单,平均单次¥367.03;打球:16.8h,平均单次2.1h;偏好:B区(65.3%)、S区/斯诺克(34.5%)、补时长(0.2%);时间:到店均值21:06 中位20:32;离店均值23:26 中位23:29;商品:哇哈哈矿泉水×4(¥20.00)、香飘飘果汁茶×1(¥10.00)、茶兀×1(¥8.00)(商品合计¥38.00 占比1.3%);综合:10-12月到店消费6天/8次,到店周期中位#1,消费排名#46,在店时长#43;12月到店消费2天/3次,到店周期中位#1,消费排名#46,在店时长#29;最近到店2025-12-04;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +47,李先生,186****8308,小侯(5.6h)、年糕(4.2h),2785.23,0.00,0.00,订单:1单,平均单次¥2785.23;打球:8.3h,平均单次8.3h;偏好:包厢(100.0%);时间:到店均值23:15 中位23:15;离店均值次日07:32 中位次日07:32;商品:清洁费150×1(¥150.00)、卡士×1(¥22.00)、合味道泡面×1(¥12.00)、轻上椰子水×1(¥12.00)、雪碧×2(¥10.00)、哇哈哈矿泉水×1(¥5.00)(商品合计¥216.00 占比7.8%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#47,在店时长#55;12月到店消费0天/0次,到店周期中位—,消费排名#47,在店时长—;最近到店2025-11-28;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +48,周先生,173****7775,乔西(7.5h)、小敌(6.6h),2726.01,0.00,0.00,订单:1单,平均单次¥2726.01;打球:7.5h,平均单次7.5h;偏好:666(100.0%);时间:到店均值15:40 中位15:40;离店均值23:11 中位23:11;商品:哇哈哈矿泉水×1(¥5.00)(商品合计¥5.00 占比0.2%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#48,在店时长#58;12月到店消费0天/0次,到店周期中位—,消费排名#48,在店时长—;最近到店2025-10-25;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +49,林先生,159****0021,婉婉(12.0h)、小柔(3.2h)、苏苏(3.1h),2690.52,0.00,49.48,订单:2单,平均单次¥1345.26;打球:6.0h,平均单次3.0h;偏好:包厢(66.7%)、A区(33.3%);时间:到店均值10:13 中位10:13;离店均值12:14 中位12:14;商品:百威235毫升×24(¥360.00)、中支芙蓉王×2(¥76.00)、绿茶×4(¥24.00)、无穷爱辣烤鸡爪×1(¥20.00)、透明袋无穷鸡翅×1(¥20.00)、益达×1(¥18.00)(商品合计¥597.00 占比22.2%);综合:10-12月到店消费2天/2次,到店周期中位#6,消费排名#49,在店时长#62;12月到店消费0天/0次,到店周期中位—,消费排名#49,在店时长—;最近到店2025-11-25;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +50,周先生,193****6822,千千(8.6h)、素素(6.2h),2643.64,0.00,2092.01,订单:3单,平均单次¥881.21;打球:14.9h,平均单次5.0h;偏好:C区(100.0%);时间:到店均值21:06 中位22:21;离店均值次日02:04 中位次日02:07;商品:哇哈哈矿泉水×5(¥25.00)、卡士×1(¥22.00)、东方树叶×2(¥16.00)、王老吉×1(¥8.00)、美汁源果粒橙×1(¥8.00)、一次性拖鞋×1(¥5.00)(商品合计¥89.00 占比3.4%);综合:10-12月到店消费3天/3次,到店周期中位#4,消费排名#50,在店时长#44;12月到店消费3天/3次,到店周期中位#4,消费排名#50,在店时长#21;最近到店2025-12-13;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +51,林先生,188****0332,周周(4.0h)、小敌(1.9h),2612.01,0.00,0.00,订单:8单,平均单次¥326.50;打球:16.9h,平均单次2.1h;偏好:666(25.1%)、麻将(22.3%)、M7(20.7%)、S区/斯诺克(12.8%);时间:到店均值14:55 中位17:06;离店均值17:17 中位19:13;商品:钻石荷花×3(¥135.00)、50枸杞槟榔×1(¥65.00)、跨越贵烟×1(¥28.00)、哇哈哈矿泉水×5(¥25.00)、红牛×2(¥20.00)、蜂蜜水×2(¥20.00)(商品合计¥330.00 占比12.6%);综合:10-12月到店消费6天/8次,到店周期中位#1,消费排名#51,在店时长#42;12月到店消费0天/0次,到店周期中位—,消费排名#51,在店时长—;最近到店2025-11-28;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +52,牛先生,152****5159,柚子(10.4h),2187.48,0.00,0.00,订单:5单,平均单次¥437.50;打球:10.4h,平均单次2.1h;偏好:C区(99.8%)、补时长(0.2%)、A区(0.0%)、B区(0.0%);时间:到店均值19:00 中位19:37;离店均值21:29 中位20:52;商品:地道肠×6(¥30.00)、东方树叶×2(¥16.00)、美汁源果粒橙×2(¥16.00)(商品合计¥62.00 占比2.8%);综合:10-12月到店消费3天/5次,到店周期中位#1,消费排名#52,在店时长#51;12月到店消费0天/0次,到店周期中位—,消费排名#52,在店时长—;最近到店2025-11-24;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +53,孟紫龙(该会员已注销),176****37411,,2131.72,0.00,0.00,订单:7单,平均单次¥304.53;打球:26.3h,平均单次3.8h;偏好:C区(99.9%)、补时长(0.1%);时间:到店均值19:17 中位19:34;离店均值23:32 中位23:56;商品:可乐×5(¥25.00)、东鹏特饮×2(¥14.00)、鱼蛋×1(¥5.00)、一次性手套×1(¥2.00)(商品合计¥46.00 占比2.2%);综合:10-12月到店消费7天/7次,到店周期中位#4,消费排名#53,在店时长#28;12月到店消费0天/0次,到店周期中位—,消费排名#53,在店时长—;最近到店2025-11-28;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +54,罗超杰,137****8012,苏苏(2.1h)、素素(1.9h)、周周(1.2h)、球球(0.8h),1584.05,0.00,3037.00,订单:8单,平均单次¥198.01;打球:8.4h,平均单次1.0h;偏好:包厢(74.8%)、B区(14.0%)、A区(10.9%)、补时长(0.2%);时间:到店均值18:55 中位22:56;离店均值20:06 中位23:23;商品:雪碧×4(¥20.00)、可乐×3(¥15.00)、维他柠檬茶×1(¥8.00)、打火机×1(¥2.00)(商品合计¥45.00 占比2.8%);综合:10-12月到店消费6天/8次,到店周期中位#3,消费排名#54,在店时长#54;12月到店消费0天/0次,到店周期中位—,消费排名#54,在店时长—;最近到店2025-11-05;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +55,汪先生,139****6339,姜姜(9.6h),1390.07,1000.00,11.99,订单:4单,平均单次¥347.52;打球:5.1h,平均单次1.3h;偏好:包厢(82.3%)、A区(17.4%)、补时长(0.4%);时间:到店均值21:16 中位22:01;离店均值23:26 中位23:25;商品:轻上椰子水×1(¥12.00)、三得利×1(¥8.00)、巧乐兹伊利×1(¥8.00)(商品合计¥28.00 占比2.0%);综合:10-12月到店消费3天/4次,到店周期中位#1,消费排名#55,在店时长#65;12月到店消费0天/0次,到店周期中位—,消费排名#55,在店时长—;最近到店2025-10-09;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +56,桂先生(该会员已注销),166****72751,,1370.81,0.00,0.00,订单:5单,平均单次¥274.16;打球:19.0h,平均单次3.8h;偏好:C区(100.0%);时间:到店均值19:20 中位19:37;离店均值23:08 中位23:57;商品:红牛×4(¥40.00)、东鹏特饮×2(¥14.00)、可乐×2(¥10.00)、地道肠×2(¥10.00)、普通扑克×1(¥5.00)(商品合计¥79.00 占比5.8%);综合:10-12月到店消费5天/5次,到店周期中位#2,消费排名#56,在店时长#39;12月到店消费0天/0次,到店周期中位—,消费排名#56,在店时长—;最近到店2025-10-16;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +57,方先生,153****3185,乔西(3.2h),1313.30,1000.00,0.00,订单:3单,平均单次¥437.77;打球:10.2h,平均单次3.4h;偏好:麻将(69.0%)、666(31.0%);时间:到店均值17:32 中位17:19;离店均值21:01 中位20:30;商品:农夫山泉苏打水×5(¥30.00)、掼蛋扑克×2(¥16.00)、可乐×3(¥15.00)、普通扑克×2(¥10.00)、红牛×1(¥10.00)、椰汁×1(¥8.00)(商品合计¥100.00 占比7.6%);综合:10-12月到店消费3天/3次,到店周期中位#10,消费排名#57,在店时长#52;12月到店消费0天/0次,到店周期中位—,消费排名#57,在店时长—;最近到店2025-11-17;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:关注是否为临时充值型,建议引导储值梯度与权益 +58,陈淑涛,132****5485,涛涛(4.4h)、乔西(3.4h),1276.77,0.00,0.00,订单:3单,平均单次¥425.59;打球:4.4h,平均单次1.5h;偏好:麻将(77.1%)、A区(22.9%)、补时长(0.0%);时间:到店均值12:52 中位09:40;离店均值14:39 中位10:40;商品:细荷花×1(¥50.00)、轻上椰子水×1(¥12.00)、哇哈哈矿泉水×2(¥10.00)、火鸡面×1(¥10.00)、茶兀×1(¥8.00)、火腿肠×1(¥5.00)(商品合计¥99.00 占比7.8%);综合:10-12月到店消费2天/3次,到店周期中位#7,消费排名#58,在店时长#69;12月到店消费0天/0次,到店周期中位—,消费排名#58,在店时长—;最近到店2025-11-22;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +59,清,130****3087,小侯(3.0h)、阿清(3.0h)、千千(3.0h),1128.06,0.00,1944.76,订单:1单,平均单次¥1128.06;打球:3.0h,平均单次3.0h;偏好:麻将(100.0%);时间:到店均值21:18 中位21:18;离店均值次日02:00 中位次日02:00;综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#59,在店时长#73;12月到店消费1天/1次,到店周期中位—,消费排名#59,在店时长#38;最近到店2025-12-08;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +60,amy,137****8221,Amy(4.8h),1105.90,0.00,0.38,订单:1单,平均单次¥1105.90;打球:4.8h,平均单次4.8h;偏好:麻将(100.0%);时间:到店均值22:47 中位22:47;离店均值次日03:37 中位次日03:37;商品:跨越贵烟×2(¥60.00)、掼蛋扑克×2(¥16.00)、热水可续杯×4(¥12.00)、可乐×2(¥10.00)、蜂蜜水×1(¥10.00)(商品合计¥108.00 占比9.8%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#60,在店时长#67;12月到店消费1天/1次,到店周期中位—,消费排名#60,在店时长#32;最近到店2025-12-06;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +61,曾先生,133****1235,奈千(1.0h),991.34,0.00,303.19,订单:7单,平均单次¥141.62;打球:13.0h,平均单次1.9h;偏好:S区/斯诺克(63.2%)、A区(36.8%);时间:到店均值13:58 中位12:19;离店均值15:49 中位14:10;商品:咖啡代购×3(¥45.00)、东方树叶×3(¥24.00)、屈臣氏苏打水×1(¥8.00)、阿萨姆×1(¥8.00)、绿茶×1(¥6.00)、哇哈哈矿泉水×1(¥5.00)(商品合计¥98.00 占比9.9%);综合:10-12月到店消费7天/7次,到店周期中位#6,消费排名#61,在店时长#45;12月到店消费2天/2次,到店周期中位#6,消费排名#61,在店时长#36;最近到店2025-12-10;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +62,昌哥,137****1229,Amy(2.0h)、小柔(1.8h)、苏苏(1.7h),942.42,0.00,2471.98,订单:2单,平均单次¥471.21;打球:4.5h,平均单次2.3h;偏好:A区(100.0%);时间:到店均值20:05 中位20:05;离店均值22:21 中位22:21;商品:农夫山泉苏打水×3(¥18.00)、麻辣王子×1(¥12.00)、哇哈哈矿泉水×2(¥10.00)、营养快线×1(¥8.00)、阿萨姆×1(¥8.00)(商品合计¥56.00 占比5.9%);综合:10-12月到店消费2天/2次,到店周期中位#15,消费排名#62,在店时长#68;12月到店消费1天/1次,到店周期中位—,消费排名#62,在店时长#44;最近到店2025-12-07;趋势:12月为高峰月,具备加深运营空间;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +63,李,131****9882,,927.44,0.00,0.00,订单:11单,平均单次¥84.31;打球:11.1h,平均单次1.0h;偏好:B区(68.7%)、A区(30.9%)、补时长(0.4%);时间:到店均值13:13 中位13:14;离店均值14:13 中位14:00;商品:哇哈哈矿泉水×4(¥20.00)(商品合计¥20.00 占比2.2%);综合:10-12月到店消费6天/11次,到店周期中位#8,消费排名#63,在店时长#49;12月到店消费0天/0次,到店周期中位—,消费排名#63,在店时长—;最近到店2025-11-24;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +64,邓飛,136****9597,婉婉(6.8h)、苏苏(6.0h)、小敌(4.5h)、小柔(1.2h)、球球(0.8h),925.47,2345.00,74.77,订单:1单,平均单次¥925.47;打球:1.9h,平均单次1.9h;偏好:包厢(100.0%);时间:到店均值01:56 中位01:56;离店均值03:48 中位03:48;商品:百威235毫升×12(¥180.00)、卡士×1(¥22.00)、农夫山泉苏打水×3(¥18.00)、一次性拖鞋×1(¥5.00)、哇哈哈矿泉水×1(¥5.00)(商品合计¥230.00 占比24.9%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#64,在店时长#85;12月到店消费0天/0次,到店周期中位—,消费排名#64,在店时长—;最近到店2025-11-09;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +65,陈世,134****1938,瑶瑶(0.8h),921.09,0.00,0.00,订单:6单,平均单次¥153.52;打球:6.5h,平均单次1.1h;偏好:C区(49.7%)、S区/斯诺克(49.6%)、补时长(0.7%);时间:到店均值20:42 中位21:08;离店均值22:16 中位22:24;综合:10-12月到店消费3天/6次,到店周期中位#24,消费排名#65,在店时长#61;12月到店消费0天/0次,到店周期中位—,消费排名#65,在店时长—;最近到店2025-11-20;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +66,婉婉,183****2742,婉婉(4.3h),867.49,1000.00,514.08,订单:2单,平均单次¥433.74;打球:4.4h,平均单次2.2h;偏好:B区(36.5%)、A区(35.9%)、888(27.6%);时间:到店均值15:41 中位15:41;离店均值18:01 中位18:01;商品:旺仔牛奶×2(¥20.00)、红牛×1(¥10.00)、王老吉×1(¥8.00)、哇哈哈矿泉水×1(¥5.00)、喜之郎果冻×1(¥5.00)(商品合计¥48.00 占比5.5%);综合:10-12月到店消费2天/2次,到店周期中位#23,消费排名#66,在店时长#70;12月到店消费0天/0次,到店周期中位—,消费排名#66,在店时长—;最近到店2025-11-21;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +67,贺斌,150****0885,佳怡(1.0h),770.56,0.00,0.00,订单:4单,平均单次¥192.64;打球:9.6h,平均单次2.4h;偏好:A区(50.1%)、C区(49.9%);时间:到店均值17:30 中位17:55;离店均值19:53 中位19:54;商品:细荷花×1(¥50.00)、可乐×5(¥25.00)、哇哈哈矿泉水×2(¥10.00)、地道肠×2(¥10.00)、东方树叶×1(¥8.00)(商品合计¥103.00 占比13.4%);综合:10-12月到店消费3天/4次,到店周期中位#4,消费排名#67,在店时长#53;12月到店消费0天/0次,到店周期中位—,消费排名#67,在店时长—;最近到店2025-11-03;趋势:10-12月到店频次下降,建议重点唤醒;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +68,陈泽斌,132****6060,,700.00,0.00,0.00,订单:6单,平均单次¥116.67;打球:0.1h,平均单次0.0h;偏好:补时长(100.0%);时间:到店均值17:51 中位20:40;离店均值18:50 中位21:39;综合:10-12月到店消费6天/6次,到店周期中位#10,消费排名#68,在店时长#97;12月到店消费1天/1次,到店周期中位—,消费排名#68,在店时长#51;最近到店2025-12-11;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +69,林总,138****1180,周周(2.2h)、婉婉(0.9h),684.44,0.00,16729.21,订单:2单,平均单次¥342.22;打球:3.7h,平均单次1.9h;偏好:包厢(100.0%);时间:到店均值18:28 中位18:28;离店均值20:20 中位20:20;商品:东方树叶×1(¥8.00)、哇哈哈AD钙奶×1(¥8.00)(商品合计¥16.00 占比2.3%);综合:10-12月到店消费2天/2次,到店周期中位#2,消费排名#69,在店时长#72;12月到店消费0天/0次,到店周期中位—,消费排名#69,在店时长—;最近到店2025-11-25;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额充足,可提供包厢/团建档期与专属权益;重点维护包厢/团建需求,提前锁档与套餐化 +70,卢广贤,186****6220,,680.44,0.00,0.00,订单:4单,平均单次¥170.11;打球:10.4h,平均单次2.6h;偏好:B区(62.8%)、C区(37.2%);时间:到店均值18:15 中位18:45;离店均值20:51 中位20:41;商品:轻上椰子水×1(¥12.00)、三得利×1(¥8.00)、东鹏特饮×1(¥7.00)、冰红茶×1(¥6.00)、普通扑克×1(¥5.00)(商品合计¥38.00 占比5.6%);综合:10-12月到店消费4天/4次,到店周期中位#18,消费排名#70,在店时长#50;12月到店消费1天/1次,到店周期中位—,消费排名#70,在店时长#46;最近到店2025-12-13;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +71,王龙,186****8011,,669.58,0.00,95.20,订单:4单,平均单次¥167.40;打球:7.8h,平均单次2.0h;偏好:麻将(99.7%)、补时长(0.3%);时间:到店均值20:56 中位21:02;离店均值23:28 中位23:28;商品:麻将房茶位费×2(¥80.00)、东方树叶×2(¥16.00)(商品合计¥96.00 占比14.3%);综合:10-12月到店消费2天/4次,到店周期中位#9,消费排名#71,在店时长#56;12月到店消费2天/4次,到店周期中位#7,消费排名#71,在店时长#28;最近到店2025-12-12;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +72,王先生,185****1125,,600.68,0.00,0.00,订单:4单,平均单次¥150.17;打球:5.9h,平均单次1.5h;偏好:S区/斯诺克(99.4%)、补时长(0.6%);时间:到店均值18:13 中位18:31;离店均值20:09 中位19:30;商品:哇哈哈矿泉水×1(¥5.00)(商品合计¥5.00 占比0.8%);综合:10-12月到店消费4天/4次,到店周期中位#6,消费排名#72,在店时长#63;12月到店消费1天/1次,到店周期中位—,消费排名#72,在店时长#43;最近到店2025-12-02;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +73,潘先生,186****0511,年糕(2.0h),564.93,0.00,0.00,订单:2单,平均单次¥282.46;打球:3.0h,平均单次1.5h;偏好:666(66.7%)、A区(33.3%);时间:到店均值18:28 中位18:28;离店均值19:29 中位19:29;商品:农夫山泉苏打水×1(¥6.00)(商品合计¥6.00 占比1.1%);综合:10-12月到店消费2天/2次,到店周期中位#11,消费排名#73,在店时长#75;12月到店消费2天/2次,到店周期中位#8,消费排名#73,在店时长#40;最近到店2025-12-13;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +74,老宋,138****4554,婉婉(1.0h)、欣怡(0.5h),541.97,0.00,2126.14,订单:2单,平均单次¥270.98;打球:1.5h,平均单次0.7h;偏好:888(66.9%)、A区(33.1%);时间:到店均值19:24 中位19:24;离店均值20:02 中位20:02;商品:百威235毫升×12(¥180.00)(商品合计¥180.00 占比33.2%);综合:10-12月到店消费2天/2次,到店周期中位#13,消费排名#74,在店时长#91;12月到店消费0天/0次,到店周期中位—,消费排名#74,在店时长—;最近到店2025-11-07;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +75,郭先生,156****5001,希希(1.6h),518.35,0.00,2.57,订单:2单,平均单次¥259.18;打球:7.1h,平均单次3.5h;偏好:A区(100.0%);时间:到店均值20:53 中位20:53;离店均值次日00:32 中位次日00:32;商品:可乐×2(¥10.00)、普通扑克×2(¥10.00)、一次性手套×2(¥4.00)(商品合计¥24.00 占比4.6%);综合:10-12月到店消费2天/2次,到店周期中位#7,消费排名#75,在店时长#60;12月到店消费0天/0次,到店周期中位—,消费排名#75,在店时长—;最近到店2025-11-01;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +76,孙启明,137****6325,,500.00,0.00,0.00,订单:4单,平均单次¥125.00;打球:2.0h,平均单次0.5h;偏好:补时长(100.0%);时间:到店均值10:50 中位09:58;离店均值11:49 中位10:57;综合:10-12月到店消费4天/4次,到店周期中位#16,消费排名#76,在店时长#83;12月到店消费0天/0次,到店周期中位—,消费排名#76,在店时长—;最近到店2025-11-19;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +77,王先生,136****0168,,500.00,0.00,0.00,订单:5单,平均单次¥100.00;打球:0.1h,平均单次0.0h;偏好:补时长(100.0%);时间:到店均值19:45 中位19:22;离店均值20:44 中位20:21;综合:10-12月到店消费4天/5次,到店周期中位#5,消费排名#77,在店时长#98;12月到店消费1天/1次,到店周期中位—,消费排名#77,在店时长#53;最近到店2025-12-03;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +78,黎先生,133****0983,奈千(1.7h)、婉婉(0.8h),470.19,0.00,0.00,订单:1单,平均单次¥470.19;打球:1.7h,平均单次1.7h;偏好:包厢(100.0%);时间:到店均值19:17 中位19:17;离店均值21:01 中位21:01;商品:王老吉×2(¥16.00)、普通茶位×1(¥10.00)、香飘飘果汁茶×1(¥10.00)(商品合计¥36.00 占比7.7%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#78,在店时长#89;12月到店消费0天/0次,到店周期中位—,消费排名#78,在店时长—;最近到店2025-10-10;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +79,常总,185****7188,年糕(1.7h)、阿清(1.7h),460.52,0.00,5000.00,订单:1单,平均单次¥460.52;打球:1.8h,平均单次1.8h;偏好:A区(100.0%);时间:到店均值19:13 中位19:13;离店均值20:58 中位20:58;商品:哇哈哈矿泉水×2(¥10.00)、脉动×1(¥8.00)(商品合计¥18.00 占比3.9%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#79,在店时长#88;12月到店消费1天/1次,到店周期中位—,消费排名#79,在店时长#47;最近到店2025-12-14;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +80,张丹逸,136****6637,,439.36,0.00,0.00,订单:4单,平均单次¥109.84;打球:2.4h,平均单次0.6h;偏好:B区(99.0%)、补时长(1.0%);时间:到店均值11:15 中位10:29;离店均值12:36 中位11:29;综合:10-12月到店消费4天/4次,到店周期中位#1,消费排名#80,在店时长#80;12月到店消费1天/1次,到店周期中位—,消费排名#80,在店时长#55;最近到店2025-12-03;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +81,陈先生,186****8238,乔西(1.8h),416.17,0.00,497.83,订单:1单,平均单次¥416.17;打球:1.8h,平均单次1.8h;偏好:包厢(100.0%);时间:到店均值21:15 中位21:15;离店均值23:04 中位23:04;商品:普通茶位×3(¥30.00)(商品合计¥30.00 占比7.2%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#81,在店时长#86;12月到店消费0天/0次,到店周期中位—,消费排名#81,在店时长—;最近到店2025-11-20;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导;重点维护包厢/团建需求,提前锁档与套餐化 +82,陈先生,138****3964,,400.00,0.00,0.00,订单:3单,平均单次¥133.33;打球:2.5h,平均单次0.8h;偏好:补时长(100.0%);时间:到店均值15:17 中位21:41;离店均值15:57 中位22:40;综合:10-12月到店消费3天/3次,到店周期中位#24,消费排名#82,在店时长#79;12月到店消费1天/1次,到店周期中位—,消费排名#82,在店时长#52;最近到店2025-12-04;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +83,张先生,136****4528,,398.35,0.00,0.00,订单:3单,平均单次¥132.78;打球:2.9h,平均单次1.0h;偏好:S区/斯诺克(98.8%)、补时长(1.2%);时间:到店均值21:05 中位20:51;离店均值22:41 中位22:13;商品:农夫山泉苏打水×1(¥6.00)(商品合计¥6.00 占比1.5%);综合:10-12月到店消费3天/3次,到店周期中位#15,消费排名#83,在店时长#76;12月到店消费2天/2次,到店周期中位#10,消费排名#83,在店时长#42;最近到店2025-12-17;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +84,小宇,187****8077,球球(1.5h)、悦悦(0.4h),397.50,0.00,936.07,订单:2单,平均单次¥198.75;打球:2.1h,平均单次1.0h;偏好:包厢(80.9%)、TV台(19.1%);时间:到店均值18:07 中位18:07;离店均值19:09 中位19:09;商品:哇哈哈AD钙奶×1(¥8.00)、农夫山泉苏打水×1(¥6.00)、哇哈哈矿泉水×1(¥5.00)(商品合计¥19.00 占比4.8%);综合:10-12月到店消费2天/2次,到店周期中位#9,消费排名#84,在店时长#82;12月到店消费0天/0次,到店周期中位—,消费排名#84,在店时长—;最近到店2025-10-13;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:重点维护包厢/团建需求,提前锁档与套餐化 +85,黄先生,191****8219,,364.33,0.00,0.00,订单:4单,平均单次¥91.08;打球:1.2h,平均单次0.3h;偏好:B区(95.4%)、补时长(4.6%);时间:到店均值22:45 中位22:43;离店均值23:46 中位23:45;综合:10-12月到店消费3天/4次,到店周期中位#4,消费排名#85,在店时长#93;12月到店消费0天/0次,到店周期中位—,消费排名#85,在店时长—;最近到店2025-11-29;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +86,刘女士,177****7538,,362.58,0.00,0.00,订单:4单,平均单次¥90.64;打球:2.8h,平均单次0.7h;偏好:B区(99.3%)、补时长(0.7%);时间:到店均值15:53 中位15:44;离店均值17:04 中位17:04;综合:10-12月到店消费1天/4次,到店周期中位—,消费排名#86,在店时长#77;12月到店消费0天/0次,到店周期中位—,消费排名#86,在店时长—;最近到店2025-10-14;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +87,魏先生,137****6862,,319.39,0.00,84.51,订单:1单,平均单次¥319.39;打球:5.4h,平均单次5.4h;偏好:A区(66.1%)、S区/斯诺克(33.9%)、B区(0.0%);时间:到店均值18:44 中位18:44;离店均值次日00:08 中位次日00:08;商品:维他柠檬茶×2(¥16.00)、东方树叶×1(¥8.00)(商品合计¥24.00 占比7.5%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#87,在店时长#64;12月到店消费1天/1次,到店周期中位—,消费排名#87,在店时长#30;最近到店2025-12-05;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +88,杜先生,188****4705,,307.47,0.00,0.00,订单:3单,平均单次¥102.49;打球:2.1h,平均单次0.7h;偏好:A区(98.3%)、补时长(1.7%);时间:到店均值21:25 中位21:44;离店均值22:45 中位22:43;商品:哇哈哈矿泉水×2(¥10.00)(商品合计¥10.00 占比3.3%);综合:10-12月到店消费2天/3次,到店周期中位#21,消费排名#88,在店时长#81;12月到店消费0天/0次,到店周期中位—,消费排名#88,在店时长—;最近到店2025-11-02;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +89,刘先生,137****2930,,300.00,0.00,1.27,订单:1单,平均单次¥300.00;打球:0.0h,平均单次0.0h;偏好:补时长(100.0%);时间:到店均值23:47 中位23:47;离店均值次日00:48 中位次日00:48;综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#89,在店时长#100;12月到店消费1天/1次,到店周期中位—,消费排名#89,在店时长#54;最近到店2025-12-07;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +90,陈先生,133****6117,,300.00,0.00,0.00,订单:2单,平均单次¥150.00;打球:0.0h,平均单次0.0h;偏好:补时长(100.0%);时间:到店均值20:50 中位20:50;离店均值21:49 中位21:49;综合:10-12月到店消费2天/2次,到店周期中位#2,消费排名#90,在店时长#99;12月到店消费0天/0次,到店周期中位—,消费排名#90,在店时长—;最近到店2025-11-21;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +91,潘先生,176****7964,,300.00,0.00,0.00,订单:1单,平均单次¥300.00;打球:3.0h,平均单次3.0h;偏好:补时长(100.0%);时间:到店均值00:03 中位00:03;离店均值00:03 中位00:03;综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#91,在店时长#74;12月到店消费1天/1次,到店周期中位—,消费排名#91,在店时长#39;最近到店2025-12-19;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +92,钟智豪,188****2803,小侯(1.8h),274.34,0.00,0.00,订单:1单,平均单次¥274.34;打球:1.8h,平均单次1.8h;偏好:A区(100.0%);时间:到店均值23:50 中位23:50;离店均值次日01:36 中位次日01:36;综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#92,在店时长#87;12月到店消费0天/0次,到店周期中位—,消费排名#92,在店时长—;最近到店2025-11-28;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +93,都先生,138****7796,素素(1.6h),269.64,0.00,0.00,订单:1单,平均单次¥269.64;打球:1.6h,平均单次1.6h;偏好:B区(100.0%);时间:到店均值15:17 中位15:17;离店均值16:56 中位16:56;商品:东方树叶×1(¥8.00)、哇哈哈AD钙奶×1(¥8.00)(商品合计¥16.00 占比5.9%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#93,在店时长#90;12月到店消费0天/0次,到店周期中位—,消费排名#93,在店时长—;最近到店2025-11-13;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +94,林志铭,135****4233,小侯(1.2h),267.96,0.00,795.66,订单:3单,平均单次¥89.32;打球:2.0h,平均单次0.7h;偏好:TV台(75.2%)、S区/斯诺克(24.8%);时间:到店均值19:45 中位20:54;离店均值20:24 中位21:40;综合:10-12月到店消费2天/3次,到店周期中位#12,消费排名#94,在店时长#84;12月到店消费0天/0次,到店周期中位—,消费排名#94,在店时长—;最近到店2025-11-30;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +95,方先生,158****6447,,248.00,0.00,0.00,订单:3单,平均单次¥82.67;打球:1.0h,平均单次0.3h;偏好:A区(97.2%)、补时长(2.8%);时间:到店均值15:35 中位22:11;离店均值16:15 中位23:11;综合:10-12月到店消费3天/3次,到店周期中位#17,消费排名#95,在店时长#94;12月到店消费2天/2次,到店周期中位#2,消费排名#95,在店时长#48;最近到店2025-12-18;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +96,李先生,176****5124,,244.00,0.00,0.00,订单:3单,平均单次¥81.33;打球:0.9h,平均单次0.3h;偏好:A区(97.8%)、补时长(2.2%);时间:到店均值20:59 中位21:28;离店均值21:39 中位21:28;综合:10-12月到店消费3天/3次,到店周期中位#5,消费排名#96,在店时长#95;12月到店消费0天/0次,到店周期中位—,消费排名#96,在店时长—;最近到店2025-10-13;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +97,钟先生,132****3438,,239.37,0.00,0.00,订单:1单,平均单次¥239.37;打球:2.6h,平均单次2.6h;偏好:麻将(100.0%);时间:到店均值19:32 中位19:32;离店均值22:10 中位22:10;商品:50枸杞槟榔×1(¥65.00)、地道肠×3(¥15.00)、哇哈哈矿泉水×2(¥10.00)、蜂蜜水×1(¥10.00)、屈臣氏苏打水×1(¥8.00)、芬达×1(¥5.00)(商品合计¥113.00 占比47.2%);综合:10-12月到店消费1天/1次,到店周期中位—,消费排名#97,在店时长#78;12月到店消费0天/0次,到店周期中位—,消费排名#97,在店时长—;最近到店2025-10-20;趋势:到店频次波动,建议按常用时段做稳定触达;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 +98,杨,130****5960,,232.00,0.00,287.50,订单:2单,平均单次¥116.00;打球:4.0h,平均单次2.0h;偏好:B区(100.0%);时间:到店均值18:09 中位18:09;离店均值20:11 中位20:11;综合:10-12月到店消费2天/2次,到店周期中位#2,消费排名#98,在店时长#71;12月到店消费2天/2次,到店周期中位#2,消费排名#98,在店时长#33;最近到店2025-12-05;趋势:10-12月到店频次上升,12月更活跃;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +99,黄国磊,131****3045,,225.33,0.00,0.22,订单:3单,平均单次¥75.11;打球:0.1h,平均单次0.0h;偏好:A区(85.5%)、补时长(14.5%);时间:到店均值17:56 中位18:02;离店均值18:36 中位18:19;商品:蜂蜜水×2(¥20.00)(商品合计¥20.00 占比8.9%);综合:10-12月到店消费3天/3次,到店周期中位#22,消费排名#99,在店时长#96;12月到店消费1天/1次,到店周期中位—,消费排名#99,在店时长#55;最近到店2025-12-12;趋势:12月为高峰月,具备加深运营空间;围客与客户运营建议:储值余额偏低,建议在其常用时段做补能引导 +100,周先生,159****9997,,200.00,0.00,0.00,订单:2单,平均单次¥100.00;打球:1.2h,平均单次0.6h;偏好:补时长(100.0%);时间:到店均值02:54 中位02:54;离店均值03:19 中位03:19;综合:10-12月到店消费2天/2次,到店周期中位#2,消费排名#100,在店时长#92;12月到店消费1天/1次,到店周期中位—,消费排名#100,在店时长#50;最近到店2025-12-01;趋势:12月为高峰月,具备加深运营空间;围客与客户运营建议:保持常用时段的稳定服务供给,提升复购粘性 diff --git a/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_总表.md b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_总表.md new file mode 100644 index 0000000..826151c --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/客户_Top100_2025年10-12月_总表.md @@ -0,0 +1,459 @@ +# 2025年10-12月 客户消费能力Top100(总表) +## 思考过程 +以台费订单为基准汇总三类明细,满足应付金额口径,并输出Top100客户的消费/充值/助教偏好。按你的要求,先生成评价为空的版本,再在脚本末尾回填评价。 + +## 查询说明 +消费=台费(dwd_table_fee_log.ledger_amount)+助教(dwd_assistant_service_log.ledger_amount)+商品(dwd_store_goods_sale.ledger_amount),均为应付金额(不扣优惠),以台费订单为基准串联;充值=充值支付流水(dwd_payment.relate_type=5, pay_status=2, pay_amount>0)按支付时间切月;储值卡未使用金额=当前有效储值卡余额之和(dim_member_card_account.balance, member_card_type_name='储值卡');喜爱助教=基础课时长+附加课时长*1.5(income_seconds换算小时),总表按10-12月汇总Top5。 + +## SQL + +### Top100(按消费总额) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +select + o.member_id, + sum(o.order_amount) as consume_total, + count(*) as order_cnt +from orders o +where o.member_id is not null and o.member_id <> 0 +group by o.member_id +order by consume_total desc +limit 100; +``` + +### 按月消费汇总 +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, x as ( + select + o.member_id, + case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + o.order_amount + from orders o + where o.member_id is not null and o.member_id <> 0 +) +select + member_id, + month_key, + sum(order_amount) as consume_sum +from x +where month_key is not null +group by member_id, month_key; +``` + +### 按月充值汇总 +```sql +with pay as ( + select + p.pay_time, + r.member_id, + p.pay_amount + from billiards_dwd.dwd_payment p + join billiards_dwd.dwd_recharge_order r on r.recharge_order_id = p.relate_id + where p.site_id = %(site_id)s + and p.relate_type = 5 + and p.pay_status = 2 + and p.pay_amount > 0 + and p.pay_time >= %(window_start)s::timestamptz + and p.pay_time < %(window_end)s::timestamptz +) +, x as ( + select + member_id, + case when pay_time >= '2025-10-01 00:00:00+08'::timestamptz and pay_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when pay_time >= '2025-11-01 00:00:00+08'::timestamptz and pay_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when pay_time >= '2025-12-01 00:00:00+08'::timestamptz and pay_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + pay_amount + from pay +) +select + member_id, + month_key, + sum(pay_amount) as recharge_sum +from x +where month_key is not null +group by member_id, month_key; +``` + +### 储值卡未使用金额(当前余额汇总) +```sql +select + tenant_member_id as member_id, + sum(balance) as stored_value_balance +from billiards_dwd.dim_member_card_account +where scd2_is_current=1 + and coalesce(is_delete,0)=0 + and member_card_type_name='储值卡' + and tenant_member_id = any(%(ids)s) +group by tenant_member_id; +``` + +### 喜爱助教Top5(10-12月) +```sql +with x as ( + select + asl.tenant_member_id as member_id, + asl.nickname as assistant_nickname, + sum(case when asl.order_assistant_type=1 then asl.income_seconds else asl.income_seconds*1.5 end) / 3600.0 as weighted_hours + from billiards_dwd.dwd_assistant_service_log asl + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0)=0 + and asl.tenant_member_id is not null and asl.tenant_member_id <> 0 + and asl.start_use_time >= %(window_start)s::timestamptz + and asl.start_use_time < %(window_end)s::timestamptz + group by asl.tenant_member_id, asl.nickname +), +ranked as ( + select *, row_number() over(partition by member_id order by weighted_hours desc) as rn + from x +) +select + member_id, + string_agg(assistant_nickname || '(' || to_char(round(weighted_hours::numeric, 1), 'FM999999990.0') || 'h)', '、' order by weighted_hours desc) as fav5 +from ranked +where rn <= 5 +group by member_id; +``` + +### 评价画像:订单/时长/到店日期 +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +select + o.member_id, + count(*) as orders, + avg(o.order_amount) as avg_order, + sum(o.table_use_seconds)/3600.0 as play_hours, + avg(o.table_use_seconds)/3600.0 as avg_play_hours, + min((o.order_start_time at time zone 'Asia/Shanghai')::date) as first_visit_day, + max((o.order_start_time at time zone 'Asia/Shanghai')::date) as last_visit_day, + count(distinct (o.order_start_time at time zone 'Asia/Shanghai')::date) as visit_days, + sum(case when o.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then 1 else 0 end) as orders_oct, + sum(case when o.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and o.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then 1 else 0 end) as orders_nov, + sum(case when o.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and o.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then 1 else 0 end) as orders_dec +from orders o +where o.member_id = any(%(ids)s) +group by o.member_id; +``` + +### 评价画像:到店/离店时间(小时) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +, t as ( + select + o.member_id, + extract(hour from (o.order_start_time at time zone 'Asia/Shanghai')) + + extract(minute from (o.order_start_time at time zone 'Asia/Shanghai'))/60.0 + + extract(second from (o.order_start_time at time zone 'Asia/Shanghai'))/3600.0 as arrive_h, + extract(hour from (o.order_end_time at time zone 'Asia/Shanghai')) + + extract(minute from (o.order_end_time at time zone 'Asia/Shanghai'))/60.0 + + extract(second from (o.order_end_time at time zone 'Asia/Shanghai'))/3600.0 as leave_h_raw + from orders o + where o.member_id = any(%(ids)s) +), +tt as ( + select + member_id, + arrive_h, + case when leave_h_raw < arrive_h then leave_h_raw + 24 else leave_h_raw end as leave_h + from t +) +select + member_id, + avg(arrive_h) as arrive_avg_h, + percentile_cont(0.5) within group (order by arrive_h) as arrive_med_h, + avg(leave_h) as leave_avg_h, + percentile_cont(0.5) within group (order by leave_h) as leave_med_h +from tt +group by member_id; +``` + +### 评价画像:球台分区偏好(按时长) +```sql +select + tfl.member_id, + coalesce(tfl.site_table_area_name,'') as site_table_area_name, + sum(tfl.real_table_use_seconds)/3600.0 as hours +from billiards_dwd.dwd_table_fee_log tfl +where tfl.site_id=%(site_id)s and coalesce(tfl.is_delete,0)=0 + and tfl.start_use_time >= %(window_start)s::timestamptz and tfl.start_use_time < %(window_end)s::timestamptz + and tfl.member_id = any(%(ids)s) +group by tfl.member_id, site_table_area_name; +``` + +### 评价画像:商品明细(名称+数量) +```sql +with base_orders as ( + select order_settle_id, max(member_id) as member_id + from billiards_dwd.dwd_table_fee_log + where site_id=%(site_id)s and coalesce(is_delete,0)=0 + and start_use_time >= %(window_start)s::timestamptz and start_use_time < %(window_end)s::timestamptz + group by order_settle_id +) +select + bo.member_id, + g.ledger_name, + sum(g.ledger_count) as qty, + sum(g.ledger_amount) as amount +from base_orders bo +join billiards_dwd.dwd_store_goods_sale g on g.order_settle_id = bo.order_settle_id +where g.site_id=%(site_id)s and coalesce(g.is_delete,0)=0 + and bo.member_id = any(%(ids)s) +group by bo.member_id, g.ledger_name; +``` + +### 评价画像:到店日期明细(用于周期/近期分析) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + max(tfl.ledger_end_time) as order_end_time, + sum(tfl.ledger_amount) as table_amount, + sum(tfl.real_table_use_seconds) as table_use_seconds + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= '2025-10-01 00:00:00+08'::timestamptz + and tfl.start_use_time < '2026-01-01 00:00:00+08'::timestamptz + group by tfl.order_settle_id +), +assistant_info as ( + select + asl.order_settle_id, + sum(asl.ledger_amount) as assistant_amount, + min(asl.start_use_time) as assistant_start_time, + max(asl.last_use_time) as assistant_end_time + from billiards_dwd.dwd_assistant_service_log asl + join base_orders bo on bo.order_settle_id = asl.order_settle_id + where asl.site_id = %(site_id)s + and coalesce(asl.is_delete,0) = 0 + group by asl.order_settle_id +), +goods_amount as ( + select + g.order_settle_id, + sum(g.ledger_amount) as goods_amount + from billiards_dwd.dwd_store_goods_sale g + join base_orders bo on bo.order_settle_id = g.order_settle_id + where g.site_id = %(site_id)s + and coalesce(g.is_delete,0) = 0 + group by g.order_settle_id +), +orders as ( + select + bo.order_settle_id, + bo.member_id, + least(bo.order_start_time, coalesce(a.assistant_start_time, bo.order_start_time)) as order_start_time, + greatest(bo.order_end_time, coalesce(a.assistant_end_time, bo.order_end_time)) as order_end_time, + bo.table_use_seconds, + coalesce(bo.table_amount,0) + coalesce(a.assistant_amount,0) + coalesce(g.goods_amount,0) as order_amount + from base_orders bo + left join assistant_info a on a.order_settle_id = bo.order_settle_id + left join goods_amount g on g.order_settle_id = bo.order_settle_id +) + +select + o.member_id, + (o.order_start_time at time zone 'Asia/Shanghai')::date as visit_date, + count(*) as orders, + sum(o.order_amount) as amount +from orders o +where o.member_id = any(%(ids)s) +group by o.member_id, visit_date +order by o.member_id, visit_date; +``` diff --git a/etl_billiards/docs/table_2025-12-19/财务_优惠分布_2025年10-12月.csv b/etl_billiards/docs/table_2025-12-19/财务_优惠分布_2025年10-12月.csv new file mode 100644 index 0000000..24f1db7 --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/财务_优惠分布_2025年10-12月.csv @@ -0,0 +1,119 @@ +2025年10-12月 财务优惠(会员折扣+台费调账)分布 +优惠=会员折扣(dwd_table_fee_log.member_discount_amount)+台费调账(dwd_table_fee_adjust.ledger_amount),按订单归集后汇总到客户(member_id),按订单最早开台时间切月;不含团购抵扣等其它优惠。 + +客户名称,手机号(脱敏),VIP卡/等级,10月,10月,10月,11月,11月,11月,12月,12月,12月,10-12月 +客户名称,手机号(脱敏),VIP卡/等级,会员折扣(元),台费调账(元),合计优惠(元),会员折扣(元),台费调账(元),合计优惠(元),会员折扣(元),台费调账(元),合计优惠(元),合计优惠(元) +轩哥,188****7530,储值卡,0.00,9212.40,9212.40,0.00,10685.47,10685.47,0.00,5331.76,5331.76,25229.63 +曾巧明,186****1488,年卡,9649.06,0.00,9649.06,8811.59,0.00,8811.59,6283.51,0.00,6283.51,24744.16 +黄生,136****9719,年卡,7821.94,0.00,7821.94,6353.05,0.00,6353.05,4181.24,0.00,4181.24,18356.23 +蔡总,159****8893,台费卡,0.00,0.00,0.00,0.00,12274.20,12274.20,0.00,2587.28,2587.28,14861.48 +罗先生,139****6996,年卡,3315.05,566.48,3881.53,4474.12,460.59,4934.71,2087.14,1320.22,3407.36,12223.60 +陈腾鑫,178****8218,台费卡,0.00,2843.20,2843.20,0.00,4913.52,4913.52,0.00,1935.78,1935.78,9692.50 +谢俊,186****5198,年卡,4193.93,0.00,4193.93,4089.12,0.00,4089.12,969.66,0.00,969.66,9252.71 +桂先生,166****7275,月卡,2175.11,0.00,2175.11,4009.66,301.18,4310.84,0.00,568.24,568.24,7054.19 +张先生,139****8852,年卡,1317.04,1307.13,2624.17,2114.89,168.51,2283.40,2007.34,0.00,2007.34,6914.91 +艾宇民,150****9958,年卡,2342.73,0.00,2342.73,2543.78,545.12,3088.90,1179.56,0.00,1179.56,6611.19 +曾丹烨,139****3242,储值卡,0.00,2348.26,2348.26,0.00,2573.25,2573.25,0.00,1240.20,1240.20,6161.71 +葛先生,138****8071,储值卡,0.00,0.00,0.00,0.00,3548.54,3548.54,0.00,2593.02,2593.02,6141.56 +小燕,178****1334,储值卡,0.00,0.00,0.00,0.00,2250.36,2250.36,0.00,2228.54,2228.54,4478.90 +孟紫龙,176****3741,月卡,0.00,0.00,0.00,576.78,0.00,576.78,2762.95,0.00,2762.95,3339.73 +江先生,188****4838,储值卡,0.00,344.54,344.54,0.00,1454.41,1454.41,0.00,405.54,405.54,2204.49 +郑先生,159****4331,储值卡,0.00,0.00,0.00,0.00,1659.16,1659.16,0.00,463.02,463.02,2122.18 +黄先生,135****3507,台费卡,0.00,631.08,631.08,0.00,631.56,631.56,0.00,810.48,810.48,2073.12 +孟紫龙(该会员已注销),176****37411,月卡,1785.72,0.00,1785.72,0.00,164.00,164.00,0.00,0.00,0.00,1949.72 +T,180****9962,储值卡,0.00,578.93,578.93,0.00,461.72,461.72,0.00,839.34,839.34,1879.99 +林先生,133****1070,储值卡,0.00,0.00,0.00,0.00,199.95,199.95,0.00,1417.70,1417.70,1617.65 +游,172****6666,储值卡,0.00,0.00,0.00,0.00,921.43,921.43,0.00,600.85,600.85,1522.28 +林先生,137****8785,储值卡,0.00,0.00,0.00,0.00,1114.20,1114.20,0.00,186.45,186.45,1300.65 +桂先生(该会员已注销),166****72751,月卡,988.04,303.77,1291.81,0.00,0.00,0.00,0.00,0.00,0.00,1291.81 +叶先生,138****9539,储值卡,0.00,703.64,703.64,0.00,341.29,341.29,0.00,99.41,99.41,1144.34 +陈德韩,134****7864,台费卡,0.00,1091.02,1091.02,0.00,0.00,0.00,0.00,0.00,0.00,1091.02 +周周,198****8725,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,1061.95,1061.95,1061.95 +胡先生,186****3391,储值卡,0.00,0.00,0.00,0.00,785.20,785.20,0.00,100.59,100.59,885.79 +小熊,139****0145,台费卡,0.00,297.01,297.01,0.00,530.45,530.45,0.00,0.00,0.00,827.46 +夏,191****2851,储值卡,0.00,659.78,659.78,0.00,137.90,137.90,0.00,0.00,0.00,797.68 +李先生,186****8308,储值卡,0.00,0.00,0.00,0.00,777.27,777.27,0.00,0.00,0.00,777.27 +叶总,137****3287,储值卡,0.00,737.12,737.12,0.00,0.00,0.00,0.00,0.00,0.00,737.12 +羊,187****5094,储值卡,0.00,38.64,38.64,0.00,647.57,647.57,0.00,0.00,0.00,686.21 +黄先生,158****2109,储值卡,0.00,240.00,240.00,0.00,326.90,326.90,0.00,100.06,100.06,666.96 +卢广贤,186****6220,年卡,263.07,0.00,263.07,250.51,0.00,250.51,128.86,0.00,128.86,642.44 +陶,189****2151,储值卡,0.00,604.89,604.89,0.00,0.00,0.00,0.00,0.00,0.00,604.89 +大G,186****4598,台费卡,0.00,0.00,0.00,0.00,245.70,245.70,0.00,356.31,356.31,602.01 +明哥,166****0999,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,547.68,547.68,547.68 +梅,136****4552,储值卡,0.00,0.00,0.00,0.00,99.02,99.02,0.00,372.66,372.66,471.68 +阿亮,159****2628,储值卡,0.00,0.00,0.00,0.00,361.34,361.34,0.00,100.86,100.86,462.20 +牛先生,152****5159,台费卡,0.00,0.00,0.00,0.00,424.31,424.31,0.00,0.00,0.00,424.31 +罗超杰,137****8012,储值卡,0.00,245.95,245.95,0.00,164.54,164.54,0.00,0.00,0.00,410.49 +陈世,134****1938,储值卡,0.00,199.51,199.51,0.00,198.89,198.89,0.00,0.00,0.00,398.40 +曾先生,133****1235,储值卡,0.00,36.67,36.67,0.00,265.71,265.71,0.00,90.45,90.45,392.83 +李,131****9882,储值卡,0.00,254.13,254.13,0.00,138.12,138.12,0.00,0.00,0.00,392.25 +陈泽斌,132****6060,储值卡,0.00,77.00,77.00,0.00,222.00,222.00,0.00,78.00,78.00,377.00 +王先生,136****0168,台费卡,0.00,0.00,0.00,0.00,235.00,235.00,0.00,65.00,65.00,300.00 +林先生,188****0332,储值卡,0.00,0.00,0.00,0.00,252.11,252.11,0.00,0.00,0.00,252.11 +歌神,188****2164,储值卡,0.00,246.28,246.28,0.00,0.00,0.00,0.00,0.00,0.00,246.28 +陈先生,159****2829,台费卡,0.00,21.00,21.00,0.00,218.13,218.13,0.00,0.00,0.00,239.13 +黄先生,191****8219,台费卡,0.00,0.00,0.00,0.00,229.16,229.16,0.00,0.00,0.00,229.16 +汪先生,139****6339,储值卡,0.00,228.07,228.07,0.00,0.00,0.00,0.00,0.00,0.00,228.07 +王龙,186****8011,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,200.78,200.78,200.78 +罗先生,139****9222,储值卡,0.00,0.00,0.00,0.00,199.17,199.17,0.00,0.00,0.00,199.17 +吴生,136****3341,储值卡,0.00,0.00,0.00,0.00,99.23,99.23,0.00,99.92,99.92,199.15 +刘女士,177****7538,储值卡,0.00,193.29,193.29,0.00,0.00,0.00,0.00,0.00,0.00,193.29 +林总,138****1180,储值卡,0.00,0.00,0.00,0.00,183.25,183.25,0.00,0.00,0.00,183.25 +陈淑涛,132****5485,储值卡,0.00,0.00,0.00,0.00,180.86,180.86,0.00,0.00,0.00,180.86 +张丹逸,136****6637,储值卡,0.00,133.68,133.68,0.00,0.00,0.00,0.00,45.00,45.00,178.68 +孙启明,137****6325,台费卡,0.00,90.00,90.00,0.00,85.00,85.00,0.00,0.00,0.00,175.00 +陈先生,138****3964,储值卡,0.00,98.00,98.00,0.00,0.00,0.00,0.00,60.00,60.00,158.00 +杜先生,188****4705,台费卡,0.00,48.00,48.00,0.00,99.73,99.73,0.00,0.00,0.00,147.73 +周先生,193****6822,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,135.65,135.65,135.65 +君姐,166****4594,储值卡,0.00,0.00,0.00,0.00,134.10,134.10,0.00,0.00,0.00,134.10 +昌哥,137****1229,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,122.03,122.03,122.03 +amy,137****8221,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,116.28,116.28,116.28 +杨,130****5960,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,116.00,116.00,116.00 +方先生,153****3185,储值卡,0.00,100.27,100.27,0.00,0.00,0.00,0.00,0.00,0.00,100.27 +方先生,158****6447,台费卡,0.00,0.00,0.00,0.00,72.00,72.00,0.00,28.00,28.00,100.00 +陈先生,133****6117,台费卡,0.00,0.00,0.00,0.00,100.00,100.00,0.00,0.00,0.00,100.00 +周先生,159****9997,台费卡,0.00,0.00,0.00,0.00,61.00,61.00,0.00,37.00,37.00,98.00 +王姐,158****8819,储值卡,0.00,97.40,97.40,0.00,0.00,0.00,0.00,0.00,0.00,97.40 +老宋,138****4554,储值卡,0.00,0.00,0.00,0.00,93.95,93.95,0.00,0.00,0.00,93.95 +李先生,176****5124,储值卡,0.00,90.00,90.00,0.00,0.00,0.00,0.00,0.00,0.00,90.00 +曾先生,159****0492,储值卡,0.00,88.00,88.00,0.00,0.00,0.00,0.00,0.00,0.00,88.00 +张先生,136****4528,台费卡,0.00,0.00,0.00,0.00,37.00,37.00,0.00,45.00,45.00,82.00 +刘先生,137****2930,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,82.00,82.00,82.00 +潘先生,176****7964,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,74.00,74.00,74.00 +王先生,183****9763,台费卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,74.00,74.00,74.00 +王先生,185****1125,台费卡,0.00,0.00,0.00,0.00,73.00,73.00,0.00,0.00,0.00,73.00 +清,130****3087,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,72.82,72.82,72.82 +贺斌,150****0885,活动抵用券,0.00,71.27,71.27,0.00,0.00,0.00,0.00,0.00,0.00,71.27 +李先生,186****9266,储值卡,0.00,0.00,0.00,0.00,69.00,69.00,0.00,0.00,0.00,69.00 +林志铭,135****4233,储值卡,0.00,0.00,0.00,0.00,66.50,66.50,0.00,0.00,0.00,66.50 +候,131****0323,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,62.79,62.79,62.79 +吕先生,155****0663,储值卡,0.00,62.54,62.54,0.00,0.00,0.00,0.00,0.00,0.00,62.54 +梁先生,134****2609,台费卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,59.00,59.00,59.00 +肖先生,156****6427,台费卡,0.00,0.00,0.00,0.00,7.00,7.00,0.00,50.00,50.00,57.00 +婉婉,183****2742,储值卡,0.00,0.00,0.00,0.00,37.47,37.47,0.00,0.00,0.00,37.47 +陈小姐,138****0778,储值卡,0.00,32.49,32.49,0.00,0.00,0.00,0.00,0.00,0.00,32.49 +黄国磊,131****3045,台费卡,0.00,0.30,0.30,0.00,19.00,19.00,0.00,0.00,0.00,19.30 +小宇,187****8077,储值卡,0.00,13.42,13.42,0.00,0.00,0.00,0.00,0.00,0.00,13.42 +胡总,133****3091,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,1.00,1.00,1.00 +李先生,131****4000,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +周先生,173****7775,台费卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +林先生,159****0021,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +魏先生,137****6862,台费卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +都先生,138****7796,活动抵用券,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +陈先生,188****8626,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +冯先生,155****0348,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +常总,185****7188,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +邓飛,136****9597,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +李先生,134****4343,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +谭先生,138****3185,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +黎先生,133****0983,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +刘哥,135****0020,台费卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +孙先生,135****3191,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +钟智豪,188****2803,活动抵用券,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +陈先生,186****8238,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +郭先生,156****5001,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +钟先生,132****3438,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +唐先生,135****0785,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +陈先生,139****0419,储值卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +潘先生,186****0511,台费卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 +罗超,137****0990,台费卡,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00 diff --git a/etl_billiards/docs/table_2025-12-19/财务_优惠分布_2025年10-12月.md b/etl_billiards/docs/table_2025-12-19/财务_优惠分布_2025年10-12月.md new file mode 100644 index 0000000..afba5bd --- /dev/null +++ b/etl_billiards/docs/table_2025-12-19/财务_优惠分布_2025年10-12月.md @@ -0,0 +1,52 @@ +# 2025年10-12月 财务优惠(会员折扣+台费调账)分布 +## 思考过程 +用台费订单为基准关联调账表,再按客户+月份汇总,输出“谁享受了优惠”及金额分布。 + +## 查询说明 +优惠=会员折扣(dwd_table_fee_log.member_discount_amount)+台费调账(dwd_table_fee_adjust.ledger_amount),按订单归集后汇总到客户(member_id),按订单最早开台时间切月;不含团购抵扣等其它优惠。 + +## SQL + +### 优惠分布(客户+月份) +```sql +with base_orders as ( + select + tfl.order_settle_id, + max(tfl.member_id) as member_id, + min(tfl.start_use_time) as order_start_time, + sum(tfl.member_discount_amount) as member_discount_amount + from billiards_dwd.dwd_table_fee_log tfl + where tfl.site_id = %(site_id)s + and coalesce(tfl.is_delete,0) = 0 + and tfl.start_use_time >= %(window_start)s::timestamptz + and tfl.start_use_time < %(window_end)s::timestamptz + group by tfl.order_settle_id +), +adjusts as ( + select + tfa.order_settle_id, + sum(tfa.ledger_amount) as adjust_amount + from billiards_dwd.dwd_table_fee_adjust tfa + join base_orders bo on bo.order_settle_id = tfa.order_settle_id + where tfa.site_id = %(site_id)s + and coalesce(tfa.is_delete,0) = 0 + group by tfa.order_settle_id +) +, x as ( + select + bo.member_id, + case when bo.order_start_time >= '2025-10-01 00:00:00+08'::timestamptz and bo.order_start_time < '2025-11-01 00:00:00+08'::timestamptz then '2025-10' when bo.order_start_time >= '2025-11-01 00:00:00+08'::timestamptz and bo.order_start_time < '2025-12-01 00:00:00+08'::timestamptz then '2025-11' when bo.order_start_time >= '2025-12-01 00:00:00+08'::timestamptz and bo.order_start_time < '2026-01-01 00:00:00+08'::timestamptz then '2025-12' else null end as month_key, + coalesce(bo.member_discount_amount,0) as member_discount_amount, + coalesce(a.adjust_amount,0) as adjust_amount + from base_orders bo + left join adjusts a on a.order_settle_id = bo.order_settle_id +) +select + member_id, + month_key, + sum(member_discount_amount) as member_discount_sum, + sum(adjust_amount) as adjust_sum +from x +where month_key is not null +group by member_id, month_key; +``` diff --git a/etl_billiards/docs/在线抓取,更新ODS ,然后将更新的ODS内容,对应到DWD的更新。.md b/etl_billiards/docs/在线抓取,更新ODS ,然后将更新的ODS内容,对应到DWD的更新。.md new file mode 100644 index 0000000..461eca0 --- /dev/null +++ b/etl_billiards/docs/在线抓取,更新ODS ,然后将更新的ODS内容,对应到DWD的更新。.md @@ -0,0 +1,34 @@ +在线抓取,更新ODS ,然后将更新的ODS内容,对应到DWD的更新。 + + +可以按“两段定时”跑:先在线抓取+入库更新 ODS,再跑 DWD_LOAD_FROM_ODS 把新增/变更同步到 DWD。CLI 用 python -m etl_billiards.cli.main。 + +1) ODS:在线抓取 + 入库(FULL) +python -m etl_billiards.cli.main ^ + --pipeline-flow FULL ^ + --tasks PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER ^ + --pg-dsn "%PG_DSN%" ^ + --store-id %STORE_ID% ^ + --api-token "%API_TOKEN%" +(可选)指定落盘目录:加 --fetch-root "D:\etl\json";美化 JSON:--write-pretty-json + +2) DWD:ODS → DWD +python -m etl_billiards.cli.main ^ + --pipeline-flow INGEST_ONLY ^ + --tasks DWD_LOAD_FROM_ODS ^ + --pg-dsn "%PG_DSN%" ^ + --store-id %STORE_ID% +推荐的环境变量 +PG_DSN=postgresql://user:pwd@host:5432/db +STORE_ID=... +API_TOKEN=... +(可选)JSON_FETCH_ROOT=... / FETCH_ROOT=...,LOG_ROOT=... +如果你希望“一条命令顺序跑完 ODS+DWD”,也可以直接: + +python -m etl_billiards.cli.main ^ + --pipeline-flow FULL ^ + --tasks PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER,DWD_LOAD_FROM_ODS ^ + --pg-dsn "%PG_DSN%" ^ + --store-id %STORE_ID% ^ + --api-token "%API_TOKEN%" +(这会对前半段任务走在线抓取+入库,对 DWD_LOAD_FROM_ODS 只做入库阶段,因为它没有抓取逻辑。) diff --git a/etl_billiards/etl_billiards/reports/dwd_quality_report.json b/etl_billiards/etl_billiards/reports/dwd_quality_report.json new file mode 100644 index 0000000..5d2508c --- /dev/null +++ b/etl_billiards/etl_billiards/reports/dwd_quality_report.json @@ -0,0 +1,692 @@ +{ + "generated_at": "2026-01-16T01:53:35.751172", + "tables": [ + { + "dwd_table": "billiards_dwd.dim_site", + "ods_table": "billiards_ods.table_fee_transactions", + "count": { + "dwd": 1, + "ods": 17217, + "diff": -17216 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_site_ex", + "ods_table": "billiards_ods.table_fee_transactions", + "count": { + "dwd": 1, + "ods": 17217, + "diff": -17216 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_table", + "ods_table": "billiards_ods.site_tables_master", + "count": { + "dwd": 74, + "ods": 74, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_table_ex", + "ods_table": "billiards_ods.site_tables_master", + "count": { + "dwd": 74, + "ods": 74, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_assistant", + "ods_table": "billiards_ods.assistant_accounts_master", + "count": { + "dwd": 64, + "ods": 64, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_assistant_ex", + "ods_table": "billiards_ods.assistant_accounts_master", + "count": { + "dwd": 64, + "ods": 64, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_member", + "ods_table": "billiards_ods.member_profiles", + "count": { + "dwd": 552, + "ods": 552, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_member_ex", + "ods_table": "billiards_ods.member_profiles", + "count": { + "dwd": 552, + "ods": 552, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_member_card_account", + "ods_table": "billiards_ods.member_stored_value_cards", + "count": { + "dwd": 938, + "ods": 938, + "diff": 0 + }, + "amounts": [ + { + "column": "balance", + "dwd_sum": 406040.3, + "ods_sum": 406040.3, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dim_member_card_account_ex", + "ods_table": "billiards_ods.member_stored_value_cards", + "count": { + "dwd": 938, + "ods": 938, + "diff": 0 + }, + "amounts": [ + { + "column": "deliveryfeededuct", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dim_tenant_goods", + "ods_table": "billiards_ods.tenant_goods_master", + "count": { + "dwd": 170, + "ods": 170, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_tenant_goods_ex", + "ods_table": "billiards_ods.tenant_goods_master", + "count": { + "dwd": 170, + "ods": 170, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_store_goods", + "ods_table": "billiards_ods.store_goods_master", + "count": { + "dwd": 169, + "ods": 169, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_store_goods_ex", + "ods_table": "billiards_ods.store_goods_master", + "count": { + "dwd": 169, + "ods": 169, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_goods_category", + "ods_table": "billiards_ods.stock_goods_category_tree", + "count": { + "dwd": 26, + "ods": 9, + "diff": 17 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_groupbuy_package", + "ods_table": "billiards_ods.group_buy_packages", + "count": { + "dwd": 34, + "ods": 34, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dim_groupbuy_package_ex", + "ods_table": "billiards_ods.group_buy_packages", + "count": { + "dwd": 34, + "ods": 34, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_settlement_head", + "ods_table": "billiards_ods.settlement_records", + "count": { + "dwd": 22055, + "ods": 22055, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_settlement_head_ex", + "ods_table": "billiards_ods.settlement_records", + "count": { + "dwd": 22055, + "ods": 22055, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_table_fee_log", + "ods_table": "billiards_ods.table_fee_transactions", + "count": { + "dwd": 17217, + "ods": 17217, + "diff": 0 + }, + "amounts": [ + { + "column": "adjust_amount", + "dwd_sum": 294914.58, + "ods_sum": 294914.58, + "diff": 0.0 + }, + { + "column": "coupon_promotion_amount", + "dwd_sum": 896198.51, + "ods_sum": 896198.51, + "diff": 0.0 + }, + { + "column": "ledger_amount", + "dwd_sum": 1961129.99, + "ods_sum": 1961129.99, + "diff": 0.0 + }, + { + "column": "member_discount_amount", + "dwd_sum": 186470.94, + "ods_sum": 186470.94, + "diff": 0.0 + }, + { + "column": "real_table_charge_money", + "dwd_sum": 775239.96, + "ods_sum": 775239.96, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_table_fee_log_ex", + "ods_table": "billiards_ods.table_fee_transactions", + "count": { + "dwd": 17217, + "ods": 17217, + "diff": 0 + }, + "amounts": [ + { + "column": "fee_total", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "mgmt_fee", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "service_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "used_card_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_table_fee_adjust", + "ods_table": "billiards_ods.table_fee_discount_records", + "count": { + "dwd": 2648, + "ods": 2648, + "diff": 0 + }, + "amounts": [ + { + "column": "ledger_amount", + "dwd_sum": 303909.03, + "ods_sum": 303909.03, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_table_fee_adjust_ex", + "ods_table": "billiards_ods.table_fee_discount_records", + "count": { + "dwd": 2648, + "ods": 2648, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_store_goods_sale", + "ods_table": "billiards_ods.store_goods_sales_records", + "count": { + "dwd": 17563, + "ods": 17563, + "diff": 0 + }, + "amounts": [ + { + "column": "cost_money", + "dwd_sum": 3116.75, + "ods_sum": 3116.75, + "diff": 0.0 + }, + { + "column": "ledger_amount", + "dwd_sum": 373588.1, + "ods_sum": 373588.1, + "diff": 0.0 + }, + { + "column": "real_goods_money", + "dwd_sum": 351914.9, + "ods_sum": 351914.9, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_store_goods_sale_ex", + "ods_table": "billiards_ods.store_goods_sales_records", + "count": { + "dwd": 466588, + "ods": 17563, + "diff": 449025 + }, + "amounts": [ + { + "column": "coupon_deduct_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "discount_money", + "dwd_sum": 571932.5, + "ods_sum": 21673.2, + "diff": 550259.3 + }, + { + "column": "member_discount_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "option_coupon_deduct_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "option_member_discount_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "point_discount_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "point_discount_money_cost", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "push_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_assistant_service_log", + "ods_table": "billiards_ods.assistant_service_records", + "count": { + "dwd": 4252, + "ods": 4666, + "diff": -414 + }, + "amounts": [ + { + "column": "coupon_deduct_money", + "dwd_sum": 10489.93, + "ods_sum": 10879.86, + "diff": -389.9300000000003 + }, + { + "column": "ledger_amount", + "dwd_sum": 1336399.55, + "ods_sum": 1459374.61, + "diff": -122975.06000000006 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_assistant_service_log_ex", + "ods_table": "billiards_ods.assistant_service_records", + "count": { + "dwd": 4666, + "ods": 4666, + "diff": 0 + }, + "amounts": [ + { + "column": "manual_discount_amount", + "dwd_sum": 414.17, + "ods_sum": 414.17, + "diff": 0.0 + }, + { + "column": "member_discount_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "service_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_assistant_trash_event", + "ods_table": "billiards_ods.assistant_cancellation_records", + "count": { + "dwd": 88, + "ods": 88, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_assistant_trash_event_ex", + "ods_table": "billiards_ods.assistant_cancellation_records", + "count": { + "dwd": 88, + "ods": 88, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_member_balance_change", + "ods_table": "billiards_ods.member_balance_changes", + "count": { + "dwd": 4492, + "ods": 4492, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_member_balance_change_ex", + "ods_table": "billiards_ods.member_balance_changes", + "count": { + "dwd": 4492, + "ods": 4492, + "diff": 0 + }, + "amounts": [ + { + "column": "refund_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_groupbuy_redemption", + "ods_table": "billiards_ods.group_buy_redemption_records", + "count": { + "dwd": 10544, + "ods": 10544, + "diff": 0 + }, + "amounts": [ + { + "column": "coupon_money", + "dwd_sum": 668774.0, + "ods_sum": 668774.0, + "diff": 0.0 + }, + { + "column": "ledger_amount", + "dwd_sum": 641028.09, + "ods_sum": 641028.09, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_groupbuy_redemption_ex", + "ods_table": "billiards_ods.group_buy_redemption_records", + "count": { + "dwd": 10544, + "ods": 10544, + "diff": 0 + }, + "amounts": [ + { + "column": "assistant_promotion_money", + "dwd_sum": 7353.59, + "ods_sum": 7353.59, + "diff": 0.0 + }, + { + "column": "assistant_service_promotion_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "goods_promotion_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "recharge_promotion_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "reward_promotion_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "table_service_promotion_money", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_platform_coupon_redemption", + "ods_table": "billiards_ods.platform_coupon_redemption_records", + "count": { + "dwd": 16086, + "ods": 16086, + "diff": 0 + }, + "amounts": [ + { + "column": "coupon_money", + "dwd_sum": 1040212.0, + "ods_sum": 1040212.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_platform_coupon_redemption_ex", + "ods_table": "billiards_ods.platform_coupon_redemption_records", + "count": { + "dwd": 16086, + "ods": 16086, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_recharge_order", + "ods_table": "billiards_ods.recharge_settlements", + "count": { + "dwd": 431, + "ods": 431, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_recharge_order_ex", + "ods_table": "billiards_ods.recharge_settlements", + "count": { + "dwd": 431, + "ods": 431, + "diff": 0 + }, + "amounts": [] + }, + { + "dwd_table": "billiards_dwd.dwd_payment", + "ods_table": "billiards_ods.payment_transactions", + "count": { + "dwd": 21611, + "ods": 21611, + "diff": 0 + }, + "amounts": [ + { + "column": "pay_amount", + "dwd_sum": 2073449.0, + "ods_sum": 2073449.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_refund", + "ods_table": "billiards_ods.refund_transactions", + "count": { + "dwd": 42, + "ods": 42, + "diff": 0 + }, + "amounts": [ + { + "column": "channel_fee", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "pay_amount", + "dwd_sum": -68958.0, + "ods_sum": -68958.0, + "diff": 0.0 + } + ] + }, + { + "dwd_table": "billiards_dwd.dwd_refund_ex", + "ods_table": "billiards_ods.refund_transactions", + "count": { + "dwd": 42, + "ods": 42, + "diff": 0 + }, + "amounts": [ + { + "column": "balance_frozen_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "card_frozen_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "refund_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + }, + { + "column": "round_amount", + "dwd_sum": 0.0, + "ods_sum": 0.0, + "diff": 0.0 + } + ] + } + ], + "note": "行数/金额核对,金额字段基于列名包含 amount/money/fee/balance 的数值列自动扫描。" +} \ No newline at end of file diff --git a/etl_billiards/fetch-test/README.md b/etl_billiards/fetch-test/README.md new file mode 100644 index 0000000..f438797 --- /dev/null +++ b/etl_billiards/fetch-test/README.md @@ -0,0 +1,25 @@ +# fetch-test + +用于放置“接口联调/规则验证”的一次性脚本(不影响主流程)。 + +## 近期记录 vs 历史记录(Former) 对比 + +脚本:`fetch-test/compare_recent_former_endpoints.py` + +默认对比窗口(end 为次日 00:00:00,与 ETL 窗口一致): +- 近期:2025-12-01 ~ 2025-12-15 +- 历史:2025-08-01 ~ 2025-08-15 + +运行: +```bash +cd etl_billiards +python fetch-test/compare_recent_former_endpoints.py +``` + +输出: +- `etl_billiards/fetch-test/recent_vs_former_report.md` +- `etl_billiards/fetch-test/recent_vs_former_report.json` + +依赖: +- `.env` 需配置 `API_TOKEN`(或 `FICOO_TOKEN`)与 `STORE_ID`,并保证 `API_BASE` 正确。 + diff --git a/etl_billiards/fetch-test/compare_recent_former_endpoints.py b/etl_billiards/fetch-test/compare_recent_former_endpoints.py new file mode 100644 index 0000000..fde2a36 --- /dev/null +++ b/etl_billiards/fetch-test/compare_recent_former_endpoints.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +""" +对比“近期记录”与“历史记录(Former)”接口: +- 是否能正确响应(HTTP + API code==0) +- 返回字段(基于 sample records 的 JSON path)是否一致 + +默认时间窗口(与 ETL 窗口语义一致,end 为次日 00:00:00): +- 近期:2025-12-01 ~ 2025-12-15(end=2025-12-16 00:00:00) +- 历史:2025-08-01 ~ 2025-08-15(end=2025-08-16 00:00:00) +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Iterable + +from dateutil import parser as dtparser +from zoneinfo import ZoneInfo + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from api.client import APIClient +from api.endpoint_routing import derive_former_endpoint as derive_former_endpoint_shared +from config.settings import AppConfig +from models.parsers import TypeParser +from tasks.ods_json_archive_task import EndpointSpec, OdsJsonArchiveTask + +CHINESE_NAMES: dict[str, str] = { + "/MemberProfile/GetMemberCardBalanceChange": "会员余额变动", + "/AssistantPerformance/GetOrderAssistantDetails": "助教服务记录", + "/AssistantPerformance/GetAbolitionAssistant": "助教撤销/作废记录", + "/TenantGoods/GetGoodsSalesList": "商品销售记录", + "/Site/GetSiteTableUseDetails": "团购核销记录", + "/Site/GetSiteTableOrderDetails": "台费订单明细", + "/Site/GetTaiFeeAdjustList": "台费调整/优惠记录", + "/GoodsStockManage/QueryGoodsOutboundReceipt": "出库单/出库记录", + "/Promotion/GetOfflineCouponConsumePageList": "平台券核销记录", + "/Order/GetRefundPayLogList": "退款记录", + "/Site/GetAllOrderSettleList": "结账记录", + "/Site/GetRechargeSettleList": "充值结算记录", + "/PayLog/GetPayLogListPage": "支付记录", +} + + +@dataclass +class EndpointCheckResult: + name_zh: str + recent_endpoint: str + recent_ok: bool + former_endpoint: str + former_ok: bool + has_schema_diff: str # "是" | "否" | "未知" + diff_detail: str + recent_records: int | None = None + former_records: int | None = None + recent_error: str | None = None + former_error: str | None = None + extracted_list_key: str | None = None + + +def _reconfigure_stdout_utf8(): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + +def derive_former_endpoint(endpoint: str) -> str | None: + # backward compatible wrapper: keep local name but delegate to shared router + return derive_former_endpoint_shared(endpoint) + + +def _parse_day_start(d: str, tz: ZoneInfo) -> datetime: + dt = dtparser.parse(d) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=tz) + else: + dt = dt.astimezone(tz) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _window_from_dates(start_date: str, end_date_inclusive: str, tz: ZoneInfo) -> tuple[datetime, datetime]: + start = _parse_day_start(start_date, tz) + end_inclusive = _parse_day_start(end_date_inclusive, tz) + end_exclusive = end_inclusive + timedelta(days=1) + return start, end_exclusive + + +def _build_window_params( + window_style: str, + store_id: int, + window_start: datetime, + window_end: datetime, + tz: ZoneInfo, +) -> dict: + if window_style == "none": + return {} + if window_style == "site": + return {"siteId": store_id} + if window_style == "range": + return { + "siteId": store_id, + "rangeStartTime": TypeParser.format_timestamp(window_start, tz), + "rangeEndTime": TypeParser.format_timestamp(window_end, tz), + } + if window_style == "pay": + return { + "siteId": store_id, + "StartPayTime": TypeParser.format_timestamp(window_start, tz), + "EndPayTime": TypeParser.format_timestamp(window_end, tz), + } + return { + "siteId": store_id, + "startTime": TypeParser.format_timestamp(window_start, tz), + "endTime": TypeParser.format_timestamp(window_end, tz), + } + + +def _extract_records(payload: dict, spec: EndpointSpec) -> tuple[list, str | None]: + # 优先使用 spec.list_key;若拿不到数据,再尝试自动推断(None)。 + records_primary = APIClient._extract_list(payload, spec.data_path, spec.list_key) + if records_primary: + return records_primary, spec.list_key + + records_fallback = APIClient._extract_list(payload, spec.data_path, None) + if records_fallback: + return records_fallback, None + + return [], spec.list_key + + +def _walk_paths(obj: Any, prefix: str, out: set[str], max_depth: int, depth: int, sample_list_elems: int): + if depth > max_depth: + return + if isinstance(obj, dict): + for k, v in obj.items(): + if not isinstance(k, str): + k = str(k) + p = f"{prefix}.{k}" if prefix else k + out.add(p) + _walk_paths(v, p, out, max_depth, depth + 1, sample_list_elems) + elif isinstance(obj, list): + p = f"{prefix}[]" if prefix else "[]" + out.add(p) + for v in obj[:sample_list_elems]: + _walk_paths(v, p, out, max_depth, depth + 1, sample_list_elems) + + +def _schema_from_records(records: list, max_records: int, max_depth: int) -> set[str]: + paths: set[str] = set() + for rec in (records or [])[:max_records]: + _walk_paths(rec, "", paths, max_depth=max_depth, depth=0, sample_list_elems=5) + return paths + + +def _schema_from_data(payload: dict, data_path: tuple[str, ...], max_depth: int) -> set[str]: + cur: Any = payload + for k in data_path: + if isinstance(cur, dict): + cur = cur.get(k) + else: + cur = None + if cur is None: + break + paths: set[str] = set() + _walk_paths(cur, "", paths, max_depth=max_depth, depth=0, sample_list_elems=5) + return paths + + +def _cell(text: str) -> str: + # markdown table cell escape + s = (text or "").replace("|", "\\|").replace("\n", "
") + return s + + +def _format_diff(recent_paths: set[str], former_paths: set[str], limit: int = 60) -> tuple[str, str]: + if recent_paths == former_paths: + return "否", "" + + only_recent = sorted(recent_paths - former_paths) + only_former = sorted(former_paths - recent_paths) + + parts: list[str] = [] + if only_recent: + truncated = only_recent[:limit] + suffix = "" if len(only_recent) <= limit else f" ...(+{len(only_recent) - limit})" + parts.append(f"仅近期({len(only_recent)}): " + ", ".join(truncated) + suffix) + if only_former: + truncated = only_former[:limit] + suffix = "" if len(only_former) <= limit else f" ...(+{len(only_former) - limit})" + parts.append(f"仅历史({len(only_former)}): " + ", ".join(truncated) + suffix) + + return "是", "\n".join(parts).strip() + + +def _post_first_page( + client: APIClient, + endpoint: str, + params: dict, + page_size: int, + spec: EndpointSpec, +) -> tuple[dict, list]: + # 只拉取第 1 页,用于“能否响应”与字段对比 + payload: dict | None = None + records: list = [] + for _, page_records, _, raw in client.iter_paginated( + endpoint=endpoint, + params=params, + page_size=page_size, + data_path=spec.data_path, + list_key=spec.list_key, + page_end=1, + ): + payload = raw + records = page_records or [] + break + return (payload or {}), (records or []) + + +def _load_specs_for_range_only() -> list[EndpointSpec]: + # 以 ODS_JSON_ARCHIVE 的 ENDPOINTS 为准,筛选出“可定义时间范围”的接口 + specs: list[EndpointSpec] = [] + for spec in OdsJsonArchiveTask.ENDPOINTS: + if spec.window_style in ("start_end", "range", "pay"): + specs.append(spec) + return specs + + +def main() -> int: + _reconfigure_stdout_utf8() + ap = argparse.ArgumentParser() + ap.add_argument("--recent-start", default="2025-12-01") + ap.add_argument("--recent-end", default="2025-12-15") + ap.add_argument("--former-start", default="2025-08-01") + ap.add_argument("--former-end", default="2025-08-15") + ap.add_argument("--page-size", type=int, default=50) + ap.add_argument("--max-records", type=int, default=50) + ap.add_argument("--max-depth", type=int, default=5) + ap.add_argument( + "--out", + default=str(Path(__file__).with_name("recent_vs_former_report.md")), + help="输出 markdown 路径", + ) + ap.add_argument( + "--out-json", + default=str(Path(__file__).with_name("recent_vs_former_report.json")), + help="输出 json 路径(含错误信息与统计)", + ) + args = ap.parse_args() + + cfg = AppConfig.load().config + tz = ZoneInfo(cfg["app"]["timezone"]) + store_id = int(cfg["app"]["store_id"]) + + recent_start, recent_end = _window_from_dates(args.recent_start, args.recent_end, tz) + former_start, former_end = _window_from_dates(args.former_start, args.former_end, tz) + + if not cfg["api"].get("token"): + raise SystemExit("缺少 api.token(请在 .env 配置 API_TOKEN 或 FICOO_TOKEN)") + + client = APIClient( + base_url=cfg["api"]["base_url"], + token=cfg["api"]["token"], + timeout=int(cfg["api"].get("timeout_sec") or 20), + retry_max=int(cfg["api"].get("retries", {}).get("max_attempts") or 3), + headers_extra=cfg["api"].get("headers_extra") or {}, + ) + + common_params = cfg["api"].get("params", {}) or {} + if not isinstance(common_params, dict): + common_params = {} + + results: list[EndpointCheckResult] = [] + specs = _load_specs_for_range_only() + + for spec in specs: + name_zh = CHINESE_NAMES.get(spec.endpoint) or Path(spec.endpoint).name + former_endpoint = derive_former_endpoint(spec.endpoint) + + # recent + recent_params = dict(common_params) + recent_params.update(_build_window_params(spec.window_style, store_id, recent_start, recent_end, tz)) + + recent_ok = False + recent_records: list = [] + recent_payload: dict = {} + recent_err: str | None = None + + try: + recent_payload, recent_records = _post_first_page( + client=client, + endpoint=spec.endpoint, + params=recent_params, + page_size=args.page_size, + spec=spec, + ) + recent_ok = True + except Exception as e: + recent_err = f"{type(e).__name__}: {e}" + + # former + former_params = dict(common_params) + former_params.update(_build_window_params(spec.window_style, store_id, former_start, former_end, tz)) + + former_ok = False + former_records: list = [] + former_payload: dict = {} + former_err: str | None = None + + if not former_endpoint: + former_err = "未提供历史记录接口 path" + else: + try: + former_payload, former_records = _post_first_page( + client=client, + endpoint=former_endpoint, + params=former_params, + page_size=args.page_size, + spec=spec, + ) + former_ok = True + except Exception as e: + former_err = f"{type(e).__name__}: {e}" + + extracted_key: str | None = spec.list_key + if recent_ok and former_ok: + # 用“更能提取出 records 的方式”来做字段对比 + recent_extracted, recent_key_used = _extract_records(recent_payload, spec) + former_extracted, former_key_used = _extract_records(former_payload, spec) + extracted_key = recent_key_used or former_key_used or spec.list_key + + if recent_extracted and former_extracted: + recent_schema = _schema_from_records(recent_extracted, args.max_records, args.max_depth) + former_schema = _schema_from_records(former_extracted, args.max_records, args.max_depth) + has_diff, detail = _format_diff(recent_schema, former_schema) + elif (not recent_extracted) and (not former_extracted): + has_diff, detail = "未知", "两侧 records 均为空,无法判断字段差异" + else: + has_diff, detail = ( + "未知", + f"一侧 records 为空(近期={len(recent_extracted)} 历史={len(former_extracted)}),无法判断字段差异", + ) + else: + if former_endpoint is None: + has_diff, detail = "未知", "无历史记录接口,跳过字段对比" + else: + has_diff, detail = "未知", "请求失败,无法对比字段" + + results.append( + EndpointCheckResult( + name_zh=name_zh, + recent_endpoint=spec.endpoint, + recent_ok=recent_ok, + former_endpoint=former_endpoint or "无", + former_ok=former_ok, + has_schema_diff=has_diff, + diff_detail=detail, + recent_records=len(recent_records) if recent_ok else None, + former_records=len(former_records) if former_ok else None, + recent_error=recent_err, + former_error=former_err, + extracted_list_key=extracted_key, + ) + ) + + # markdown report + out_md = Path(args.out) + out_json = Path(args.out_json) + out_md.parent.mkdir(parents=True, exist_ok=True) + + header = [ + "# 近期记录 vs 历史记录(Former) 接口对比报告", + "", + f"- 近期窗口: `{recent_start.isoformat()}` ~ `{recent_end.isoformat()}`(end 为次日 00:00:00)", + f"- 历史窗口: `{former_start.isoformat()}` ~ `{former_end.isoformat()}`(end 为次日 00:00:00)", + f"- store_id: `{store_id}`", + f"- base_url: `{cfg['api']['base_url']}`", + "", + "表头:接口名称(中文);近期记录接口 path;近期记录是否返回;历史记录接口 path;历史记录是否返回;是否存在返回字段差异;差异字段详情。", + "", + "| 接口名称(中文) | 近期记录接口 path | 近期记录是否返回 | 历史记录接口 path | 历史记录是否返回 | 是否存在返回字段差异 | 差异字段详情 |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + + rows: list[str] = [] + for r in results: + rows.append( + "| " + + " | ".join( + [ + _cell(r.name_zh), + _cell(r.recent_endpoint), + "是" if r.recent_ok else "否", + _cell(r.former_endpoint), + "是" if r.former_ok else "否", + _cell(r.has_schema_diff), + _cell(r.diff_detail or ""), + ] + ) + + " |" + ) + + out_md.write_text("\n".join(header + rows) + "\n", encoding="utf-8") + + out_json.write_text( + json.dumps( + { + "recent_window": {"start": recent_start.isoformat(), "end": recent_end.isoformat()}, + "former_window": {"start": former_start.isoformat(), "end": former_end.isoformat()}, + "store_id": store_id, + "base_url": cfg["api"]["base_url"], + "results": [asdict(r) for r in results], + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + print(f"OK: wrote {out_md}") + print(f"OK: wrote {out_json}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/etl_billiards/fetch-test/recent_vs_former_report.json b/etl_billiards/fetch-test/recent_vs_former_report.json new file mode 100644 index 0000000..2ce5c04 --- /dev/null +++ b/etl_billiards/fetch-test/recent_vs_former_report.json @@ -0,0 +1,196 @@ +{ + "recent_window": { + "start": "2025-12-01T00:00:00+08:00", + "end": "2025-12-16T00:00:00+08:00" + }, + "former_window": { + "start": "2025-08-01T00:00:00+08:00", + "end": "2025-08-16T00:00:00+08:00" + }, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "name_zh": "会员余额变动", + "recent_endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "recent_ok": true, + "former_endpoint": "/MemberProfile/GetFormerMemberCardBalanceChange", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": null + }, + { + "name_zh": "助教服务记录", + "recent_endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "recent_ok": true, + "former_endpoint": "/AssistantPerformance/GetFormerOrderAssistantDetails", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": "orderAssistantDetails" + }, + { + "name_zh": "助教撤销/作废记录", + "recent_endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "recent_ok": true, + "former_endpoint": "无", + "former_ok": false, + "has_schema_diff": "未知", + "diff_detail": "无历史记录接口,跳过字段对比", + "recent_records": 18, + "former_records": null, + "recent_error": null, + "former_error": "未提供历史记录接口 path", + "extracted_list_key": "abolitionAssistants" + }, + { + "name_zh": "商品销售记录", + "recent_endpoint": "/TenantGoods/GetGoodsSalesList", + "recent_ok": true, + "former_endpoint": "/TenantGoods/GetFormerGoodsSalesList", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": "orderGoodsLedgers" + }, + { + "name_zh": "团购核销记录", + "recent_endpoint": "/Site/GetSiteTableUseDetails", + "recent_ok": true, + "former_endpoint": "/Site/GetSiteTableUseDetails", + "former_ok": true, + "has_schema_diff": "未知", + "diff_detail": "一侧 records 为空(近期=50 历史=0),无法判断字段差异", + "recent_records": 50, + "former_records": 0, + "recent_error": null, + "former_error": null, + "extracted_list_key": "siteTableUseDetailsList" + }, + { + "name_zh": "台费订单明细", + "recent_endpoint": "/Site/GetSiteTableOrderDetails", + "recent_ok": true, + "former_endpoint": "/Site/GetFormerSiteTableOrderDetails", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": "siteTableUseDetailsList" + }, + { + "name_zh": "台费调整/优惠记录", + "recent_endpoint": "/Site/GetTaiFeeAdjustList", + "recent_ok": true, + "former_endpoint": "/Site/GetFormerTaiFeeAdjustList", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": "taiFeeAdjustInfos" + }, + { + "name_zh": "出库单/出库记录", + "recent_endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "recent_ok": true, + "former_endpoint": "/GoodsStockManage/QueryFormerGoodsOutboundReceipt", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": "queryDeliveryRecordsList" + }, + { + "name_zh": "平台券核销记录", + "recent_endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "recent_ok": true, + "former_endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": null + }, + { + "name_zh": "退款记录", + "recent_endpoint": "/Order/GetRefundPayLogList", + "recent_ok": true, + "former_endpoint": "无", + "former_ok": false, + "has_schema_diff": "未知", + "diff_detail": "无历史记录接口,跳过字段对比", + "recent_records": 5, + "former_records": null, + "recent_error": null, + "former_error": "未提供历史记录接口 path", + "extracted_list_key": null + }, + { + "name_zh": "结账记录", + "recent_endpoint": "/Site/GetAllOrderSettleList", + "recent_ok": true, + "former_endpoint": "/Site/GetFormerOrderSettleList", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": "settleList" + }, + { + "name_zh": "充值结算记录", + "recent_endpoint": "/Site/GetRechargeSettleList", + "recent_ok": true, + "former_endpoint": "/Site/GetFormerRechargeSettleList", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 39, + "former_records": 47, + "recent_error": null, + "former_error": null, + "extracted_list_key": "settleList" + }, + { + "name_zh": "支付记录", + "recent_endpoint": "/PayLog/GetPayLogListPage", + "recent_ok": true, + "former_endpoint": "/PayLog/GetFormerPayLogListPage", + "former_ok": true, + "has_schema_diff": "否", + "diff_detail": "", + "recent_records": 50, + "former_records": 50, + "recent_error": null, + "former_error": null, + "extracted_list_key": null + } + ] +} diff --git a/etl_billiards/fetch-test/recent_vs_former_report.md b/etl_billiards/fetch-test/recent_vs_former_report.md new file mode 100644 index 0000000..f18ce8c --- /dev/null +++ b/etl_billiards/fetch-test/recent_vs_former_report.md @@ -0,0 +1,24 @@ +# 近期记录 vs 历史记录(Former) 接口对比报告 + +- 近期窗口: `2025-12-01T00:00:00+08:00` ~ `2025-12-16T00:00:00+08:00`(end 为次日 00:00:00) +- 历史窗口: `2025-08-01T00:00:00+08:00` ~ `2025-08-16T00:00:00+08:00`(end 为次日 00:00:00) +- store_id: `2790685415443269` +- base_url: `https://pc.ficoo.vip/apiprod/admin/v1/` + +表头:接口名称(中文);近期记录接口 path;近期记录是否返回;历史记录接口 path;历史记录是否返回;是否存在返回字段差异;差异字段详情。 + +| 接口名称(中文) | 近期记录接口 path | 近期记录是否返回 | 历史记录接口 path | 历史记录是否返回 | 是否存在返回字段差异 | 差异字段详情 | +| --- | --- | --- | --- | --- | --- | --- | +| 会员余额变动 | /MemberProfile/GetMemberCardBalanceChange | 是 | /MemberProfile/GetFormerMemberCardBalanceChange | 是 | 否 | | +| 助教服务记录 | /AssistantPerformance/GetOrderAssistantDetails | 是 | /AssistantPerformance/GetFormerOrderAssistantDetails | 是 | 否 | | +| 助教撤销/作废记录 | /AssistantPerformance/GetAbolitionAssistant | 是 | 无 | 否 | 未知 | 无历史记录接口,跳过字段对比 | +| 商品销售记录 | /TenantGoods/GetGoodsSalesList | 是 | /TenantGoods/GetFormerGoodsSalesList | 是 | 否 | | +| 团购核销记录 | /Site/GetSiteTableUseDetails | 是 | /Site/GetSiteTableUseDetails | 是 | 未知 | 一侧 records 为空(近期=50 历史=0),无法判断字段差异 | +| 台费订单明细 | /Site/GetSiteTableOrderDetails | 是 | /Site/GetFormerSiteTableOrderDetails | 是 | 否 | | +| 台费调整/优惠记录 | /Site/GetTaiFeeAdjustList | 是 | /Site/GetFormerTaiFeeAdjustList | 是 | 否 | | +| 出库单/出库记录 | /GoodsStockManage/QueryGoodsOutboundReceipt | 是 | /GoodsStockManage/QueryFormerGoodsOutboundReceipt | 是 | 否 | | +| 平台券核销记录 | /Promotion/GetOfflineCouponConsumePageList | 是 | /Promotion/GetOfflineCouponConsumePageList | 是 | 否 | | +| 退款记录 | /Order/GetRefundPayLogList | 是 | 无 | 否 | 未知 | 无历史记录接口,跳过字段对比 | +| 结账记录 | /Site/GetAllOrderSettleList | 是 | /Site/GetFormerOrderSettleList | 是 | 否 | | +| 充值结算记录 | /Site/GetRechargeSettleList | 是 | /Site/GetFormerRechargeSettleList | 是 | 否 | | +| 支付记录 | /PayLog/GetPayLogListPage | 是 | /PayLog/GetFormerPayLogListPage | 是 | 否 | | diff --git a/etl_billiards/orchestration/run_tracker.py b/etl_billiards/orchestration/run_tracker.py index ca85d2f..86e745b 100644 --- a/etl_billiards/orchestration/run_tracker.py +++ b/etl_billiards/orchestration/run_tracker.py @@ -39,9 +39,18 @@ class RunTracker: 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): + def update_run( + self, + run_id: int, + counts: dict, + status: str, + ended_at: datetime = None, + manifest: dict = None, + error_message: str = None, + window: dict | None = None, + request_params: dict | None = None, + overlap_seconds: int | None = None, + ): """更新运行记录""" sql = """ UPDATE etl_admin.etl_run @@ -54,17 +63,65 @@ class RunTracker: status = %s, ended_at = %s, manifest = %s, - error_message = %s + error_message = %s, + window_start = COALESCE(%s, window_start), + window_end = COALESCE(%s, window_end), + window_minutes = COALESCE(%s, window_minutes), + overlap_seconds = COALESCE(%s, overlap_seconds), + request_params = CASE WHEN %s IS NULL THEN request_params ELSE %s::jsonb END WHERE run_id = %s """ + def _count(v, default: int = 0) -> int: + if v is None: + return default + if isinstance(v, bool): + return int(v) + if isinstance(v, int): + return int(v) + if isinstance(v, str): + try: + return int(v) + except Exception: + return default + if isinstance(v, (list, tuple, set, dict)): + try: + return len(v) + except Exception: + return default + return default + + safe_counts = counts or {} + + window_start = None + window_end = None + window_minutes = None + if isinstance(window, dict): + window_start = window.get("start") or window.get("window_start") + window_end = window.get("end") or window.get("window_end") + window_minutes = window.get("minutes") or window.get("window_minutes") + + request_json = None if request_params is None else json.dumps(request_params or {}, ensure_ascii=False) 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) + ( + _count(safe_counts.get("fetched", 0)), + _count(safe_counts.get("inserted", 0)), + _count(safe_counts.get("updated", 0)), + _count(safe_counts.get("skipped", 0)), + _count(safe_counts.get("errors", 0)), + _count(safe_counts.get("unknown_fields", 0)), + status, + ended_at, + json.dumps(manifest or {}, ensure_ascii=False), + error_message, + window_start, + window_end, + window_minutes, + overlap_seconds, + request_json, + request_json, + run_id, + ), ) self.db.commit() diff --git a/etl_billiards/orchestration/scheduler.py b/etl_billiards/orchestration/scheduler.py index 4f6ffe0..0faaf0a 100644 --- a/etl_billiards/orchestration/scheduler.py +++ b/etl_billiards/orchestration/scheduler.py @@ -1,7 +1,17 @@ # -*- coding: utf-8 -*- -"""ETL 调度:支持在线抓取、离线清洗入库、全流程三种模式。""" +"""ETL 调度:支持在线抓取、离线清洗入库、全流程三种模式。 + +说明: + 为了便于排障与审计,调度器默认会在每次运行时将日志写入文件: + `io.log_root/.log`。 + + - 该文件路径会同步写入 `etl_admin.etl_run.log_path` 字段(由 RunTracker 记录)。 + - 文件日志通过给 root logger 动态挂载 FileHandler 实现,保证即便子模块使用 + `logging.getLogger(__name__)` 也能写入同一份日志文件。 +""" from __future__ import annotations +import logging import uuid from datetime import datetime from pathlib import Path @@ -50,6 +60,40 @@ class ETLScheduler: self.run_tracker = RunTracker(self.db_conn) self.task_registry = default_registry + def _attach_run_file_logger(self, run_uuid: str) -> logging.Handler | None: + """ + 为本次 run_uuid 动态挂载文件日志处理器。 + + 返回值: + - 成功:返回 FileHandler(调用方负责 removeHandler/close) + - 失败:返回 None(不中断主流程) + """ + log_root = Path(self.config["io"]["log_root"]) + try: + log_root.mkdir(parents=True, exist_ok=True) + except Exception as exc: # noqa: BLE001 + self.logger.warning("创建日志目录失败:%s(%s)", log_root, exc) + return None + + log_path = log_root / f"{run_uuid}.log" + try: + handler: logging.Handler = logging.FileHandler(log_path, encoding="utf-8") + except Exception as exc: # noqa: BLE001 + self.logger.warning("创建文件日志失败:%s(%s)", log_path, exc) + return None + + fmt = logging.Formatter( + fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler.setFormatter(fmt) + handler.setLevel(logging.INFO) + + # 挂到 root logger,保证各模块 logger 都能写入同一文件。 + root_logger = logging.getLogger() + root_logger.addHandler(handler) + return handler + # ------------------------------------------------------------------ public def run_tasks(self, task_codes: list | None = None): """按配置或传入列表执行任务。""" @@ -59,16 +103,28 @@ class ETLScheduler: if not task_codes: task_codes = self.config.get("run.tasks", []) - self.logger.info("开始运行任务: %s, run_uuid=%s", task_codes, run_uuid) + file_handler = self._attach_run_file_logger(run_uuid) + try: + self.logger.info("开始运行任务: %s, run_uuid=%s", task_codes, run_uuid) - for task_code in task_codes: - try: - self._run_single_task(task_code, run_uuid, store_id) - except Exception as exc: # noqa: BLE001 - self.logger.error("任务 %s 失败: %s", task_code, exc, exc_info=True) - continue + for task_code in task_codes: + try: + self._run_single_task(task_code, run_uuid, store_id) + except Exception as exc: # noqa: BLE001 + self.logger.error("任务 %s 失败: %s", task_code, exc, exc_info=True) + continue - self.logger.info("所有任务执行完成") + self.logger.info("所有任务执行完成") + finally: + if file_handler is not None: + try: + logging.getLogger().removeHandler(file_handler) + except Exception: + pass + try: + file_handler.close() + except Exception: + pass # ------------------------------------------------------------------ internals def _run_single_task(self, task_code: str, run_uuid: str, store_id: int): @@ -98,6 +154,37 @@ class ETLScheduler: fetch_stats = None try: + # ODS_* tasks (except ODS_JSON_ARCHIVE) don't implement extract/transform/load stages in this repo + # version, so we execute them as a single step with the appropriate API client. + if self._is_ods_task(task_code): + if self.pipeline_flow in {"FULL", "FETCH_ONLY"}: + result, _ = self._execute_ods_record_and_load(task_code, cursor_data, fetch_dir, run_id) + else: + source_dir = self._resolve_ingest_source(fetch_dir, None) + result = self._execute_ingest(task_code, cursor_data, source_dir) + + self.run_tracker.update_run( + run_id=run_id, + counts=result.get("counts") or {}, + status=self._map_run_status(result.get("status")), + ended_at=datetime.now(self.tz), + window=result.get("window"), + request_params=result.get("request_params"), + overlap_seconds=self.config.get("run.overlap_seconds"), + ) + + if (result.get("status") or "").upper() == "SUCCESS": + window = result.get("window") + if isinstance(window, dict): + self.cursor_mgr.advance( + task_id=task_id, + store_id=store_id, + window_start=window.get("start"), + window_end=window.get("end"), + run_id=run_id, + ) + return + if self._flow_includes_fetch(): fetch_stats = self._execute_fetch(task_code, cursor_data, fetch_dir, run_id) if self.pipeline_flow == "FETCH_ONLY": @@ -119,6 +206,9 @@ class ETLScheduler: counts=result["counts"], status=self._map_run_status(result["status"]), ended_at=datetime.now(self.tz), + window=result.get("window"), + request_params=result.get("request_params"), + overlap_seconds=self.config.get("run.overlap_seconds"), ) if (result.get("status") or "").upper() == "SUCCESS": @@ -158,7 +248,10 @@ class ETLScheduler: extracted = task.extract(context) # 抓取结束,不执行 transform/load stats = recording_client.last_dump or {} - fetched_count = stats.get("records") or len(extracted.get("records", [])) if isinstance(extracted, dict) else 0 + extracted_count = 0 + if isinstance(extracted, dict): + extracted_count = int(extracted.get("fetched") or 0) or len(extracted.get("records", [])) + fetched_count = stats.get("records") or extracted_count or 0 self.logger.info( "%s: 抓取完成,文件=%s,记录数=%s", task_code, @@ -167,6 +260,34 @@ class ETLScheduler: ) return {"file": stats.get("file"), "records": fetched_count, "pages": stats.get("pages")} + @staticmethod + def _is_ods_task(task_code: str) -> bool: + tc = str(task_code or "").upper() + return tc.startswith("ODS_") and tc != "ODS_JSON_ARCHIVE" + + def _execute_ods_record_and_load( + self, + task_code: str, + cursor_data: dict | None, + fetch_dir: Path, + run_id: int, + ) -> tuple[dict, dict]: + """ + Execute an ODS task with RecordingAPIClient so it fetches online and writes JSON dumps. + (ODS tasks in this repo perform DB upsert inside execute(); there is no staged extract/load.) + """ + recording_client = RecordingAPIClient( + base_client=self.api_client, + output_dir=fetch_dir, + task_code=task_code, + run_id=run_id, + write_pretty=self.write_pretty_json, + ) + task = self.task_registry.create_task(task_code, self.config, self.db_ops, recording_client, self.logger) + self.logger.info("%s: ODS fetch+load start, dir=%s", task_code, fetch_dir) + result = task.execute(cursor_data) + return result, (recording_client.last_dump or {}) + def _execute_ingest(self, task_code: str, cursor_data: dict | None, source_dir: Path): """本地清洗入库:使用 LocalJsonClient 回放 JSON,走原有任务 ETL。""" local_client = LocalJsonClient(source_dir) diff --git a/etl_billiards/orchestration/task_registry.py b/etl_billiards/orchestration/task_registry.py index 3882b67..251127e 100644 --- a/etl_billiards/orchestration/task_registry.py +++ b/etl_billiards/orchestration/task_registry.py @@ -23,6 +23,10 @@ from tasks.init_dwd_schema_task import InitDwdSchemaTask from tasks.dwd_load_task import DwdLoadTask from tasks.ticket_dwd_task import TicketDwdTask from tasks.dwd_quality_task import DwdQualityTask +from tasks.ods_json_archive_task import OdsJsonArchiveTask +from tasks.check_cutoff_task import CheckCutoffTask +from tasks.init_dws_schema_task import InitDwsSchemaTask +from tasks.dws_build_order_summary_task import DwsBuildOrderSummaryTask class TaskRegistry: """任务注册和工厂""" @@ -72,5 +76,9 @@ default_registry.register("INIT_ODS_SCHEMA", InitOdsSchemaTask) default_registry.register("INIT_DWD_SCHEMA", InitDwdSchemaTask) default_registry.register("DWD_LOAD_FROM_ODS", DwdLoadTask) default_registry.register("DWD_QUALITY_CHECK", DwdQualityTask) +default_registry.register("ODS_JSON_ARCHIVE", OdsJsonArchiveTask) +default_registry.register("CHECK_CUTOFF", CheckCutoffTask) +default_registry.register("INIT_DWS_SCHEMA", InitDwsSchemaTask) +default_registry.register("DWS_BUILD_ORDER_SUMMARY", DwsBuildOrderSummaryTask) for code, task_cls in ODS_TASK_CLASSES.items(): default_registry.register(code, task_cls) diff --git a/etl_billiards/reports/__init__.py b/etl_billiards/reports/__init__.py new file mode 100644 index 0000000..2d9f13d --- /dev/null +++ b/etl_billiards/reports/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +"""Reports package.""" + diff --git a/etl_billiards/reports/assistant_extra_lessons_13811638071_2025-11-14.csv b/etl_billiards/reports/assistant_extra_lessons_13811638071_2025-11-14.csv new file mode 100644 index 0000000..73bcbb0 --- /dev/null +++ b/etl_billiards/reports/assistant_extra_lessons_13811638071_2025-11-14.csv @@ -0,0 +1,7 @@ +会员名称,会员手机号,订单号,开始时间,助教课时,结账时间 +葛先生,13811638071,2987682979566149,2025-12-01 00:08:19,小燕(2h0m),2025-12-01 00:08:25 +葛先生,13811638071,2996398357319941,2025-12-07 03:54:04,小燕(6h0m),2025-12-07 03:54:45 +葛先生,13811638071,3003030187398085,2025-12-11 20:20:19,小燕(4h0m),2025-12-11 20:20:22 +葛先生,13811638071,3008791223945669,2025-12-15 22:00:44,小燕(2h0m),2025-12-15 22:00:48 +葛先生,13811638071,3010440130430917,2025-12-17 01:58:06,小燕(2h0m),2025-12-17 01:58:11 +葛先生,13811638071,3025720252254149,2025-12-27 21:01:50,小燕(4h0m),2025-12-27 21:01:53 diff --git a/etl_billiards/reports/assistant_orders_13811638071_2025-09-01.csv b/etl_billiards/reports/assistant_orders_13811638071_2025-09-01.csv new file mode 100644 index 0000000..39e4855 --- /dev/null +++ b/etl_billiards/reports/assistant_orders_13811638071_2025-09-01.csv @@ -0,0 +1,153 @@ +会员名称,会员手机号,订单号,桌台(房间)名称,开始时间,持续时间,助教挂钟信息,结账时间 +葛先生,13811638071,2957496003612357,B8,2025-11-09 16:20:32,2h 0m,周周(1h57m),2025-11-09 18:27:12 +葛先生,13811638071,2982280563167941,A2,2025-11-27 04:32:42,4h 53m,阿清(4h49m) 小燕(4h53m),2025-11-27 11:33:19 +葛先生,13811638071,2984703823612613,VIP5,2025-11-28 21:37:46,10h 8m,小燕(8h39m) 阿清(10h7m),2025-11-29 07:46:38 +葛先生,13811638071,2986305074877125,VIP5,2025-11-30 00:46:38,6h 16m,小燕(6h15m),2025-11-30 07:06:16 +葛先生,13811638071,2987452433699397,S1,2025-11-30 20:13:48,2h 15m,小燕(1h59m) 乔西(2h15m),2025-11-30 22:29:58 +葛先生,13811638071,2990547445273157,TV,2025-12-03 00:42:12,8h 10m,小燕(8h10m),2025-12-03 08:52:52 +葛先生,13811638071,2991901466284741,A3,2025-12-03 23:39:35,7h 16m,小燕(7h16m),2025-12-04 09:59:55 +葛先生,13811638071,2993396100174533,A3,2025-12-05 01:00:00,0h 25m,,2025-12-05 09:16:58 +葛先生,13811638071,2993421047679685,TV,2025-12-05 01:25:23,7h 50m,小燕(8h16m),2025-12-05 09:16:58 +葛先生,13811638071,2994596202270981,B3,2025-12-05 21:20:49,0h 35m,,2025-12-06 03:19:33 +葛先生,13811638071,2994624795250949,TV,2025-12-05 21:49:54,5h 29m,小燕(5h29m),2025-12-06 03:19:33 +葛先生,13811638071,3002915771880325,TV,2025-12-11 18:23:55,1h 6m,球球(1h6m),2025-12-12 05:16:57 +葛先生,13811638071,3003029803307781,A6,2025-12-11 20:19:55,2h 50m,小燕(2h50m),2025-12-12 01:50:19 +葛先生,13811638071,3003192948659141,888,2025-12-11 23:05:52,6h 0m,涛涛(5h51m) 小燕(5h56m) 梦梦(2h52m) 年糕(2h38m),2025-12-12 05:16:57 +葛先生,13811638071,3003354125276101,A1,2025-12-12 01:49:50,0h 0m,,2025-12-12 01:50:19 +葛先生,13811638071,3003355747341189,补时长4,2025-12-12 01:51:29,0h 1m,,2025-12-12 01:51:48 +葛先生,13811638071,3004579787884613,TV,2025-12-12 22:36:38,8h 10m,小燕(8h10m),2025-12-13 06:47:52 +葛先生,13811638071,3005655255435397,A11,2025-12-13 16:50:40,4h 53m,小燕(4h53m),2025-12-13 21:44:28 +葛先生,13811638071,3005944536778822,补时长7,2025-12-13 21:44:56,2h 0m,,2025-12-13 21:45:20 +葛先生,13811638071,3006295607298309,A1,2025-12-14 03:42:04,0h 43m,小燕(0h43m),2025-12-14 04:26:40 +葛先生,13811638071,3007126951397381,A1,2025-12-14 17:47:45,2h 5m,小燕(2h5m),2025-12-14 19:53:51 +葛先生,13811638071,3007250978523205,补时长5,2025-12-14 19:53:55,0h 1m,,2025-12-14 19:54:17 +葛先生,13811638071,3007446854797445,TV,2025-12-14 23:13:10,5h 57m,阿清(4h5m) 阿清(1h5m) 小燕(5h57m),2025-12-15 05:11:18 +葛先生,13811638071,3008791817832389,A1,2025-12-15 22:01:20,2h 19m,小燕(2h19m),2025-12-16 02:42:42 +葛先生,13811638071,3008834924841285,S1,2025-12-15 22:45:11,3h 55m,苏苏(2h28m),2025-12-16 02:42:42 +葛先生,13811638071,3009076506036165,A2,2025-12-16 02:50:56,3h 40m,小燕(3h39m),2025-12-16 06:32:44 +葛先生,13811638071,3009492999244293,补时长7,2025-12-16 09:54:37,3h 0m,,2025-12-16 09:54:56 +葛先生,13811638071,3010218131671557,A1,2025-12-16 22:12:16,3h 18m,小燕(3h18m) 阿清(2h43m),2025-12-17 01:31:11 +葛先生,13811638071,3011469546309317,A1,2025-12-17 19:25:16,1h 34m,小燕(1h34m),2025-12-17 22:04:09 +葛先生,13811638071,3011552955975237,C2,2025-12-17 20:50:07,1h 13m,小燕(1h3m),2025-12-17 22:04:09 +葛先生,13811638071,3011630421837381,A18,2025-12-17 22:08:55,3h 3m,小燕(3h3m),2025-12-18 01:12:30 +葛先生,13811638071,3012931996487173,TV,2025-12-18 20:12:57,5h 38m,小燕(5h38m),2025-12-19 01:53:16 +葛先生,13811638071,3013266727145349,补时长5,2025-12-19 01:53:27,2h 0m,,2025-12-19 01:53:55 +葛先生,13811638071,3013302603681669,A6,2025-12-19 02:29:57,0h 30m,小燕(0h29m),2025-12-19 03:00:10 +葛先生,13811638071,3013331369283205,A1,2025-12-19 02:59:12,0h 18m,,2025-12-19 02:59:41 +葛先生,13811638071,3014164982173317,A6,2025-12-19 17:07:12,11h 18m,小燕(11h18m) 阿清(2h0m),2025-12-20 06:59:01 +葛先生,13811638071,3014479760183173,TV,2025-12-19 22:27:25,8h 31m,阿清(8h22m) 小燕(2h53m),2025-12-20 06:59:01 +葛先生,13811638071,3015171680994757,补时长7,2025-12-20 10:11:16,3h 0m,,2025-12-20 10:11:36 +葛先生,13811638071,3015847664387653,S2,2025-12-20 21:38:55,0h 56m,球球(0h56m),2025-12-20 22:38:26 +葛先生,13811638071,3015856577545861,S1,2025-12-20 21:47:59,1h 12m,小燕(1h12m),2025-12-20 23:01:49 +葛先生,13811638071,3015974337283525,补时长6,2025-12-20 23:47:46,3h 0m,,2025-12-20 23:48:13 +葛先生,13811638071,3015974988367429,C4,2025-12-20 23:48:26,4h 28m,小燕(4h28m),2025-12-21 04:17:32 +葛先生,13811638071,3016300766643845,TV,2025-12-21 05:19:50,0h 56m,小燕(0h56m),2025-12-21 06:16:53 +葛先生,13811638071,3016653147375173,补时长7,2025-12-21 11:18:18,2h 0m,,2025-12-21 11:18:40 +葛先生,13811638071,3017225201927749,S1,2025-12-21 21:00:13,1h 28m,,2025-12-21 22:30:16 +葛先生,13811638071,3017273506465285,C5,2025-12-21 21:49:21,1h 24m,千千(1h12m),2025-12-21 23:45:38 +葛先生,13811638071,3017388287231429,补时长6,2025-12-21 23:46:07,0h 0m,,2025-12-21 23:46:27 +葛先生,13811638071,3017486080099845,A1,2025-12-22 01:25:36,1h 49m,小燕(1h49m),2025-12-22 03:15:17 +葛先生,13811638071,3017593957697157,补时长6,2025-12-22 03:15:20,0h 0m,,2025-12-22 03:15:37 +葛先生,13811638071,3017596515419717,A1,2025-12-22 03:17:56,3h 49m,小燕(3h49m),2025-12-22 07:08:11 +葛先生,13811638071,3018642766775813,S1,2025-12-22 21:02:14,1h 7m,小燕(1h7m),2025-12-22 22:10:33 +葛先生,13811638071,3018644316587589,补时长7,2025-12-22 21:03:49,0h 0m,,2025-12-22 21:04:09 +葛先生,13811638071,3018709942093445,S1,2025-12-22 22:10:34,0h 43m,小燕(0h43m),2025-12-22 22:54:21 +葛先生,13811638071,3018710150219205,补时长6,2025-12-22 22:10:47,0h 0m,,2025-12-22 22:11:08 +葛先生,13811638071,3018753009468933,S1,2025-12-22 22:54:23,1h 3m,小燕(1h3m),2025-12-22 23:58:44 +葛先生,13811638071,3018766856488389,补时长6,2025-12-22 23:08:28,0h 0m,,2025-12-22 23:08:51 +葛先生,13811638071,3018816489850309,补时长7,2025-12-22 23:58:58,0h 0m,,2025-12-22 23:59:34 +葛先生,13811638071,3018826595370437,M7,2025-12-23 00:09:14,4h 0m,小燕(3h59m),2025-12-23 04:23:37 +葛先生,13811638071,3019076971070917,补时长6,2025-12-23 04:23:56,2h 0m,,2025-12-23 04:24:21 +葛先生,13811638071,3019077416650181,A1,2025-12-23 04:24:23,3h 7m,小燕(3h7m),2025-12-23 07:32:22 +葛先生,13811638071,3019262758979077,补时长6,2025-12-23 07:32:56,0h 0m,,2025-12-23 07:34:10 +葛先生,13811638071,3020101562009093,A6,2025-12-23 21:46:12,10h 2m,小燕(10h1m),2025-12-24 07:48:47 +葛先生,13811638071,3020694538569157,补时长5,2025-12-24 07:49:25,3h 0m,,2025-12-24 07:49:46 +葛先生,13811638071,3021420856576006,S1,2025-12-24 20:08:16,1h 22m,小燕(1h22m),2025-12-24 21:30:46 +葛先生,13811638071,3021501988194373,S1,2025-12-24 21:30:47,1h 50m,小燕(1h49m),2025-12-24 23:21:38 +葛先生,13811638071,3021610987734853,S1,2025-12-24 23:21:40,2h 21m,小燕(2h21m),2025-12-25 01:43:51 +葛先生,13811638071,3021770635823109,A6,2025-12-25 02:04:04,7h 56m,小燕(7h56m),2025-12-25 12:08:55 +葛先生,13811638071,3022365496854533,补时长6,2025-12-25 12:09:12,4h 0m,,2025-12-25 12:09:46 +葛先生,13811638071,3022873228429253,S1,2025-12-25 20:45:41,1h 55m,小燕(1h55m),2025-12-25 22:42:03 +葛先生,13811638071,3022987729340421,S1,2025-12-25 22:42:10,1h 9m,小燕(1h9m),2025-12-25 23:52:24 +葛先生,13811638071,3023056813000773,S1,2025-12-25 23:52:26,0h 58m,小燕(0h58m),2025-12-26 00:51:10 +葛先生,13811638071,3023057336322053,补时长5,2025-12-25 23:52:58,0h 0m,,2025-12-25 23:53:18 +葛先生,13811638071,3023118312458181,A6,2025-12-26 00:55:00,5h 59m,小燕(5h59m),2025-12-26 06:55:32 +葛先生,13811638071,3024330328901573,C1,2025-12-26 21:27:56,3h 14m,小燕(3h14m),2025-12-27 00:42:31 +葛先生,13811638071,3024521672149061,A6,2025-12-27 00:42:34,4h 56m,小燕(4h56m),2025-12-27 05:43:00 +葛先生,13811638071,3024522484762565,补时长6,2025-12-27 00:43:24,2h 0m,,2025-12-27 00:43:51 +葛先生,13811638071,3025075373508677,补时长7,2025-12-27 10:05:50,2h 0m,,2025-12-27 10:06:18 +葛先生,13811638071,3025697485457477,A12,2025-12-27 20:38:40,0h 24m,小燕(0h24m),2025-12-27 21:03:38 +葛先生,13811638071,3025926993020741,A18,2025-12-28 00:32:08,3h 59m,小燕(3h58m),2025-12-28 04:31:30 +葛先生,13811638071,3026162441734149,补时长5,2025-12-28 04:31:39,1h 40m,,2025-12-28 04:32:29 +葛先生,13811638071,3027018732144709,A8,2025-12-28 19:02:43,1h 5m,小燕(1h5m),2025-12-28 20:08:25 +葛先生,13811638071,3027082894100549,S1,2025-12-28 20:07:59,0h 47m,小燕(0h47m),2025-12-28 20:55:54 +葛先生,13811638071,3027086248364101,A8,2025-12-28 20:11:24,0h 32m,,2025-12-28 20:11:45 +葛先生,13811638071,3027130023888837,S1,2025-12-28 20:55:56,0h 42m,,2025-12-28 22:48:42 +葛先生,13811638071,3027130329974789,A6,2025-12-28 20:56:14,0h 32m,,2025-12-28 20:56:31 +葛先生,13811638071,3027171803039749,VIP5,2025-12-28 21:38:25,1h 9m,小燕(1h52m),2025-12-28 22:48:42 +葛先生,13811638071,3027240911620101,VIP5,2025-12-28 22:48:44,0h 42m,小燕(0h42m),2025-12-28 23:32:00 +葛先生,13811638071,3027274797680709,A1,2025-12-28 23:23:12,0h 4m,,2025-12-29 00:45:50 +葛先生,13811638071,3027279097726917,C2,2025-12-28 23:27:34,1h 17m,婉婉(1h17m),2025-12-29 00:45:50 +葛先生,13811638071,3027283487705029,VIP5,2025-12-28 23:32:02,1h 38m,小燕(1h38m),2025-12-29 01:10:27 +葛先生,13811638071,3027381047756741,补时长7,2025-12-29 01:11:17,0h 0m,,2025-12-29 01:11:47 +葛先生,13811638071,3027494215747397,A1,2025-12-29 03:06:24,0h 0m,小燕(1h50m),2025-12-29 03:06:51 +葛先生,13811638071,3027520016877573,补时长5,2025-12-29 03:32:39,1h 40m,,2025-12-29 03:33:33 +葛先生,13811638071,3028365240272837,A6,2025-12-29 17:52:27,2h 3m,小燕(2h3m),2025-12-29 19:58:42 +葛先生,13811638071,3028488531724357,M5,2025-12-29 19:57:52,3h 3m,小燕(3h3m),2025-12-29 23:04:58 +葛先生,13811638071,3028489795159877,A6,2025-12-29 19:59:09,1h 2m,,2025-12-29 19:59:31 +葛先生,13811638071,3028620706809861,VIP5,2025-12-29 22:12:19,1h 2m,小柔(1h2m) 小燕(0h6m),2025-12-29 23:16:04 +葛先生,13811638071,3028672505759557,补时长7,2025-12-29 23:05:01,0h 0m,,2025-12-29 23:05:28 +葛先生,13811638071,3028796631942981,A1,2025-12-30 01:11:17,0h 0m,小燕(1h12m),2025-12-30 01:12:20 +葛先生,13811638071,3028797785933637,补时长6,2025-12-30 01:12:28,0h 0m,,2025-12-30 01:12:43 +葛先生,13811638071,3028798611277893,A6,2025-12-30 01:13:18,0h 51m,小燕(0h50m),2025-12-30 02:04:34 +葛先生,13811638071,3029823520163653,A6,2025-12-30 18:35:53,1h 30m,小燕(1h30m),2025-12-30 20:06:17 +葛先生,13811638071,3029912671930309,A2,2025-12-30 20:06:35,0h 45m,,2025-12-30 20:07:12 +葛先生,13811638071,3029920336283461,S1,2025-12-30 20:14:23,1h 9m,小燕(1h8m),2025-12-30 21:26:01 +葛先生,13811638071,3029953608239109,S3,2025-12-30 20:48:13,0h 54m,,2025-12-30 21:43:22 +葛先生,13811638071,3029990793086789,S1,2025-12-30 21:26:03,1h 22m,小燕(1h1m) 阿清(0h20m),2025-12-30 22:48:21 +葛先生,13811638071,3029991615416389,A6,2025-12-30 21:26:53,0h 50m,,2025-12-30 21:27:30 +葛先生,13811638071,3030051846162437,A6,2025-12-30 22:28:09,1h 35m,小燕(1h13m),2025-12-31 00:03:58 +葛先生,13811638071,3030071938451269,S1,2025-12-30 22:48:36,0h 45m,阿清(0h45m),2025-12-30 23:34:52 +葛先生,13811638071,3030329100781637,补时长2,2025-12-31 03:10:12,0h 1m,,2025-12-31 03:10:58 +葛先生,13811638071,3031047867485317,VIP1,2025-12-31 15:21:22,2h 15m,小燕(2h15m),2025-12-31 18:30:37 +葛先生,13811638071,3031246571603653,补时长2,2025-12-31 18:43:30,0h 0m,,2025-12-31 18:44:20 +葛先生,13811638071,3031246638532229,补时长3,2025-12-31 18:43:34,0h 0m,,2025-12-31 18:44:20 +葛先生,13811638071,3036870032656645,A6,2026-01-04 18:03:58,2h 15m,小燕(2h15m),2026-01-04 20:20:13 +葛先生,13811638071,3037004116691653,补时长7,2026-01-04 20:20:22,0h 0m,,2026-01-04 20:20:58 +葛先生,13811638071,3037130243574469,A7,2026-01-04 22:28:40,0h 0m,小燕(2h3m),2026-01-04 22:29:02 +葛先生,13811638071,3037131328588485,M5,2026-01-04 22:29:47,3h 9m,,2026-01-05 01:44:43 +葛先生,13811638071,3037318211751109,补时长7,2026-01-05 01:39:53,0h 0m,,2026-01-05 01:40:11 +葛先生,13811638071,3038443483466437,A6,2026-01-05 20:44:34,0h 21m,,2026-01-06 02:31:25 +葛先生,13811638071,3038464422284485,M5,2026-01-05 21:05:52,3h 0m,,2026-01-06 02:31:25 +葛先生,13811638071,3038582201207493,S1,2026-01-05 23:05:41,0h 57m,乔西(0h57m) 小燕(0h21m),2026-01-06 02:31:25 +葛先生,13811638071,3038597488594117,补时长7,2026-01-05 23:21:14,0h 0m,小燕(2h35m),2026-01-05 23:22:18 +葛先生,13811638071,3038663145786693,TV,2026-01-06 00:28:01,0h 0m,阿清(1h59m) 小燕(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,TV,2026-01-06 00:28:51,1h 59m,阿清(1h59m) 小燕(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3039616315803525,A1,2026-01-06 16:37:38,8h 50m,小燕(8h50m),2026-01-07 01:28:11 +葛先生,13811638071,3039824130983749,M7,2026-01-06 20:09:02,3h 22m,小燕(3h22m),2026-01-06 23:36:08 +葛先生,13811638071,3040027900495749,补时长7,2026-01-06 23:36:19,2h 0m,,2026-01-06 23:36:43 +葛先生,13811638071,3040128992692037,TV,2026-01-07 01:19:10,2h 46m,小燕(2h37m),2026-01-07 04:06:07 +葛先生,13811638071,3040137917564805,补时长7,2026-01-07 01:28:14,3h 0m,,2026-01-07 01:28:39 +葛先生,13811638071,3040888209426117,A6,2026-01-07 14:11:29,10h 24m,小燕(10h24m),2026-01-08 05:48:14 +葛先生,13811638071,3041240108402437,VIP5,2026-01-07 20:09:27,1h 11m,小燕(1h11m),2026-01-07 21:21:15 +葛先生,13811638071,3041310718166853,VIP5,2026-01-07 21:21:16,0h 46m,小燕(0h46m) 小燕(0h0m),2026-01-07 22:08:05 +葛先生,13811638071,3041356781012741,VIP5,2026-01-07 22:08:08,1h 2m,小燕(1h1m),2026-01-07 23:10:30 +葛先生,13811638071,3041418120349573,VIP5,2026-01-07 23:10:32,0h 49m,小燕(0h48m),2026-01-08 00:04:02 +葛先生,13811638071,3041502086645445,TV,2026-01-08 00:35:57,5h 11m,小燕(5h11m) 阿清(1h55m),2026-01-08 05:48:14 +葛先生,13811638071,3042589002975045,A6,2026-01-08 19:01:37,4h 9m,小燕(0h0m) 小燕(4h9m) 小燕(0h0m),2026-01-08 23:11:19 +葛先生,13811638071,3042590786307781,S1,2026-01-08 19:03:26,1h 50m,小燕(0h0m),2026-01-08 21:21:03 +葛先生,13811638071,3042699136239301,VIP5,2026-01-08 20:53:38,0h 27m,小燕(2h17m),2026-01-08 21:21:03 +葛先生,13811638071,3042723784541957,TV,2026-01-08 21:18:43,1h 13m,年糕(0h19m) 年糕(0h0m) 年糕(1h28m),2026-01-09 00:02:40 +葛先生,13811638071,3042726147917509,VIP5,2026-01-08 21:21:07,1h 22m,小燕(1h23m),2026-01-08 22:43:49 +葛先生,13811638071,3042796595971845,TV,2026-01-08 22:32:47,1h 28m,年糕(0h19m) 年糕(0h0m) 年糕(1h28m),2026-01-09 00:02:40 +葛先生,13811638071,3042807473456837,VIP5,2026-01-08 22:43:51,0h 26m,小燕(0h26m),2026-01-08 23:11:19 +葛先生,13811638071,3042835118278341,补时长7,2026-01-08 23:11:58,2h 0m,,2026-01-08 23:12:30 +葛先生,13811638071,3042885054465733,补时长7,2026-01-09 00:02:46,0h 0m,,2026-01-09 00:03:28 +葛先生,13811638071,3043910456903429,A6,2026-01-09 17:25:52,7h 34m,小燕(0h0m) 小燕(7h34m) 小燕(0h0m),2026-01-10 01:01:10 +葛先生,13811638071,3044081890182917,S2,2026-01-09 20:20:15,2h 15m,千千(2h13m),2026-01-09 22:38:08 +葛先生,13811638071,3044086761490309,666,2026-01-09 20:25:13,1h 54m,,2026-01-09 22:22:52 +葛先生,13811638071,3044105870411525,M7,2026-01-09 20:44:39,3h 49m,小燕(0h0m) 小燕(3h43m),2026-01-10 00:39:15 +葛先生,13811638071,3044358662178117,补时长6,2026-01-10 01:01:48,4h 0m,,2026-01-10 01:02:29 +葛先生,13811638071,3045217778599621,A6,2026-01-10 15:35:44,7h 33m,小燕(0h0m) 小燕(7h33m),2026-01-10 23:10:26 +葛先生,13811638071,3045663905318661,VIP5,2026-01-10 23:09:34,3h 7m,小燕(3h7m),2026-01-11 02:17:13 +葛先生,13811638071,3045763277047109,补时长5,2026-01-11 00:50:39,2h 2m,,2026-01-11 02:04:36 diff --git a/etl_billiards/reports/assistant_orders_13811638071_2025-11-01.csv b/etl_billiards/reports/assistant_orders_13811638071_2025-11-01.csv new file mode 100644 index 0000000..bd41001 --- /dev/null +++ b/etl_billiards/reports/assistant_orders_13811638071_2025-11-01.csv @@ -0,0 +1,174 @@ +会员名称,会员手机号,订单号,桌台(房间)名称,开始时间,持续时间,助教挂钟信息,结账时间 +葛先生,13811638071,2955001404426373,TV,2025-11-07 22:02:54,6h 17m,周周(6h17m),2025-11-08 04:22:47 +葛先生,13811638071,2957496003612357,B8,2025-11-09 16:20:32,2h 0m,周周(1h57m),2025-11-09 18:27:12 +葛先生,13811638071,2958114600914629,TV,2025-11-09 21:09:49,2h 46m,周周(2h46m) 素素(5h52m),2025-11-10 05:50:36 +葛先生,13811638071,2958114600914629,S1,2025-11-09 23:55:55,2h 53m,,2025-11-10 05:50:36 +葛先生,13811638071,2958114600914629,TV,2025-11-10 02:49:48,2h 59m,周周(2h46m) 素素(5h52m),2025-11-10 05:50:36 +葛先生,13811638071,2963565616418565,VIP5,2025-11-13 23:14:52,3h 31m,年糕(3h28m),2025-11-14 02:46:30 +葛先生,13811638071,2965176087987909,S1,2025-11-15 00:32:38,2h 0m,,2025-11-15 05:16:43 +葛先生,13811638071,2965176087987909,TV,2025-11-15 02:33:07,2h 43m,千千(4h43m) 小燕(2h48m),2025-11-15 05:16:43 +葛先生,13811638071,2966373430363909,888,2025-11-15 22:51:07,3h 25m,柚子(3h25m) 千千(3h21m) 素素(3h12m) 年糕(3h16m) 球球(0h55m),2025-11-16 02:23:36 +葛先生,13811638071,2970984317881221,TV,2025-11-18 21:32:20,7h 7m,,2025-11-19 05:34:46 +葛先生,13811638071,2970984317881221,补时长6,2025-11-19 05:01:34,0h 0m,梦梦(4h27m) 千千(3h7m) 小燕(2h49m),2025-11-19 05:34:46 +葛先生,13811638071,2972114730570373,VIP5,2025-11-19 22:30:59,1h 40m,小燕(1h39m),2025-11-20 02:40:52 +葛先生,13811638071,2972114730570373,TV,2025-11-20 00:11:28,2h 29m,小燕(2h29m),2025-11-20 02:40:52 +葛先生,13811638071,2972261781556293,补时长3,2025-11-20 02:41:04,0h 0m,,2025-11-20 02:41:33 +葛先生,13811638071,2972261781556293,补时长4,2025-11-20 02:41:06,0h 0m,,2025-11-20 02:41:33 +葛先生,13811638071,2973537861161797,TV,2025-11-21 00:19:09,5h 41m,小燕(5h41m) Amy(1h34m),2025-11-21 06:02:46 +葛先生,13811638071,2976440977246341,TV,2025-11-23 01:32:22,5h 53m,小燕(5h53m),2025-11-23 07:26:33 +葛先生,13811638071,2979199778228165,S1,2025-11-24 23:22:58,0h 56m,阿清(0h54m) 小燕(0h9m),2025-11-25 01:10:58 +葛先生,13811638071,2979199778228165,TV,2025-11-25 00:18:45,0h 44m,小燕(0h44m) 阿清(0h44m),2025-11-25 01:10:58 +葛先生,13811638071,2979241077901253,M3,2025-11-25 01:00:46,3h 10m,千千(3h10m) 小燕(3h8m) 阿清(3h8m),2025-11-25 04:13:47 +葛先生,13811638071,2980539547354565,A2,2025-11-25 22:44:25,0h 24m,小燕(0h24m),2025-11-26 05:10:08 +葛先生,13811638071,2980539547354565,VIP5,2025-11-25 23:01:38,6h 7m,小燕(6h0m),2025-11-26 05:10:08 +葛先生,13811638071,2982280563167941,A2,2025-11-27 04:32:42,4h 53m,小燕(4h53m) 阿清(4h49m),2025-11-27 11:33:19 +葛先生,13811638071,2984703823612613,VIP5,2025-11-28 21:37:46,10h 8m,阿清(10h7m) 小燕(8h39m),2025-11-29 07:46:38 +葛先生,13811638071,2986305074877125,VIP5,2025-11-30 00:46:38,6h 16m,小燕(6h15m),2025-11-30 07:06:16 +葛先生,13811638071,2987452433699397,S1,2025-11-30 20:13:48,2h 15m,乔西(2h15m) 小燕(1h59m),2025-11-30 22:29:58 +葛先生,13811638071,2990547445273157,TV,2025-12-03 00:42:12,8h 10m,小燕(8h10m),2025-12-03 08:52:52 +葛先生,13811638071,2991901466284741,A3,2025-12-03 23:39:35,7h 16m,小燕(7h16m),2025-12-04 09:59:55 +葛先生,13811638071,2993421047679685,A3,2025-12-05 01:00:00,0h 25m,,2025-12-05 09:16:58 +葛先生,13811638071,2993421047679685,TV,2025-12-05 01:25:23,7h 50m,小燕(8h16m),2025-12-05 09:16:58 +葛先生,13811638071,2994624795250949,B3,2025-12-05 21:20:49,0h 35m,,2025-12-06 03:19:33 +葛先生,13811638071,2994624795250949,TV,2025-12-05 21:49:54,5h 29m,小燕(5h29m),2025-12-06 03:19:33 +葛先生,13811638071,3003192948659141,TV,2025-12-11 18:23:55,1h 6m,球球(1h6m),2025-12-12 05:16:57 +葛先生,13811638071,3003354125276101,A6,2025-12-11 20:19:55,2h 50m,小燕(2h50m),2025-12-12 01:50:19 +葛先生,13811638071,3003192948659141,888,2025-12-11 23:05:52,6h 0m,小燕(5h56m) 梦梦(2h52m) 涛涛(5h51m) 年糕(2h38m),2025-12-12 05:16:57 +葛先生,13811638071,3003354125276101,A1,2025-12-12 01:49:50,0h 0m,,2025-12-12 01:50:19 +葛先生,13811638071,3003355747341189,补时长4,2025-12-12 01:51:29,0h 1m,,2025-12-12 01:51:48 +葛先生,13811638071,3004579787884613,TV,2025-12-12 22:36:38,8h 10m,小燕(8h10m),2025-12-13 06:47:52 +葛先生,13811638071,3005655255435397,A11,2025-12-13 16:50:40,4h 53m,小燕(4h53m),2025-12-13 21:44:28 +葛先生,13811638071,3005944536778822,补时长7,2025-12-13 21:44:56,2h 0m,,2025-12-13 21:45:20 +葛先生,13811638071,3006295607298309,A1,2025-12-14 03:42:04,0h 43m,小燕(0h43m),2025-12-14 04:26:40 +葛先生,13811638071,3007126951397381,A1,2025-12-14 17:47:45,2h 5m,小燕(2h5m),2025-12-14 19:53:51 +葛先生,13811638071,3007250978523205,补时长5,2025-12-14 19:53:55,0h 1m,,2025-12-14 19:54:17 +葛先生,13811638071,3007446854797445,TV,2025-12-14 23:13:10,5h 57m,小燕(5h57m) 阿清(5h10m),2025-12-15 05:11:18 +葛先生,13811638071,3008834924841285,A1,2025-12-15 22:01:20,2h 19m,小燕(2h19m),2025-12-16 02:42:42 +葛先生,13811638071,3008834924841285,S1,2025-12-15 22:45:11,3h 55m,苏苏(2h28m),2025-12-16 02:42:42 +葛先生,13811638071,3009076506036165,A2,2025-12-16 02:50:56,3h 40m,小燕(3h39m),2025-12-16 06:32:44 +葛先生,13811638071,3009492999244293,补时长7,2025-12-16 09:54:37,3h 0m,,2025-12-16 09:54:56 +葛先生,13811638071,3010218131671557,A1,2025-12-16 22:12:16,3h 18m,小燕(3h18m) 阿清(2h43m),2025-12-17 01:31:11 +葛先生,13811638071,3011552955975237,A1,2025-12-17 19:25:16,1h 34m,小燕(1h34m),2025-12-17 22:04:09 +葛先生,13811638071,3011552955975237,C2,2025-12-17 20:50:07,1h 13m,小燕(1h3m),2025-12-17 22:04:09 +葛先生,13811638071,3011630421837381,A18,2025-12-17 22:08:55,3h 3m,小燕(3h3m),2025-12-18 01:12:30 +葛先生,13811638071,3012931996487173,TV,2025-12-18 20:12:57,5h 38m,小燕(5h38m),2025-12-19 01:53:16 +葛先生,13811638071,3013266727145349,补时长5,2025-12-19 01:53:27,2h 0m,,2025-12-19 01:53:55 +葛先生,13811638071,3013302603681669,A6,2025-12-19 02:29:57,0h 30m,小燕(0h29m),2025-12-19 03:00:10 +葛先生,13811638071,3013331369283205,A1,2025-12-19 02:59:12,0h 18m,,2025-12-19 02:59:41 +葛先生,13811638071,3014479760183173,A6,2025-12-19 17:07:12,11h 18m,小燕(11h18m) 阿清(2h0m),2025-12-20 06:59:01 +葛先生,13811638071,3014479760183173,TV,2025-12-19 22:27:25,8h 31m,小燕(2h53m) 阿清(8h22m),2025-12-20 06:59:01 +葛先生,13811638071,3015171680994757,补时长7,2025-12-20 10:11:16,3h 0m,,2025-12-20 10:11:36 +葛先生,13811638071,3015904480560645,S2,2025-12-20 21:38:55,0h 56m,球球(0h56m),2025-12-20 22:38:26 +葛先生,13811638071,3015928035247749,S1,2025-12-20 21:47:59,1h 12m,小燕(1h12m),2025-12-20 23:01:49 +葛先生,13811638071,3015974337283525,补时长6,2025-12-20 23:47:46,3h 0m,,2025-12-20 23:48:13 +葛先生,13811638071,3015974988367429,C4,2025-12-20 23:48:26,4h 28m,小燕(4h28m),2025-12-21 04:17:32 +葛先生,13811638071,3016300766643845,TV,2025-12-21 05:19:50,0h 56m,小燕(0h56m),2025-12-21 06:16:53 +葛先生,13811638071,3016653147375173,补时长7,2025-12-21 11:18:18,2h 0m,,2025-12-21 11:18:40 +葛先生,13811638071,3017225201927749,S1,2025-12-21 21:00:13,1h 28m,,2025-12-21 22:30:16 +葛先生,13811638071,3017273506465285,C5,2025-12-21 21:49:21,1h 24m,千千(1h12m),2025-12-21 23:45:38 +葛先生,13811638071,3017388287231429,补时长6,2025-12-21 23:46:07,0h 0m,,2025-12-21 23:46:27 +葛先生,13811638071,3017486080099845,A1,2025-12-22 01:25:36,1h 49m,小燕(1h49m),2025-12-22 03:15:17 +葛先生,13811638071,3017593957697157,补时长6,2025-12-22 03:15:20,0h 0m,,2025-12-22 03:15:37 +葛先生,13811638071,3017596515419717,A1,2025-12-22 03:17:56,3h 49m,小燕(3h49m),2025-12-22 07:08:11 +葛先生,13811638071,3018642766775813,S1,2025-12-22 21:02:14,1h 7m,小燕(1h7m),2025-12-22 22:10:33 +葛先生,13811638071,3018644316587589,补时长7,2025-12-22 21:03:49,0h 0m,,2025-12-22 21:04:09 +葛先生,13811638071,3018709942093445,S1,2025-12-22 22:10:34,0h 43m,小燕(0h43m),2025-12-22 22:54:21 +葛先生,13811638071,3018710150219205,补时长6,2025-12-22 22:10:47,0h 0m,,2025-12-22 22:11:08 +葛先生,13811638071,3018753009468933,S1,2025-12-22 22:54:23,1h 3m,小燕(1h3m),2025-12-22 23:58:44 +葛先生,13811638071,3018766856488389,补时长6,2025-12-22 23:08:28,0h 0m,,2025-12-22 23:08:51 +葛先生,13811638071,3018816489850309,补时长7,2025-12-22 23:58:58,0h 0m,,2025-12-22 23:59:34 +葛先生,13811638071,3018826595370437,M7,2025-12-23 00:09:14,4h 0m,小燕(3h59m),2025-12-23 04:23:37 +葛先生,13811638071,3019076971070917,补时长6,2025-12-23 04:23:56,2h 0m,,2025-12-23 04:24:21 +葛先生,13811638071,3019077416650181,A1,2025-12-23 04:24:23,3h 7m,小燕(3h7m),2025-12-23 07:32:22 +葛先生,13811638071,3019262758979077,补时长6,2025-12-23 07:32:56,0h 0m,,2025-12-23 07:34:10 +葛先生,13811638071,3020101562009093,A6,2025-12-23 21:46:12,10h 2m,小燕(10h1m),2025-12-24 07:48:47 +葛先生,13811638071,3020694538569157,补时长5,2025-12-24 07:49:25,3h 0m,,2025-12-24 07:49:46 +葛先生,13811638071,3021420856576006,S1,2025-12-24 20:08:16,1h 22m,小燕(1h22m),2025-12-24 21:30:46 +葛先生,13811638071,3021501988194373,S1,2025-12-24 21:30:47,1h 50m,小燕(1h49m),2025-12-24 23:21:38 +葛先生,13811638071,3021610987734853,S1,2025-12-24 23:21:40,2h 21m,小燕(2h21m),2025-12-25 01:43:51 +葛先生,13811638071,3021770635823109,A6,2025-12-25 02:04:04,7h 56m,小燕(7h56m),2025-12-25 12:08:55 +葛先生,13811638071,3022365496854533,补时长6,2025-12-25 12:09:12,4h 0m,,2025-12-25 12:09:46 +葛先生,13811638071,3022873228429253,S1,2025-12-25 20:45:41,1h 55m,小燕(1h55m),2025-12-25 22:42:03 +葛先生,13811638071,3022987729340421,S1,2025-12-25 22:42:10,1h 9m,小燕(1h9m),2025-12-25 23:52:24 +葛先生,13811638071,3023056813000773,S1,2025-12-25 23:52:26,0h 58m,小燕(0h58m),2025-12-26 00:51:10 +葛先生,13811638071,3023057336322053,补时长5,2025-12-25 23:52:58,0h 0m,,2025-12-25 23:53:18 +葛先生,13811638071,3023118312458181,A6,2025-12-26 00:55:00,5h 59m,小燕(5h59m),2025-12-26 06:55:32 +葛先生,13811638071,3024330328901573,C1,2025-12-26 21:27:56,3h 14m,小燕(3h14m),2025-12-27 00:42:31 +葛先生,13811638071,3024521672149061,A6,2025-12-27 00:42:34,4h 56m,小燕(4h56m),2025-12-27 05:43:00 +葛先生,13811638071,3024522484762565,补时长6,2025-12-27 00:43:24,2h 0m,,2025-12-27 00:43:51 +葛先生,13811638071,3025075373508677,补时长7,2025-12-27 10:05:50,2h 0m,,2025-12-27 10:06:18 +葛先生,13811638071,3025697485457477,A12,2025-12-27 20:38:40,0h 24m,小燕(0h24m),2025-12-27 21:03:38 +葛先生,13811638071,3025926993020741,A18,2025-12-28 00:32:08,3h 59m,小燕(3h58m),2025-12-28 04:31:30 +葛先生,13811638071,3026162441734149,补时长5,2025-12-28 04:31:39,1h 40m,,2025-12-28 04:32:29 +葛先生,13811638071,3027018732144709,A8,2025-12-28 19:02:43,1h 5m,小燕(1h5m),2025-12-28 20:08:25 +葛先生,13811638071,3027082894100549,S1,2025-12-28 20:07:59,0h 47m,小燕(0h47m),2025-12-28 20:55:54 +葛先生,13811638071,3027086248364101,A8,2025-12-28 20:11:24,0h 32m,,2025-12-28 20:11:45 +葛先生,13811638071,3027171803039749,S1,2025-12-28 20:55:56,0h 42m,,2025-12-28 22:48:42 +葛先生,13811638071,3027130329974789,A6,2025-12-28 20:56:14,0h 32m,,2025-12-28 20:56:31 +葛先生,13811638071,3027171803039749,VIP5,2025-12-28 21:38:25,1h 9m,小燕(1h52m),2025-12-28 22:48:42 +葛先生,13811638071,3027240911620101,VIP5,2025-12-28 22:48:44,0h 42m,小燕(0h42m),2025-12-28 23:32:00 +葛先生,13811638071,3027279097726917,A1,2025-12-28 23:23:12,0h 4m,,2025-12-29 00:45:50 +葛先生,13811638071,3027279097726917,C2,2025-12-28 23:27:34,1h 17m,婉婉(1h17m),2025-12-29 00:45:50 +葛先生,13811638071,3027283487705029,VIP5,2025-12-28 23:32:02,1h 38m,小燕(1h38m),2025-12-29 01:10:27 +葛先生,13811638071,3027381047756741,补时长7,2025-12-29 01:11:17,0h 0m,,2025-12-29 01:11:47 +葛先生,13811638071,3027494215747397,A1,2025-12-29 03:06:24,0h 0m,小燕(1h50m),2025-12-29 03:06:51 +葛先生,13811638071,3027520016877573,补时长5,2025-12-29 03:32:39,1h 40m,,2025-12-29 03:33:33 +葛先生,13811638071,3028365240272837,A6,2025-12-29 17:52:27,2h 3m,小燕(2h3m),2025-12-29 19:58:42 +葛先生,13811638071,3028488531724357,M5,2025-12-29 19:57:52,3h 3m,小燕(3h3m),2025-12-29 23:04:58 +葛先生,13811638071,3028489795159877,A6,2025-12-29 19:59:09,1h 2m,,2025-12-29 19:59:31 +葛先生,13811638071,3028620706809861,VIP5,2025-12-29 22:12:19,1h 2m,小柔(1h2m) 小燕(0h6m),2025-12-29 23:16:04 +葛先生,13811638071,3028672505759557,补时长7,2025-12-29 23:05:01,0h 0m,,2025-12-29 23:05:28 +葛先生,13811638071,3028796631942981,A1,2025-12-30 01:11:17,0h 0m,小燕(1h12m),2025-12-30 01:12:20 +葛先生,13811638071,3028797785933637,补时长6,2025-12-30 01:12:28,0h 0m,,2025-12-30 01:12:43 +葛先生,13811638071,3028798611277893,A6,2025-12-30 01:13:18,0h 51m,小燕(0h50m),2025-12-30 02:04:34 +葛先生,13811638071,3029823520163653,A6,2025-12-30 18:35:53,1h 30m,小燕(1h30m),2025-12-30 20:06:17 +葛先生,13811638071,3029912671930309,A2,2025-12-30 20:06:35,0h 45m,,2025-12-30 20:07:12 +葛先生,13811638071,3029920336283461,S1,2025-12-30 20:14:23,1h 9m,小燕(1h8m),2025-12-30 21:26:01 +葛先生,13811638071,3029953608239109,S3,2025-12-30 20:48:13,0h 54m,,2025-12-30 21:43:22 +葛先生,13811638071,3029990793086789,S1,2025-12-30 21:26:03,1h 22m,小燕(1h1m) 阿清(0h20m),2025-12-30 22:48:21 +葛先生,13811638071,3029991615416389,A6,2025-12-30 21:26:53,0h 50m,,2025-12-30 21:27:30 +葛先生,13811638071,3030051846162437,A6,2025-12-30 22:28:09,1h 35m,小燕(1h13m),2025-12-31 00:03:58 +葛先生,13811638071,3030071938451269,S1,2025-12-30 22:48:36,0h 45m,阿清(0h45m),2025-12-30 23:34:52 +葛先生,13811638071,3030329100781637,补时长2,2025-12-31 03:10:12,0h 1m,,2025-12-31 03:10:58 +葛先生,13811638071,3031047867485317,VIP1,2025-12-31 15:21:22,2h 15m,小燕(2h15m),2025-12-31 18:30:37 +葛先生,13811638071,3031246638532229,补时长2,2025-12-31 18:43:30,0h 0m,,2025-12-31 18:44:20 +葛先生,13811638071,3031246638532229,补时长3,2025-12-31 18:43:34,0h 0m,,2025-12-31 18:44:20 +葛先生,13811638071,3036870032656645,A6,2026-01-04 18:03:58,2h 15m,小燕(2h15m),2026-01-04 20:20:13 +葛先生,13811638071,3037004116691653,补时长7,2026-01-04 20:20:22,0h 0m,,2026-01-04 20:20:58 +葛先生,13811638071,3037130243574469,A7,2026-01-04 22:28:40,0h 0m,小燕(2h3m),2026-01-04 22:29:02 +葛先生,13811638071,3037131328588485,M5,2026-01-04 22:29:47,3h 9m,,2026-01-05 01:44:43 +葛先生,13811638071,3037318211751109,补时长7,2026-01-05 01:39:53,0h 0m,,2026-01-05 01:40:11 +葛先生,13811638071,3038663954582853,A6,2026-01-05 20:44:34,0h 21m,,2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,M5,2026-01-05 21:05:52,3h 0m,,2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,S1,2026-01-05 23:05:41,0h 57m,乔西(0h57m) 小燕(0h21m),2026-01-06 02:31:25 +葛先生,13811638071,3038597488594117,补时长7,2026-01-05 23:21:14,0h 0m,小燕(2h35m),2026-01-05 23:22:18 +葛先生,13811638071,3038663954582853,TV,2026-01-06 00:28:01,0h 0m,小燕(1h59m) 阿清(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,TV,2026-01-06 00:28:51,1h 59m,小燕(1h59m) 阿清(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3039616315803525,A1,2026-01-06 16:37:38,8h 50m,小燕(8h50m),2026-01-07 01:28:11 +葛先生,13811638071,3039824130983749,M7,2026-01-06 20:09:02,3h 22m,小燕(3h22m),2026-01-06 23:36:08 +葛先生,13811638071,3040027900495749,补时长7,2026-01-06 23:36:19,2h 0m,,2026-01-06 23:36:43 +葛先生,13811638071,3040128992692037,TV,2026-01-07 01:19:10,2h 46m,小燕(2h37m),2026-01-07 04:06:07 +葛先生,13811638071,3040137917564805,补时长7,2026-01-07 01:28:14,3h 0m,,2026-01-07 01:28:39 +葛先生,13811638071,3041502086645445,A6,2026-01-07 14:11:29,10h 24m,小燕(10h24m),2026-01-08 05:48:14 +葛先生,13811638071,3041240108402437,VIP5,2026-01-07 20:09:27,1h 11m,小燕(1h11m),2026-01-07 21:21:15 +葛先生,13811638071,3041310718166853,VIP5,2026-01-07 21:21:16,0h 46m,小燕(0h46m),2026-01-07 22:08:05 +葛先生,13811638071,3041356781012741,VIP5,2026-01-07 22:08:08,1h 2m,小燕(1h1m),2026-01-07 23:10:30 +葛先生,13811638071,3041418120349573,VIP5,2026-01-07 23:10:32,0h 49m,小燕(0h48m),2026-01-08 00:04:02 +葛先生,13811638071,3041502086645445,TV,2026-01-08 00:35:57,5h 11m,阿清(1h55m) 小燕(5h11m),2026-01-08 05:48:14 +葛先生,13811638071,3042589002975045,A6,2026-01-08 19:01:37,4h 9m,小燕(6h9m),2026-01-08 23:11:19 +葛先生,13811638071,3042699136239301,S1,2026-01-08 19:03:26,1h 50m,,2026-01-08 21:21:03 +葛先生,13811638071,3042699136239301,VIP5,2026-01-08 20:53:38,0h 27m,小燕(2h17m),2026-01-08 21:21:03 +葛先生,13811638071,3042796595971845,TV,2026-01-08 21:18:43,1h 13m,年糕(1h47m),2026-01-09 00:02:40 +葛先生,13811638071,3042726147917509,VIP5,2026-01-08 21:21:07,1h 22m,小燕(1h23m),2026-01-08 22:43:49 +葛先生,13811638071,3042796595971845,TV,2026-01-08 22:32:47,1h 28m,年糕(1h47m),2026-01-09 00:02:40 +葛先生,13811638071,3042589002975045,VIP5,2026-01-08 22:43:51,0h 26m,小燕(0h26m),2026-01-08 23:11:19 +葛先生,13811638071,3042835118278341,补时长7,2026-01-08 23:11:58,2h 0m,,2026-01-08 23:12:30 +葛先生,13811638071,3042885054465733,补时长7,2026-01-09 00:02:46,0h 0m,,2026-01-09 00:03:28 +葛先生,13811638071,3043910456903429,A6,2026-01-09 17:25:52,7h 34m,小燕(10h34m),2026-01-10 01:01:10 +葛先生,13811638071,3044081890182917,S2,2026-01-09 20:20:15,2h 15m,千千(2h13m),2026-01-09 22:38:08 +葛先生,13811638071,3044086761490309,666,2026-01-09 20:25:13,1h 54m,,2026-01-09 22:22:52 +葛先生,13811638071,3044105870411525,M7,2026-01-09 20:44:39,3h 49m,小燕(3h43m),2026-01-10 00:39:15 +葛先生,13811638071,3044358662178117,补时长6,2026-01-10 01:01:48,4h 0m,,2026-01-10 01:02:29 +葛先生,13811638071,3045217778599621,A6,2026-01-10 15:35:44,7h 33m,小燕(7h33m),2026-01-10 23:10:26 +葛先生,13811638071,3045663905318661,VIP5,2026-01-10 23:09:34,3h 7m,小燕(3h7m),2026-01-11 02:17:13 +葛先生,13811638071,3045763277047109,补时长5,2026-01-11 00:50:39,2h 2m,,2026-01-11 02:04:36 diff --git a/etl_billiards/reports/assistant_orders_13811638071_2025-11-01_no_table.csv b/etl_billiards/reports/assistant_orders_13811638071_2025-11-01_no_table.csv new file mode 100644 index 0000000..73befd5 --- /dev/null +++ b/etl_billiards/reports/assistant_orders_13811638071_2025-11-01_no_table.csv @@ -0,0 +1,27 @@ +会员名称,会员手机号,订单号,桌台(房间)名称,开始时间,持续时间,助教挂钟信息,结账时间 +葛先生,13811638071,2957821891038725,,2025-11-09 21:52:03,0h 0m,,2025-11-09 21:52:08 +葛先生,13811638071,2966177428162245,,2025-11-15 19:31:45,0h 0m,,2025-11-15 19:31:50 +葛先生,13811638071,2980458746317189,,2025-11-25 21:39:27,0h 0m,,2025-11-25 21:39:32 +葛先生,13811638071,2982035921635973,,2025-11-27 00:23:50,0h 0m,,2025-11-27 00:23:54 +葛先生,13811638071,2986261249411781,,2025-11-30 00:02:04,0h 0m,,2025-11-30 00:02:10 +葛先生,13811638071,2987682979566149,,2025-12-01 00:08:19,0h 0m,,2025-12-01 00:08:25 +葛先生,13811638071,2996398357319941,,2025-12-07 03:54:04,0h 0m,,2025-12-07 03:54:45 +葛先生,13811638071,3000142759381509,,2025-12-09 19:23:04,0h 0m,,2025-12-09 19:23:08 +葛先生,13811638071,3000218457131525,,2025-12-09 20:40:04,0h 0m,,2025-12-09 20:40:07 +葛先生,13811638071,3000309702920709,,2025-12-09 22:12:54,0h 1m,,2025-12-09 22:14:06 +葛先生,13811638071,3000373824244293,,2025-12-09 23:18:07,0h 0m,,2025-12-09 23:18:10 +葛先生,13811638071,3000377913346565,,2025-12-09 23:22:17,0h 0m,,2025-12-09 23:22:22 +葛先生,13811638071,3000415061690757,,2025-12-10 00:00:04,0h 0m,,2025-12-10 00:00:08 +葛先生,13811638071,3003030187398085,,2025-12-11 20:20:19,0h 0m,,2025-12-11 20:20:22 +葛先生,13811638071,3005540084271109,,2025-12-13 14:53:31,0h 0m,,2025-12-13 14:53:34 +葛先生,13811638071,3005540596467845,,2025-12-13 14:54:02,0h 0m,,2025-12-13 14:54:03 +葛先生,13811638071,3005709583894533,,2025-12-13 17:45:56,0h 0m,,2025-12-13 17:45:58 +葛先生,13811638071,3008791223945669,,2025-12-15 22:00:44,0h 0m,,2025-12-15 22:00:48 +葛先生,13811638071,3010440130430917,,2025-12-17 01:58:06,0h 0m,,2025-12-17 01:58:11 +葛先生,13811638071,3014246545706757,,2025-12-19 18:30:11,0h 0m,,2025-12-19 18:30:15 +葛先生,13811638071,3017228572607941,,2025-12-21 21:03:39,0h 0m,,2025-12-21 21:03:41 +葛先生,13811638071,3025720252254149,,2025-12-27 21:01:50,0h 0m,,2025-12-27 21:01:53 +葛先生,13811638071,3041096056031110,,2026-01-07 17:42:55,0h 0m,,2026-01-07 17:43:01 +葛先生,13811638071,3045359793473221,,2026-01-10 18:00:13,0h 0m,,2026-01-10 18:00:16 +葛先生,13811638071,3045651477251973,,2026-01-10 22:56:56,0h 0m,,2026-01-10 22:56:58 +葛先生,13811638071,3047065961137861,,2026-01-11 22:55:49,0h 0m,,2026-01-11 22:55:51 diff --git a/etl_billiards/reports/assistant_orders_13811638071_2025-11-14.csv b/etl_billiards/reports/assistant_orders_13811638071_2025-11-14.csv new file mode 100644 index 0000000..10fb192 --- /dev/null +++ b/etl_billiards/reports/assistant_orders_13811638071_2025-11-14.csv @@ -0,0 +1,168 @@ +会员名称,会员手机号,订单号,桌台(房间)名称,开始时间,持续时间,助教挂钟信息,结账时间 +葛先生,13811638071,2965176087987909,S1,2025-11-15 00:32:38,2h 0m,,2025-11-15 05:16:43 +葛先生,13811638071,2965176087987909,TV,2025-11-15 02:33:07,2h 43m,千千(4h43m) 小燕(2h48m),2025-11-15 05:16:43 +葛先生,13811638071,2966373430363909,888,2025-11-15 22:51:07,3h 25m,柚子(3h25m) 千千(3h21m) 素素(3h12m) 年糕(3h16m) 球球(0h55m),2025-11-16 02:23:36 +葛先生,13811638071,2970984317881221,TV,2025-11-18 21:32:20,7h 7m,,2025-11-19 05:34:46 +葛先生,13811638071,2970984317881221,补时长6,2025-11-19 05:01:34,0h 0m,梦梦(4h27m) 千千(3h7m) 小燕(2h49m),2025-11-19 05:34:46 +葛先生,13811638071,2972114730570373,VIP5,2025-11-19 22:30:59,1h 40m,小燕(1h39m),2025-11-20 02:40:52 +葛先生,13811638071,2972114730570373,TV,2025-11-20 00:11:28,2h 29m,小燕(2h29m),2025-11-20 02:40:52 +葛先生,13811638071,2972261781556293,补时长3,2025-11-20 02:41:04,0h 0m,,2025-11-20 02:41:33 +葛先生,13811638071,2972261781556293,补时长4,2025-11-20 02:41:06,0h 0m,,2025-11-20 02:41:33 +葛先生,13811638071,2973537861161797,TV,2025-11-21 00:19:09,5h 41m,小燕(5h41m) Amy(1h34m),2025-11-21 06:02:46 +葛先生,13811638071,2976440977246341,TV,2025-11-23 01:32:22,5h 53m,小燕(5h53m),2025-11-23 07:26:33 +葛先生,13811638071,2979199778228165,S1,2025-11-24 23:22:58,0h 56m,阿清(0h54m) 小燕(0h9m),2025-11-25 01:10:58 +葛先生,13811638071,2979199778228165,TV,2025-11-25 00:18:45,0h 44m,小燕(0h44m) 阿清(0h44m),2025-11-25 01:10:58 +葛先生,13811638071,2979241077901253,M3,2025-11-25 01:00:46,3h 10m,千千(3h10m) 小燕(3h8m) 阿清(3h8m),2025-11-25 04:13:47 +葛先生,13811638071,2980539547354565,A2,2025-11-25 22:44:25,0h 24m,小燕(0h24m),2025-11-26 05:10:08 +葛先生,13811638071,2980539547354565,VIP5,2025-11-25 23:01:38,6h 7m,小燕(6h0m),2025-11-26 05:10:08 +葛先生,13811638071,2982280563167941,A2,2025-11-27 04:32:42,4h 53m,小燕(4h53m) 阿清(4h49m),2025-11-27 11:33:19 +葛先生,13811638071,2984703823612613,VIP5,2025-11-28 21:37:46,10h 8m,阿清(10h7m) 小燕(8h39m),2025-11-29 07:46:38 +葛先生,13811638071,2986305074877125,VIP5,2025-11-30 00:46:38,6h 16m,小燕(6h15m),2025-11-30 07:06:16 +葛先生,13811638071,2987452433699397,S1,2025-11-30 20:13:48,2h 15m,乔西(2h15m) 小燕(1h59m),2025-11-30 22:29:58 +葛先生,13811638071,2990547445273157,TV,2025-12-03 00:42:12,8h 10m,小燕(8h10m),2025-12-03 08:52:52 +葛先生,13811638071,2991901466284741,A3,2025-12-03 23:39:35,7h 16m,小燕(7h16m),2025-12-04 09:59:55 +葛先生,13811638071,2993421047679685,A3,2025-12-05 01:00:00,0h 25m,,2025-12-05 09:16:58 +葛先生,13811638071,2993421047679685,TV,2025-12-05 01:25:23,7h 50m,小燕(8h16m),2025-12-05 09:16:58 +葛先生,13811638071,2994624795250949,B3,2025-12-05 21:20:49,0h 35m,,2025-12-06 03:19:33 +葛先生,13811638071,2994624795250949,TV,2025-12-05 21:49:54,5h 29m,小燕(5h29m),2025-12-06 03:19:33 +葛先生,13811638071,3003192948659141,TV,2025-12-11 18:23:55,1h 6m,球球(1h6m),2025-12-12 05:16:57 +葛先生,13811638071,3003354125276101,A6,2025-12-11 20:19:55,2h 50m,小燕(2h50m),2025-12-12 01:50:19 +葛先生,13811638071,3003192948659141,888,2025-12-11 23:05:52,6h 0m,小燕(5h56m) 梦梦(2h52m) 涛涛(5h51m) 年糕(2h38m),2025-12-12 05:16:57 +葛先生,13811638071,3003354125276101,A1,2025-12-12 01:49:50,0h 0m,,2025-12-12 01:50:19 +葛先生,13811638071,3003355747341189,补时长4,2025-12-12 01:51:29,0h 1m,,2025-12-12 01:51:48 +葛先生,13811638071,3004579787884613,TV,2025-12-12 22:36:38,8h 10m,小燕(8h10m),2025-12-13 06:47:52 +葛先生,13811638071,3005655255435397,A11,2025-12-13 16:50:40,4h 53m,小燕(4h53m),2025-12-13 21:44:28 +葛先生,13811638071,3005944536778822,补时长7,2025-12-13 21:44:56,2h 0m,,2025-12-13 21:45:20 +葛先生,13811638071,3006295607298309,A1,2025-12-14 03:42:04,0h 43m,小燕(0h43m),2025-12-14 04:26:40 +葛先生,13811638071,3007126951397381,A1,2025-12-14 17:47:45,2h 5m,小燕(2h5m),2025-12-14 19:53:51 +葛先生,13811638071,3007250978523205,补时长5,2025-12-14 19:53:55,0h 1m,,2025-12-14 19:54:17 +葛先生,13811638071,3007446854797445,TV,2025-12-14 23:13:10,5h 57m,小燕(5h57m) 阿清(5h10m),2025-12-15 05:11:18 +葛先生,13811638071,3008834924841285,A1,2025-12-15 22:01:20,2h 19m,小燕(2h19m),2025-12-16 02:42:42 +葛先生,13811638071,3008834924841285,S1,2025-12-15 22:45:11,3h 55m,苏苏(2h28m),2025-12-16 02:42:42 +葛先生,13811638071,3009076506036165,A2,2025-12-16 02:50:56,3h 40m,小燕(3h39m),2025-12-16 06:32:44 +葛先生,13811638071,3009492999244293,补时长7,2025-12-16 09:54:37,3h 0m,,2025-12-16 09:54:56 +葛先生,13811638071,3010218131671557,A1,2025-12-16 22:12:16,3h 18m,小燕(3h18m) 阿清(2h43m),2025-12-17 01:31:11 +葛先生,13811638071,3011552955975237,A1,2025-12-17 19:25:16,1h 34m,小燕(1h34m),2025-12-17 22:04:09 +葛先生,13811638071,3011552955975237,C2,2025-12-17 20:50:07,1h 13m,小燕(1h3m),2025-12-17 22:04:09 +葛先生,13811638071,3011630421837381,A18,2025-12-17 22:08:55,3h 3m,小燕(3h3m),2025-12-18 01:12:30 +葛先生,13811638071,3012931996487173,TV,2025-12-18 20:12:57,5h 38m,小燕(5h38m),2025-12-19 01:53:16 +葛先生,13811638071,3013266727145349,补时长5,2025-12-19 01:53:27,2h 0m,,2025-12-19 01:53:55 +葛先生,13811638071,3013302603681669,A6,2025-12-19 02:29:57,0h 30m,小燕(0h29m),2025-12-19 03:00:10 +葛先生,13811638071,3013331369283205,A1,2025-12-19 02:59:12,0h 18m,,2025-12-19 02:59:41 +葛先生,13811638071,3014479760183173,A6,2025-12-19 17:07:12,11h 18m,小燕(11h18m) 阿清(2h0m),2025-12-20 06:59:01 +葛先生,13811638071,3014479760183173,TV,2025-12-19 22:27:25,8h 31m,小燕(2h53m) 阿清(8h22m),2025-12-20 06:59:01 +葛先生,13811638071,3015171680994757,补时长7,2025-12-20 10:11:16,3h 0m,,2025-12-20 10:11:36 +葛先生,13811638071,3015904480560645,S2,2025-12-20 21:38:55,0h 56m,球球(0h56m),2025-12-20 22:38:26 +葛先生,13811638071,3015928035247749,S1,2025-12-20 21:47:59,1h 12m,小燕(1h12m),2025-12-20 23:01:49 +葛先生,13811638071,3015974337283525,补时长6,2025-12-20 23:47:46,3h 0m,,2025-12-20 23:48:13 +葛先生,13811638071,3015974988367429,C4,2025-12-20 23:48:26,4h 28m,小燕(4h28m),2025-12-21 04:17:32 +葛先生,13811638071,3016300766643845,TV,2025-12-21 05:19:50,0h 56m,小燕(0h56m),2025-12-21 06:16:53 +葛先生,13811638071,3016653147375173,补时长7,2025-12-21 11:18:18,2h 0m,,2025-12-21 11:18:40 +葛先生,13811638071,3017225201927749,S1,2025-12-21 21:00:13,1h 28m,,2025-12-21 22:30:16 +葛先生,13811638071,3017273506465285,C5,2025-12-21 21:49:21,1h 24m,千千(1h12m),2025-12-21 23:45:38 +葛先生,13811638071,3017388287231429,补时长6,2025-12-21 23:46:07,0h 0m,,2025-12-21 23:46:27 +葛先生,13811638071,3017486080099845,A1,2025-12-22 01:25:36,1h 49m,小燕(1h49m),2025-12-22 03:15:17 +葛先生,13811638071,3017593957697157,补时长6,2025-12-22 03:15:20,0h 0m,,2025-12-22 03:15:37 +葛先生,13811638071,3017596515419717,A1,2025-12-22 03:17:56,3h 49m,小燕(3h49m),2025-12-22 07:08:11 +葛先生,13811638071,3018642766775813,S1,2025-12-22 21:02:14,1h 7m,小燕(1h7m),2025-12-22 22:10:33 +葛先生,13811638071,3018644316587589,补时长7,2025-12-22 21:03:49,0h 0m,,2025-12-22 21:04:09 +葛先生,13811638071,3018709942093445,S1,2025-12-22 22:10:34,0h 43m,小燕(0h43m),2025-12-22 22:54:21 +葛先生,13811638071,3018710150219205,补时长6,2025-12-22 22:10:47,0h 0m,,2025-12-22 22:11:08 +葛先生,13811638071,3018753009468933,S1,2025-12-22 22:54:23,1h 3m,小燕(1h3m),2025-12-22 23:58:44 +葛先生,13811638071,3018766856488389,补时长6,2025-12-22 23:08:28,0h 0m,,2025-12-22 23:08:51 +葛先生,13811638071,3018816489850309,补时长7,2025-12-22 23:58:58,0h 0m,,2025-12-22 23:59:34 +葛先生,13811638071,3018826595370437,M7,2025-12-23 00:09:14,4h 0m,小燕(3h59m),2025-12-23 04:23:37 +葛先生,13811638071,3019076971070917,补时长6,2025-12-23 04:23:56,2h 0m,,2025-12-23 04:24:21 +葛先生,13811638071,3019077416650181,A1,2025-12-23 04:24:23,3h 7m,小燕(3h7m),2025-12-23 07:32:22 +葛先生,13811638071,3019262758979077,补时长6,2025-12-23 07:32:56,0h 0m,,2025-12-23 07:34:10 +葛先生,13811638071,3020101562009093,A6,2025-12-23 21:46:12,10h 2m,小燕(10h1m),2025-12-24 07:48:47 +葛先生,13811638071,3020694538569157,补时长5,2025-12-24 07:49:25,3h 0m,,2025-12-24 07:49:46 +葛先生,13811638071,3021420856576006,S1,2025-12-24 20:08:16,1h 22m,小燕(1h22m),2025-12-24 21:30:46 +葛先生,13811638071,3021501988194373,S1,2025-12-24 21:30:47,1h 50m,小燕(1h49m),2025-12-24 23:21:38 +葛先生,13811638071,3021610987734853,S1,2025-12-24 23:21:40,2h 21m,小燕(2h21m),2025-12-25 01:43:51 +葛先生,13811638071,3021770635823109,A6,2025-12-25 02:04:04,7h 56m,小燕(7h56m),2025-12-25 12:08:55 +葛先生,13811638071,3022365496854533,补时长6,2025-12-25 12:09:12,4h 0m,,2025-12-25 12:09:46 +葛先生,13811638071,3022873228429253,S1,2025-12-25 20:45:41,1h 55m,小燕(1h55m),2025-12-25 22:42:03 +葛先生,13811638071,3022987729340421,S1,2025-12-25 22:42:10,1h 9m,小燕(1h9m),2025-12-25 23:52:24 +葛先生,13811638071,3023056813000773,S1,2025-12-25 23:52:26,0h 58m,小燕(0h58m),2025-12-26 00:51:10 +葛先生,13811638071,3023057336322053,补时长5,2025-12-25 23:52:58,0h 0m,,2025-12-25 23:53:18 +葛先生,13811638071,3023118312458181,A6,2025-12-26 00:55:00,5h 59m,小燕(5h59m),2025-12-26 06:55:32 +葛先生,13811638071,3024330328901573,C1,2025-12-26 21:27:56,3h 14m,小燕(3h14m),2025-12-27 00:42:31 +葛先生,13811638071,3024521672149061,A6,2025-12-27 00:42:34,4h 56m,小燕(4h56m),2025-12-27 05:43:00 +葛先生,13811638071,3024522484762565,补时长6,2025-12-27 00:43:24,2h 0m,,2025-12-27 00:43:51 +葛先生,13811638071,3025075373508677,补时长7,2025-12-27 10:05:50,2h 0m,,2025-12-27 10:06:18 +葛先生,13811638071,3025697485457477,A12,2025-12-27 20:38:40,0h 24m,小燕(0h24m),2025-12-27 21:03:38 +葛先生,13811638071,3025926993020741,A18,2025-12-28 00:32:08,3h 59m,小燕(3h58m),2025-12-28 04:31:30 +葛先生,13811638071,3026162441734149,补时长5,2025-12-28 04:31:39,1h 40m,,2025-12-28 04:32:29 +葛先生,13811638071,3027018732144709,A8,2025-12-28 19:02:43,1h 5m,小燕(1h5m),2025-12-28 20:08:25 +葛先生,13811638071,3027082894100549,S1,2025-12-28 20:07:59,0h 47m,小燕(0h47m),2025-12-28 20:55:54 +葛先生,13811638071,3027086248364101,A8,2025-12-28 20:11:24,0h 32m,,2025-12-28 20:11:45 +葛先生,13811638071,3027171803039749,S1,2025-12-28 20:55:56,0h 42m,,2025-12-28 22:48:42 +葛先生,13811638071,3027130329974789,A6,2025-12-28 20:56:14,0h 32m,,2025-12-28 20:56:31 +葛先生,13811638071,3027171803039749,VIP5,2025-12-28 21:38:25,1h 9m,小燕(1h52m),2025-12-28 22:48:42 +葛先生,13811638071,3027240911620101,VIP5,2025-12-28 22:48:44,0h 42m,小燕(0h42m),2025-12-28 23:32:00 +葛先生,13811638071,3027279097726917,A1,2025-12-28 23:23:12,0h 4m,,2025-12-29 00:45:50 +葛先生,13811638071,3027279097726917,C2,2025-12-28 23:27:34,1h 17m,婉婉(1h17m),2025-12-29 00:45:50 +葛先生,13811638071,3027283487705029,VIP5,2025-12-28 23:32:02,1h 38m,小燕(1h38m),2025-12-29 01:10:27 +葛先生,13811638071,3027381047756741,补时长7,2025-12-29 01:11:17,0h 0m,,2025-12-29 01:11:47 +葛先生,13811638071,3027494215747397,A1,2025-12-29 03:06:24,0h 0m,小燕(1h50m),2025-12-29 03:06:51 +葛先生,13811638071,3027520016877573,补时长5,2025-12-29 03:32:39,1h 40m,,2025-12-29 03:33:33 +葛先生,13811638071,3028365240272837,A6,2025-12-29 17:52:27,2h 3m,小燕(2h3m),2025-12-29 19:58:42 +葛先生,13811638071,3028488531724357,M5,2025-12-29 19:57:52,3h 3m,小燕(3h3m),2025-12-29 23:04:58 +葛先生,13811638071,3028489795159877,A6,2025-12-29 19:59:09,1h 2m,,2025-12-29 19:59:31 +葛先生,13811638071,3028620706809861,VIP5,2025-12-29 22:12:19,1h 2m,小柔(1h2m) 小燕(0h6m),2025-12-29 23:16:04 +葛先生,13811638071,3028672505759557,补时长7,2025-12-29 23:05:01,0h 0m,,2025-12-29 23:05:28 +葛先生,13811638071,3028796631942981,A1,2025-12-30 01:11:17,0h 0m,小燕(1h12m),2025-12-30 01:12:20 +葛先生,13811638071,3028797785933637,补时长6,2025-12-30 01:12:28,0h 0m,,2025-12-30 01:12:43 +葛先生,13811638071,3028798611277893,A6,2025-12-30 01:13:18,0h 51m,小燕(0h50m),2025-12-30 02:04:34 +葛先生,13811638071,3029823520163653,A6,2025-12-30 18:35:53,1h 30m,小燕(1h30m),2025-12-30 20:06:17 +葛先生,13811638071,3029912671930309,A2,2025-12-30 20:06:35,0h 45m,,2025-12-30 20:07:12 +葛先生,13811638071,3029920336283461,S1,2025-12-30 20:14:23,1h 9m,小燕(1h8m),2025-12-30 21:26:01 +葛先生,13811638071,3029953608239109,S3,2025-12-30 20:48:13,0h 54m,,2025-12-30 21:43:22 +葛先生,13811638071,3029990793086789,S1,2025-12-30 21:26:03,1h 22m,小燕(1h1m) 阿清(0h20m),2025-12-30 22:48:21 +葛先生,13811638071,3029991615416389,A6,2025-12-30 21:26:53,0h 50m,,2025-12-30 21:27:30 +葛先生,13811638071,3030051846162437,A6,2025-12-30 22:28:09,1h 35m,小燕(1h13m),2025-12-31 00:03:58 +葛先生,13811638071,3030071938451269,S1,2025-12-30 22:48:36,0h 45m,阿清(0h45m),2025-12-30 23:34:52 +葛先生,13811638071,3030329100781637,补时长2,2025-12-31 03:10:12,0h 1m,,2025-12-31 03:10:58 +葛先生,13811638071,3031047867485317,VIP1,2025-12-31 15:21:22,2h 15m,小燕(2h15m),2025-12-31 18:30:37 +葛先生,13811638071,3031246638532229,补时长2,2025-12-31 18:43:30,0h 0m,,2025-12-31 18:44:20 +葛先生,13811638071,3031246638532229,补时长3,2025-12-31 18:43:34,0h 0m,,2025-12-31 18:44:20 +葛先生,13811638071,3036870032656645,A6,2026-01-04 18:03:58,2h 15m,小燕(2h15m),2026-01-04 20:20:13 +葛先生,13811638071,3037004116691653,补时长7,2026-01-04 20:20:22,0h 0m,,2026-01-04 20:20:58 +葛先生,13811638071,3037130243574469,A7,2026-01-04 22:28:40,0h 0m,小燕(2h3m),2026-01-04 22:29:02 +葛先生,13811638071,3037131328588485,M5,2026-01-04 22:29:47,3h 9m,,2026-01-05 01:44:43 +葛先生,13811638071,3037318211751109,补时长7,2026-01-05 01:39:53,0h 0m,,2026-01-05 01:40:11 +葛先生,13811638071,3038663954582853,A6,2026-01-05 20:44:34,0h 21m,,2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,M5,2026-01-05 21:05:52,3h 0m,,2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,S1,2026-01-05 23:05:41,0h 57m,乔西(0h57m) 小燕(0h21m),2026-01-06 02:31:25 +葛先生,13811638071,3038597488594117,补时长7,2026-01-05 23:21:14,0h 0m,小燕(2h35m),2026-01-05 23:22:18 +葛先生,13811638071,3038663954582853,TV,2026-01-06 00:28:01,0h 0m,小燕(1h59m) 阿清(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,TV,2026-01-06 00:28:51,1h 59m,小燕(1h59m) 阿清(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3039616315803525,A1,2026-01-06 16:37:38,8h 50m,小燕(8h50m),2026-01-07 01:28:11 +葛先生,13811638071,3039824130983749,M7,2026-01-06 20:09:02,3h 22m,小燕(3h22m),2026-01-06 23:36:08 +葛先生,13811638071,3040027900495749,补时长7,2026-01-06 23:36:19,2h 0m,,2026-01-06 23:36:43 +葛先生,13811638071,3040128992692037,TV,2026-01-07 01:19:10,2h 46m,小燕(2h37m),2026-01-07 04:06:07 +葛先生,13811638071,3040137917564805,补时长7,2026-01-07 01:28:14,3h 0m,,2026-01-07 01:28:39 +葛先生,13811638071,3041502086645445,A6,2026-01-07 14:11:29,10h 24m,小燕(10h24m),2026-01-08 05:48:14 +葛先生,13811638071,3041240108402437,VIP5,2026-01-07 20:09:27,1h 11m,小燕(1h11m),2026-01-07 21:21:15 +葛先生,13811638071,3041310718166853,VIP5,2026-01-07 21:21:16,0h 46m,小燕(0h46m),2026-01-07 22:08:05 +葛先生,13811638071,3041356781012741,VIP5,2026-01-07 22:08:08,1h 2m,小燕(1h1m),2026-01-07 23:10:30 +葛先生,13811638071,3041418120349573,VIP5,2026-01-07 23:10:32,0h 49m,小燕(0h48m),2026-01-08 00:04:02 +葛先生,13811638071,3041502086645445,TV,2026-01-08 00:35:57,5h 11m,阿清(1h55m) 小燕(5h11m),2026-01-08 05:48:14 +葛先生,13811638071,3042589002975045,A6,2026-01-08 19:01:37,4h 9m,小燕(6h9m),2026-01-08 23:11:19 +葛先生,13811638071,3042699136239301,S1,2026-01-08 19:03:26,1h 50m,,2026-01-08 21:21:03 +葛先生,13811638071,3042699136239301,VIP5,2026-01-08 20:53:38,0h 27m,小燕(2h17m),2026-01-08 21:21:03 +葛先生,13811638071,3042796595971845,TV,2026-01-08 21:18:43,1h 13m,年糕(1h47m),2026-01-09 00:02:40 +葛先生,13811638071,3042726147917509,VIP5,2026-01-08 21:21:07,1h 22m,小燕(1h23m),2026-01-08 22:43:49 +葛先生,13811638071,3042796595971845,TV,2026-01-08 22:32:47,1h 28m,年糕(1h47m),2026-01-09 00:02:40 +葛先生,13811638071,3042589002975045,VIP5,2026-01-08 22:43:51,0h 26m,小燕(0h26m),2026-01-08 23:11:19 +葛先生,13811638071,3042835118278341,补时长7,2026-01-08 23:11:58,2h 0m,,2026-01-08 23:12:30 +葛先生,13811638071,3042885054465733,补时长7,2026-01-09 00:02:46,0h 0m,,2026-01-09 00:03:28 +葛先生,13811638071,3043910456903429,A6,2026-01-09 17:25:52,7h 34m,小燕(10h34m),2026-01-10 01:01:10 +葛先生,13811638071,3044081890182917,S2,2026-01-09 20:20:15,2h 15m,千千(2h13m),2026-01-09 22:38:08 +葛先生,13811638071,3044086761490309,666,2026-01-09 20:25:13,1h 54m,,2026-01-09 22:22:52 +葛先生,13811638071,3044105870411525,M7,2026-01-09 20:44:39,3h 49m,小燕(3h43m),2026-01-10 00:39:15 +葛先生,13811638071,3044358662178117,补时长6,2026-01-10 01:01:48,4h 0m,,2026-01-10 01:02:29 +葛先生,13811638071,3045217778599621,A6,2026-01-10 15:35:44,7h 33m,小燕(7h33m),2026-01-10 23:10:26 +葛先生,13811638071,3045663905318661,VIP5,2026-01-10 23:09:34,3h 7m,小燕(3h7m),2026-01-11 02:17:13 +葛先生,13811638071,3045763277047109,补时长5,2026-01-11 00:50:39,2h 2m,,2026-01-11 02:04:36 diff --git a/etl_billiards/reports/assistant_orders_13811638071_2026-01-01.csv b/etl_billiards/reports/assistant_orders_13811638071_2026-01-01.csv new file mode 100644 index 0000000..16117a0 --- /dev/null +++ b/etl_billiards/reports/assistant_orders_13811638071_2026-01-01.csv @@ -0,0 +1,40 @@ +会员名称,会员手机号,订单号,桌台(房间)名称,开始时间,持续时间,助教挂钟信息,结账时间 +葛先生,13811638071,3036870032656645,A6,2026-01-04 18:03:58,2h 15m,小燕(2h15m),2026-01-04 20:20:13 +葛先生,13811638071,3037004116691653,补时长7,2026-01-04 20:20:22,0h 0m,,2026-01-04 20:20:58 +葛先生,13811638071,3037130243574469,A7,2026-01-04 22:28:40,0h 0m,小燕(2h3m),2026-01-04 22:29:02 +葛先生,13811638071,3037131328588485,M5,2026-01-04 22:29:47,3h 9m,,2026-01-05 01:44:43 +葛先生,13811638071,3037318211751109,补时长7,2026-01-05 01:39:53,0h 0m,,2026-01-05 01:40:11 +葛先生,13811638071,3038443483466437,A6,2026-01-05 20:44:34,0h 21m,,2026-01-06 02:31:25 +葛先生,13811638071,3038464422284485,M5,2026-01-05 21:05:52,3h 0m,,2026-01-06 02:31:25 +葛先生,13811638071,3038582201207493,S1,2026-01-05 23:05:41,0h 57m,乔西(0h57m) 小燕(0h21m),2026-01-06 02:31:25 +葛先生,13811638071,3038597488594117,补时长7,2026-01-05 23:21:14,0h 0m,小燕(2h35m),2026-01-05 23:22:18 +葛先生,13811638071,3038663145786693,TV,2026-01-06 00:28:01,0h 0m,阿清(1h59m) 小燕(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3038663954582853,TV,2026-01-06 00:28:51,1h 59m,阿清(1h59m) 小燕(1h59m),2026-01-06 02:31:25 +葛先生,13811638071,3039616315803525,A1,2026-01-06 16:37:38,8h 50m,小燕(8h50m),2026-01-07 01:28:11 +葛先生,13811638071,3039824130983749,M7,2026-01-06 20:09:02,3h 22m,小燕(3h22m),2026-01-06 23:36:08 +葛先生,13811638071,3040027900495749,补时长7,2026-01-06 23:36:19,2h 0m,,2026-01-06 23:36:43 +葛先生,13811638071,3040128992692037,TV,2026-01-07 01:19:10,2h 46m,小燕(2h37m),2026-01-07 04:06:07 +葛先生,13811638071,3040137917564805,补时长7,2026-01-07 01:28:14,3h 0m,,2026-01-07 01:28:39 +葛先生,13811638071,3040888209426117,A6,2026-01-07 14:11:29,10h 24m,小燕(10h24m),2026-01-08 05:48:14 +葛先生,13811638071,3041240108402437,VIP5,2026-01-07 20:09:27,1h 11m,小燕(1h11m),2026-01-07 21:21:15 +葛先生,13811638071,3041310718166853,VIP5,2026-01-07 21:21:16,0h 46m,小燕(0h46m) 小燕(0h0m),2026-01-07 22:08:05 +葛先生,13811638071,3041356781012741,VIP5,2026-01-07 22:08:08,1h 2m,小燕(1h1m),2026-01-07 23:10:30 +葛先生,13811638071,3041418120349573,VIP5,2026-01-07 23:10:32,0h 49m,小燕(0h48m),2026-01-08 00:04:02 +葛先生,13811638071,3041502086645445,TV,2026-01-08 00:35:57,5h 11m,阿清(1h55m) 小燕(5h11m),2026-01-08 05:48:14 +葛先生,13811638071,3042589002975045,A6,2026-01-08 19:01:37,4h 9m,小燕(0h0m) 小燕(4h9m) 小燕(0h0m),2026-01-08 23:11:19 +葛先生,13811638071,3042590786307781,S1,2026-01-08 19:03:26,1h 50m,小燕(0h0m),2026-01-08 21:21:03 +葛先生,13811638071,3042699136239301,VIP5,2026-01-08 20:53:38,0h 27m,小燕(2h17m),2026-01-08 21:21:03 +葛先生,13811638071,3042723784541957,TV,2026-01-08 21:18:43,1h 13m,年糕(1h28m) 年糕(0h0m) 年糕(0h19m),2026-01-09 00:02:40 +葛先生,13811638071,3042726147917509,VIP5,2026-01-08 21:21:07,1h 22m,小燕(1h23m),2026-01-08 22:43:49 +葛先生,13811638071,3042796595971845,TV,2026-01-08 22:32:47,1h 28m,年糕(1h28m) 年糕(0h0m) 年糕(0h19m),2026-01-09 00:02:40 +葛先生,13811638071,3042807473456837,VIP5,2026-01-08 22:43:51,0h 26m,小燕(0h26m),2026-01-08 23:11:19 +葛先生,13811638071,3042835118278341,补时长7,2026-01-08 23:11:58,2h 0m,,2026-01-08 23:12:30 +葛先生,13811638071,3042885054465733,补时长7,2026-01-09 00:02:46,0h 0m,,2026-01-09 00:03:28 +葛先生,13811638071,3043910456903429,A6,2026-01-09 17:25:52,7h 34m,小燕(7h34m) 小燕(0h0m) 小燕(0h0m),2026-01-10 01:01:10 +葛先生,13811638071,3044081890182917,S2,2026-01-09 20:20:15,2h 15m,千千(2h13m),2026-01-09 22:38:08 +葛先生,13811638071,3044086761490309,666,2026-01-09 20:25:13,1h 54m,,2026-01-09 22:22:52 +葛先生,13811638071,3044105870411525,M7,2026-01-09 20:44:39,3h 49m,小燕(0h0m) 小燕(3h43m),2026-01-10 00:39:15 +葛先生,13811638071,3044358662178117,补时长6,2026-01-10 01:01:48,4h 0m,,2026-01-10 01:02:29 +葛先生,13811638071,3045217778599621,A6,2026-01-10 15:35:44,7h 33m,小燕(7h33m) 小燕(0h0m),2026-01-10 23:10:26 +葛先生,13811638071,3045663905318661,VIP5,2026-01-10 23:09:34,3h 7m,小燕(3h7m),2026-01-11 02:17:13 +葛先生,13811638071,3045763277047109,补时长5,2026-01-11 00:50:39,2h 2m,,2026-01-11 02:04:36 diff --git a/etl_billiards/reports/assistant_overtime_rest_xiaoyan.csv b/etl_billiards/reports/assistant_overtime_rest_xiaoyan.csv new file mode 100644 index 0000000..21a9db0 --- /dev/null +++ b/etl_billiards/reports/assistant_overtime_rest_xiaoyan.csv @@ -0,0 +1,3 @@ +助教昵称,桌台(房间)名称,超休课时,超休金额,超休原因,记录时间 +小燕,A6,小燕(0h0m),0,,2026-01-08 05:48:14 +小燕,VIP5,小燕(43h21m),99.70,,2026-01-08 22:43:49 diff --git a/etl_billiards/reports/loyal_billiards_customers_2025-12-26.csv b/etl_billiards/reports/loyal_billiards_customers_2025-12-26.csv new file mode 100644 index 0000000..538861b --- /dev/null +++ b/etl_billiards/reports/loyal_billiards_customers_2025-12-26.csv @@ -0,0 +1,51 @@ +排名,客户姓名,联系方式,单次日打球小时数,到店平均间隔(天),10-12月来店次数,最后一次到店日期 +1,黄生,13609719719,7.53,1.8,43,2025-12-24 14:51 +2,曾巧明,18688471488,6.25,1.2,71,2025-12-25 15:49 +3,葛先生,13811638071,5.81,1.3,39,2025-12-26 00:55 +4,孟紫龙,17631643741,4.73,1.5,17,2025-12-23 16:27 +5,桂先生,16676777275,4.70,2.3,25,2025-12-16 19:05 +6,孟紫龙(该会员已注销),17631643741(1),4.64,2.0,6,2025-10-13 18:57 +7,陈先生,13922200419,3.92,,1,2025-10-18 12:35 +8,候,13161960323,3.67,1.0,6,2025-12-17 23:13 +9,小燕,17802081334,3.58,1.2,29,2025-12-22 20:10 +10,陈先生,15915782829,3.48,,1,2025-11-07 00:44 +11,牛先生,15201265159,3.44,1.0,3,2025-11-24 17:10 +12,李先生,13427574343,3.39,2.8,14,2025-12-24 22:49 +13,汪先生,13925126339,3.34,1.0,2,2025-10-08 20:59 +14,林先生,13342871070,3.30,,1,2025-12-05 15:52 +15,桂先生(该会员已注销),16676777275(1),3.17,2.0,6,2025-10-16 19:37 +16,贺斌,15017500885,3.15,3.0,3,2025-11-03 18:42 +17,谢俊,18620395198,3.09,1.6,53,2025-12-22 20:03 +18,郑先生,15902794331,3.01,1.7,12,2025-12-10 22:12 +19,周先生,19350986822,2.92,2.0,11,2025-12-26 02:06 +20,王先生,18520321125,2.91,6.0,2,2025-12-02 21:00 +21,胡先生,18620043391,2.89,8.0,4,2025-12-01 19:01 +22,罗先生,13924036996,2.85,1.3,65,2025-12-25 22:25 +23,刘女士,17727637538,2.80,,1,2025-10-14 18:24 +24,魏先生,13726266862,2.70,1.0,2,2025-12-05 22:18 +25,吴生,13600453341,2.60,6.1,14,2025-12-25 17:51 +26,王先生,18302299763,2.58,,1,2025-12-24 18:30 +27,梅,13672464552,2.47,,1,2025-11-22 20:29 +28,郭先生,15622365001,2.43,3.5,3,2025-11-01 20:06 +29,艾宇民,15062279958,2.40,1.7,47,2025-12-25 20:28 +30,阿亮,15920462628,2.40,2.0,7,2025-12-04 20:05 +31,林先生,13763388785,2.39,2.0,6,2025-12-02 19:48 +32,李先生,13128264000,2.36,1.8,5,2025-11-07 23:01 +33,陈腾鑫,17817318218,2.35,1.5,50,2025-12-12 21:39 +34,昌哥,13798811229,2.27,16.0,2,2025-12-07 21:05 +35,张先生,13902258852,2.20,2.0,43,2025-12-24 18:42 +36,黄先生,13570163507,2.20,4.0,22,2025-12-24 15:58 +37,陈德韩,13431017864,2.18,4.2,6,2025-10-26 19:59 +38,罗先生,13922289222,2.16,9.4,6,2025-11-26 18:54 +39,陈世,13430271938,2.15,24.5,3,2025-11-20 21:45 +40,T,18028579962,2.14,6.9,9,2025-12-18 17:36 +41,大G,18680114598,2.10,1.0,2,2025-12-02 20:53 +42,卢广贤,18613066220,2.08,14.8,5,2025-12-13 15:27 +43,叶总,13711223287,2.08,1.5,7,2025-10-27 20:57 +44,杜先生,18826454705,2.03,,1,2025-11-02 20:12 +45,谭先生,13824473185,2.03,,1,2025-10-20 17:15 +46,杨,13066365960,2.00,2.0,2,2025-12-05 19:28 +47,小熊,13927020145,1.94,9.5,3,2025-11-04 17:36 +48,曾先生,13316091235,1.92,10.1,8,2025-12-24 11:53 +49,游,17267866666,1.89,1.6,8,2025-12-07 17:36 +50,夏,19120942851,1.88,1.8,6,2025-11-06 18:05 diff --git a/etl_billiards/reports/loyal_billiards_customers_gap_lt_20d_2025-12-26.csv b/etl_billiards/reports/loyal_billiards_customers_gap_lt_20d_2025-12-26.csv new file mode 100644 index 0000000..71cbf75 --- /dev/null +++ b/etl_billiards/reports/loyal_billiards_customers_gap_lt_20d_2025-12-26.csv @@ -0,0 +1,64 @@ +排名,客户姓名,联系方式,单次日打球小时数,到店平均间隔(天),10-12月来店次数,最后一次到店日期 +1,黄生,13609719719,7.53,1.8,43,2025-12-24 14:51 +2,曾巧明,18688471488,6.25,1.2,71,2025-12-25 15:49 +3,葛先生,13811638071,5.81,1.3,39,2025-12-26 00:55 +4,孟紫龙,17631643741,4.73,1.5,17,2025-12-23 16:27 +5,桂先生,16676777275,4.70,2.3,25,2025-12-16 19:05 +6,孟紫龙(该会员已注销),17631643741(1),4.64,2.0,6,2025-10-13 18:57 +7,候,13161960323,3.67,1.0,6,2025-12-17 23:13 +8,小燕,17802081334,3.58,1.2,29,2025-12-22 20:10 +9,牛先生,15201265159,3.44,1.0,3,2025-11-24 17:10 +10,李先生,13427574343,3.39,2.8,14,2025-12-24 22:49 +11,汪先生,13925126339,3.34,1.0,2,2025-10-08 20:59 +12,桂先生(该会员已注销),16676777275(1),3.17,2.0,6,2025-10-16 19:37 +13,贺斌,15017500885,3.15,3.0,3,2025-11-03 18:42 +14,谢俊,18620395198,3.09,1.6,53,2025-12-22 20:03 +15,郑先生,15902794331,3.01,1.7,12,2025-12-10 22:12 +16,周先生,19350986822,2.92,2.0,11,2025-12-26 02:06 +17,王先生,18520321125,2.91,6.0,2,2025-12-02 21:00 +18,胡先生,18620043391,2.89,8.0,4,2025-12-01 19:01 +19,罗先生,13924036996,2.85,1.3,65,2025-12-25 22:25 +20,魏先生,13726266862,2.70,1.0,2,2025-12-05 22:18 +21,吴生,13600453341,2.60,6.1,14,2025-12-25 17:51 +22,郭先生,15622365001,2.43,3.5,3,2025-11-01 20:06 +23,艾宇民,15062279958,2.40,1.7,47,2025-12-25 20:28 +24,阿亮,15920462628,2.40,2.0,7,2025-12-04 20:05 +25,林先生,13763388785,2.39,2.0,6,2025-12-02 19:48 +26,李先生,13128264000,2.36,1.8,5,2025-11-07 23:01 +27,陈腾鑫,17817318218,2.35,1.5,50,2025-12-12 21:39 +28,昌哥,13798811229,2.27,16.0,2,2025-12-07 21:05 +29,张先生,13902258852,2.20,2.0,43,2025-12-24 18:42 +30,黄先生,13570163507,2.20,4.0,22,2025-12-24 15:58 +31,陈德韩,13431017864,2.18,4.2,6,2025-10-26 19:59 +32,罗先生,13922289222,2.16,9.4,6,2025-11-26 18:54 +33,T,18028579962,2.14,6.9,9,2025-12-18 17:36 +34,大G,18680114598,2.10,1.0,2,2025-12-02 20:53 +35,卢广贤,18613066220,2.08,14.8,5,2025-12-13 15:27 +36,叶总,13711223287,2.08,1.5,7,2025-10-27 20:57 +37,杨,13066365960,2.00,2.0,2,2025-12-05 19:28 +38,小熊,13927020145,1.94,9.5,3,2025-11-04 17:36 +39,曾先生,13316091235,1.92,10.1,8,2025-12-24 11:53 +40,游,17267866666,1.89,1.6,8,2025-12-07 17:36 +41,夏,19120942851,1.88,1.8,6,2025-11-06 18:05 +42,胡总,13385143091,1.86,1.0,2,2025-12-20 21:00 +43,轩哥,18826267530,1.85,3.2,25,2025-12-18 02:11 +44,常总,18570077188,1.84,4.0,3,2025-12-22 19:21 +45,歌神,18819262164,1.75,2.0,3,2025-10-24 21:47 +46,君姐,16624614594,1.66,1.0,2,2025-11-27 21:22 +47,叶先生,13826479539,1.60,7.3,10,2025-12-05 23:07 +48,江先生,18819484838,1.52,5.1,15,2025-12-13 19:24 +49,黄先生,15818822109,1.48,7.8,7,2025-12-17 00:58 +50,张先生,13682854528,1.48,2.0,2,2025-12-19 22:37 +51,陶,18924022151,1.41,2.2,6,2025-10-26 23:52 +52,林总,13808881180,1.40,10.3,4,2025-12-24 19:18 +53,林先生,18826220332,1.36,17.7,4,2025-12-24 00:28 +54,李,13189179882,1.33,9.2,6,2025-11-24 12:38 +55,张丹逸,13609066637,1.20,1.0,2,2025-10-06 22:47 +56,罗超杰,13711268012,1.06,4.7,7,2025-11-05 15:50 +57,小宇,18745728077,1.03,10.0,2,2025-10-13 16:24 +58,蔡总,15914338893,1.01,3.1,17,2025-12-24 00:30 +59,羊,18785445094,0.99,3.0,9,2025-11-23 19:27 +60,林志铭,13570304233,0.98,13.0,2,2025-11-30 16:19 +61,钟智豪,18814002803,0.88,1.0,2,2025-11-28 23:50 +62,明哥,16620040999,0.66,5.5,3,2025-12-09 22:42 +63,冯先生,15588690348,0.46,1.0,2,2025-10-31 23:34 diff --git a/etl_billiards/reports/loyal_billiards_customers_report.py b/etl_billiards/reports/loyal_billiards_customers_report.py new file mode 100644 index 0000000..975ea81 --- /dev/null +++ b/etl_billiards/reports/loyal_billiards_customers_report.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- +""" +统计“忠实台球类竞技客户”Top N(按平均单次日打球小时数排序)。 + +口径(默认): +- 时间范围:2025-10-01 至今(可传参覆盖) +- 仅统计 member_id != 0 且有时长的客户 +- 台桌区域仅包含:A区、B区、C区、VIP包厢、斯诺克区、TV台 +- 同日同一客户若同时开多台:按时间区间并集计时(不重复叠加) +- “单次日打球小时数” = (时间范围内每日打球总小时数) / (有打球的日数) +- “到店平均间(天)” = 相邻来店日的平均间隔天数(来店日数<2 则为空) +- “10-12月来店次数” = 时间范围内来店日数(按日去重) +- “最后一次到店日期” = 该客户最后一次开台的 start_use_time(精确到分钟) + +输出:CSV(UTF-8-SIG,便于 Excel 打开) +""" + +from __future__ import annotations + +import argparse +import csv +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +from pathlib import Path +from typing import Iterable +from zoneinfo import ZoneInfo + +from config.settings import AppConfig +from database.connection import DatabaseConnection + + +DEFAULT_AREAS = ("A区", "B区", "C区", "VIP包厢", "斯诺克区", "TV台") + + +@dataclass(frozen=True) +class SessionRow: + member_id: int + start: datetime + end: datetime + area_name: str | None + + +@dataclass(frozen=True) +class MemberAgg: + member_id: int + total_hours: float + visit_days: int + avg_daily_hours: float + avg_gap_days: float | None + last_visit_at: datetime | None + + +def _as_tz(dt: datetime, tz: ZoneInfo) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=tz) + return dt.astimezone(tz) + + +def _parse_date(s: str) -> date: + s = (s or "").strip() + if len(s) >= 10: + s = s[:10] + return date.fromisoformat(s) + + +def _date_floor(dt: datetime, tz: ZoneInfo) -> datetime: + dt = _as_tz(dt, tz) + return datetime.combine(dt.date(), time.min).replace(tzinfo=tz) + + +def _split_by_day(start: datetime, end: datetime, tz: ZoneInfo) -> Iterable[tuple[date, datetime, datetime]]: + start = _as_tz(start, tz) + end = _as_tz(end, tz) + if end <= start: + return [] + cur = start + out: list[tuple[date, datetime, datetime]] = [] + while True: + day_end = _date_floor(cur, tz) + timedelta(days=1) + seg_end = end if end <= day_end else day_end + out.append((cur.date(), cur, seg_end)) + if seg_end >= end: + break + cur = seg_end + return out + + +def _union_seconds(intervals: list[tuple[datetime, datetime]], tz: ZoneInfo) -> int: + cleaned = [] + for s, e in intervals: + s = _as_tz(s, tz) + e = _as_tz(e, tz) + if e > s: + cleaned.append((s, e)) + if not cleaned: + return 0 + cleaned.sort(key=lambda x: x[0]) + total = 0 + cur_s, cur_e = cleaned[0] + for s, e in cleaned[1:]: + if s <= cur_e: + if e > cur_e: + cur_e = e + else: + total += int((cur_e - cur_s).total_seconds()) + cur_s, cur_e = s, e + total += int((cur_e - cur_s).total_seconds()) + return total + + +def _avg_gap_days(visit_dates: list[date]) -> float | None: + if len(visit_dates) < 2: + return None + visit_dates = sorted(set(visit_dates)) + if len(visit_dates) < 2: + return None + gaps = [(b - a).days for a, b in zip(visit_dates, visit_dates[1:]) if (b - a).days > 0] + if not gaps: + return None + return sum(gaps) / len(gaps) + + +def _load_sessions( + conn: DatabaseConnection, + *, + store_id: int, + tz: ZoneInfo, + start_dt: datetime, + end_dt: datetime, + areas: tuple[str, ...], +) -> list[SessionRow]: + sql = """ + SELECT + member_id, + start_use_time, + ledger_end_time, + real_table_use_seconds, + site_table_area_name + FROM billiards_dwd.dwd_table_fee_log + WHERE site_id = %s + AND member_id IS NOT NULL + AND member_id <> 0 + AND start_use_time >= %s + AND start_use_time < %s + AND site_table_area_name = ANY(%s) + """ + rows = conn.query(sql, (store_id, start_dt, end_dt, list(areas))) + sessions: list[SessionRow] = [] + for r in rows: + member_id = int(r.get("member_id") or 0) + if member_id <= 0: + continue + start = r.get("start_use_time") + end = r.get("ledger_end_time") + if not isinstance(start, datetime): + continue + if isinstance(end, datetime): + pass + else: + secs = r.get("real_table_use_seconds") + try: + secs = int(secs or 0) + except Exception: + secs = 0 + if secs > 0: + end = start + timedelta(seconds=secs) + else: + continue + + start = _as_tz(start, tz) + end = _as_tz(end, tz) + if end <= start: + continue + + sessions.append(SessionRow(member_id=member_id, start=start, end=end, area_name=r.get("site_table_area_name"))) + return sessions + + +def _load_member_profiles(conn: DatabaseConnection, member_ids: list[int]) -> dict[int, dict]: + if not member_ids: + return {} + sql = """ + SELECT member_id, nickname, mobile + FROM billiards_dwd.dim_member + WHERE scd2_is_current = 1 + AND member_id = ANY(%s) + """ + rows = conn.query(sql, (member_ids,)) + return {int(r["member_id"]): r for r in rows if r.get("member_id") is not None} + + +def _load_latest_settlement_contact(conn: DatabaseConnection, *, store_id: int, member_ids: list[int]) -> dict[int, dict]: + if not member_ids: + return {} + sql = """ + SELECT DISTINCT ON (member_id) + member_id, member_name, member_phone, pay_time + FROM billiards_dwd.dwd_settlement_head + WHERE site_id = %s + AND member_id = ANY(%s) + AND member_id <> 0 + ORDER BY member_id, pay_time DESC NULLS LAST + """ + rows = conn.query(sql, (store_id, member_ids)) + return {int(r["member_id"]): r for r in rows if r.get("member_id") is not None} + + +def _build_member_aggs(sessions: list[SessionRow], tz: ZoneInfo) -> list[MemberAgg]: + by_member_day: dict[int, dict[date, list[tuple[datetime, datetime]]]] = {} + last_visit_at: dict[int, datetime] = {} + + for s in sessions: + if s.member_id not in last_visit_at or s.start > last_visit_at[s.member_id]: + last_visit_at[s.member_id] = s.start + for d, seg_s, seg_e in _split_by_day(s.start, s.end, tz): + by_member_day.setdefault(s.member_id, {}).setdefault(d, []).append((seg_s, seg_e)) + + aggs: list[MemberAgg] = [] + for member_id, day_map in by_member_day.items(): + day_seconds = {d: _union_seconds(iv, tz) for d, iv in day_map.items()} + day_seconds = {d: sec for d, sec in day_seconds.items() if sec > 0} + if not day_seconds: + continue + visit_dates = sorted(day_seconds.keys()) + total_hours = sum(day_seconds.values()) / 3600.0 + visit_days = len(visit_dates) + avg_daily_hours = total_hours / visit_days if visit_days else 0.0 + aggs.append( + MemberAgg( + member_id=member_id, + total_hours=float(total_hours), + visit_days=int(visit_days), + avg_daily_hours=float(avg_daily_hours), + avg_gap_days=_avg_gap_days(visit_dates), + last_visit_at=last_visit_at.get(member_id), + ) + ) + + return aggs + + +def _write_report( + out_path: Path, + *, + rows: list[MemberAgg], + tz: ZoneInfo, + dim_profiles: dict[int, dict], + latest_settle: dict[int, dict], +): + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f) + w.writerow(["排名", "客户姓名", "联系方式", "单次日打球小时数", "到店平均间隔(天)", "10-12月来店次数", "最后一次到店日期"]) + for idx, row in enumerate(rows, start=1): + mid = int(row.member_id) + prof = dim_profiles.get(mid) or {} + settle = latest_settle.get(mid) or {} + name = settle.get("member_name") or prof.get("nickname") or "" + phone = settle.get("member_phone") or prof.get("mobile") or "" + last_visit_str = "" + if isinstance(row.last_visit_at, datetime): + last_visit_str = _as_tz(row.last_visit_at, tz).strftime("%Y-%m-%d %H:%M") + w.writerow( + [ + idx, + str(name), + str(phone), + f"{float(row.avg_daily_hours or 0.0):.2f}", + "" if row.avg_gap_days is None else f"{float(row.avg_gap_days):.1f}", + int(row.visit_days or 0), + last_visit_str, + ] + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Loyal billiards customers report") + parser.add_argument("--start-date", default="2025-10-01", help="YYYY-MM-DD") + parser.add_argument("--end-date", default="", help="YYYY-MM-DD (default: today)") + parser.add_argument("--top-n", type=int, default=50) + parser.add_argument("--areas", default=",".join(DEFAULT_AREAS), help="comma separated") + parser.add_argument("--out", default="", help="output csv path") + args = parser.parse_args() + + cfg = AppConfig.load({}) + tz = ZoneInfo(cfg.get("app.timezone", "Asia/Taipei")) + store_id = int(cfg.get("app.store_id")) + + start_date = _parse_date(args.start_date) + end_date = _parse_date(args.end_date) if args.end_date else datetime.now(tz).date() + if end_date < start_date: + raise SystemExit("end_date must be >= start_date") + + start_dt = datetime.combine(start_date, time.min).replace(tzinfo=tz) + end_dt = datetime.combine(end_date + timedelta(days=1), time.min).replace(tzinfo=tz) + + areas = tuple([a.strip() for a in str(args.areas or "").split(",") if a.strip()]) + if not areas: + raise SystemExit("areas is empty") + + conn = DatabaseConnection(dsn=cfg["db"]["dsn"], session=cfg["db"].get("session")) + try: + sessions = _load_sessions(conn, store_id=store_id, tz=tz, start_dt=start_dt, end_dt=end_dt, areas=areas) + aggs = _build_member_aggs(sessions, tz) + aggs.sort(key=lambda x: x.avg_daily_hours, reverse=True) + top_n = max(1, int(args.top_n)) + + picked_avg = aggs[:top_n] + picked_gap_lt_20 = [r for r in aggs if r.avg_gap_days is not None and r.avg_gap_days < 20] + + picked_visit = sorted(aggs, key=lambda x: (x.visit_days, x.avg_daily_hours), reverse=True)[:top_n] + picked_hours = sorted(aggs, key=lambda x: (x.total_hours, x.avg_daily_hours), reverse=True)[:top_n] + + member_ids = sorted({r.member_id for r in (picked_avg + picked_gap_lt_20 + picked_visit + picked_hours)}) + dim_profiles = _load_member_profiles(conn, member_ids) + latest_settle = _load_latest_settlement_contact(conn, store_id=store_id, member_ids=member_ids) + finally: + conn.close() + + today = datetime.now(tz).date() + base_dir = Path(args.out).parent if args.out else Path(__file__).parent + out_main = Path(args.out) if args.out else base_dir / f"loyal_billiards_customers_{today.isoformat()}.csv" + out_gap = base_dir / f"loyal_billiards_customers_gap_lt_20d_{today.isoformat()}.csv" + out_visit = base_dir / f"loyal_billiards_customers_top{top_n}_by_visit_days_{today.isoformat()}.csv" + out_hours = base_dir / f"loyal_billiards_customers_top{top_n}_by_total_hours_{today.isoformat()}.csv" + + _write_report(out_main, rows=picked_avg, tz=tz, dim_profiles=dim_profiles, latest_settle=latest_settle) + _write_report(out_gap, rows=picked_gap_lt_20, tz=tz, dim_profiles=dim_profiles, latest_settle=latest_settle) + _write_report(out_visit, rows=picked_visit, tz=tz, dim_profiles=dim_profiles, latest_settle=latest_settle) + _write_report(out_hours, rows=picked_hours, tz=tz, dim_profiles=dim_profiles, latest_settle=latest_settle) + + print(str(out_main)) + print(str(out_gap)) + print(str(out_visit)) + print(str(out_hours)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/etl_billiards/reports/loyal_billiards_customers_top50_by_total_hours_2025-12-26.csv b/etl_billiards/reports/loyal_billiards_customers_top50_by_total_hours_2025-12-26.csv new file mode 100644 index 0000000..6efa253 --- /dev/null +++ b/etl_billiards/reports/loyal_billiards_customers_top50_by_total_hours_2025-12-26.csv @@ -0,0 +1,51 @@ +排名,客户姓名,联系方式,单次日打球小时数,到店平均间隔(天),10-12月来店次数,最后一次到店日期 +1,曾巧明,18688471488,6.25,1.2,71,2025-12-25 15:49 +2,黄生,13609719719,7.53,1.8,43,2025-12-24 14:51 +3,葛先生,13811638071,5.81,1.3,39,2025-12-26 00:55 +4,罗先生,13924036996,2.85,1.3,65,2025-12-25 22:25 +5,谢俊,18620395198,3.09,1.6,53,2025-12-22 20:03 +6,桂先生,16676777275,4.70,2.3,25,2025-12-16 19:05 +7,陈腾鑫,17817318218,2.35,1.5,50,2025-12-12 21:39 +8,艾宇民,15062279958,2.40,1.7,47,2025-12-25 20:28 +9,小燕,17802081334,3.58,1.2,29,2025-12-22 20:10 +10,张先生,13902258852,2.20,2.0,43,2025-12-24 18:42 +11,孟紫龙,17631643741,4.73,1.5,17,2025-12-23 16:27 +12,黄先生,13570163507,2.20,4.0,22,2025-12-24 15:58 +13,李先生,13427574343,3.39,2.8,14,2025-12-24 22:49 +14,轩哥,18826267530,1.85,3.2,25,2025-12-18 02:11 +15,吴生,13600453341,2.60,6.1,14,2025-12-25 17:51 +16,郑先生,15902794331,3.01,1.7,12,2025-12-10 22:12 +17,周先生,19350986822,2.92,2.0,11,2025-12-26 02:06 +18,孟紫龙(该会员已注销),17631643741(1),4.64,2.0,6,2025-10-13 18:57 +19,江先生,18819484838,1.52,5.1,15,2025-12-13 19:24 +20,候,13161960323,3.67,1.0,6,2025-12-17 23:13 +21,T,18028579962,2.14,6.9,9,2025-12-18 17:36 +22,桂先生(该会员已注销),16676777275(1),3.17,2.0,6,2025-10-16 19:37 +23,蔡总,15914338893,1.01,3.1,17,2025-12-24 00:30 +24,阿亮,15920462628,2.40,2.0,7,2025-12-04 20:05 +25,叶先生,13826479539,1.60,7.3,10,2025-12-05 23:07 +26,曾先生,13316091235,1.92,10.1,8,2025-12-24 11:53 +27,游,17267866666,1.89,1.6,8,2025-12-07 17:36 +28,叶总,13711223287,2.08,1.5,7,2025-10-27 20:57 +29,林先生,13763388785,2.39,2.0,6,2025-12-02 19:48 +30,陈德韩,13431017864,2.18,4.2,6,2025-10-26 19:59 +31,罗先生,13922289222,2.16,9.4,6,2025-11-26 18:54 +32,李先生,13128264000,2.36,1.8,5,2025-11-07 23:01 +33,胡先生,18620043391,2.89,8.0,4,2025-12-01 19:01 +34,夏,19120942851,1.88,1.8,6,2025-11-06 18:05 +35,卢广贤,18613066220,2.08,14.8,5,2025-12-13 15:27 +36,黄先生,15818822109,1.48,7.8,7,2025-12-17 00:58 +37,牛先生,15201265159,3.44,1.0,3,2025-11-24 17:10 +38,贺斌,15017500885,3.15,3.0,3,2025-11-03 18:42 +39,羊,18785445094,0.99,3.0,9,2025-11-23 19:27 +40,陶,18924022151,1.41,2.2,6,2025-10-26 23:52 +41,李,13189179882,1.33,9.2,6,2025-11-24 12:38 +42,罗超杰,13711268012,1.06,4.7,7,2025-11-05 15:50 +43,郭先生,15622365001,2.43,3.5,3,2025-11-01 20:06 +44,汪先生,13925126339,3.34,1.0,2,2025-10-08 20:59 +45,陈世,13430271938,2.15,24.5,3,2025-11-20 21:45 +46,王先生,18520321125,2.91,6.0,2,2025-12-02 21:00 +47,小熊,13927020145,1.94,9.5,3,2025-11-04 17:36 +48,林总,13808881180,1.40,10.3,4,2025-12-24 19:18 +49,常总,18570077188,1.84,4.0,3,2025-12-22 19:21 +50,林先生,18826220332,1.36,17.7,4,2025-12-24 00:28 diff --git a/etl_billiards/reports/loyal_billiards_customers_top50_by_visit_days_2025-12-26.csv b/etl_billiards/reports/loyal_billiards_customers_top50_by_visit_days_2025-12-26.csv new file mode 100644 index 0000000..b0e4ce7 --- /dev/null +++ b/etl_billiards/reports/loyal_billiards_customers_top50_by_visit_days_2025-12-26.csv @@ -0,0 +1,51 @@ +排名,客户姓名,联系方式,单次日打球小时数,到店平均间隔(天),10-12月来店次数,最后一次到店日期 +1,曾巧明,18688471488,6.25,1.2,71,2025-12-25 15:49 +2,罗先生,13924036996,2.85,1.3,65,2025-12-25 22:25 +3,谢俊,18620395198,3.09,1.6,53,2025-12-22 20:03 +4,陈腾鑫,17817318218,2.35,1.5,50,2025-12-12 21:39 +5,艾宇民,15062279958,2.40,1.7,47,2025-12-25 20:28 +6,黄生,13609719719,7.53,1.8,43,2025-12-24 14:51 +7,张先生,13902258852,2.20,2.0,43,2025-12-24 18:42 +8,葛先生,13811638071,5.81,1.3,39,2025-12-26 00:55 +9,小燕,17802081334,3.58,1.2,29,2025-12-22 20:10 +10,桂先生,16676777275,4.70,2.3,25,2025-12-16 19:05 +11,轩哥,18826267530,1.85,3.2,25,2025-12-18 02:11 +12,黄先生,13570163507,2.20,4.0,22,2025-12-24 15:58 +13,孟紫龙,17631643741,4.73,1.5,17,2025-12-23 16:27 +14,蔡总,15914338893,1.01,3.1,17,2025-12-24 00:30 +15,江先生,18819484838,1.52,5.1,15,2025-12-13 19:24 +16,李先生,13427574343,3.39,2.8,14,2025-12-24 22:49 +17,吴生,13600453341,2.60,6.1,14,2025-12-25 17:51 +18,郑先生,15902794331,3.01,1.7,12,2025-12-10 22:12 +19,周先生,19350986822,2.92,2.0,11,2025-12-26 02:06 +20,叶先生,13826479539,1.60,7.3,10,2025-12-05 23:07 +21,T,18028579962,2.14,6.9,9,2025-12-18 17:36 +22,羊,18785445094,0.99,3.0,9,2025-11-23 19:27 +23,曾先生,13316091235,1.92,10.1,8,2025-12-24 11:53 +24,游,17267866666,1.89,1.6,8,2025-12-07 17:36 +25,阿亮,15920462628,2.40,2.0,7,2025-12-04 20:05 +26,叶总,13711223287,2.08,1.5,7,2025-10-27 20:57 +27,黄先生,15818822109,1.48,7.8,7,2025-12-17 00:58 +28,罗超杰,13711268012,1.06,4.7,7,2025-11-05 15:50 +29,孟紫龙(该会员已注销),17631643741(1),4.64,2.0,6,2025-10-13 18:57 +30,候,13161960323,3.67,1.0,6,2025-12-17 23:13 +31,桂先生(该会员已注销),16676777275(1),3.17,2.0,6,2025-10-16 19:37 +32,林先生,13763388785,2.39,2.0,6,2025-12-02 19:48 +33,陈德韩,13431017864,2.18,4.2,6,2025-10-26 19:59 +34,罗先生,13922289222,2.16,9.4,6,2025-11-26 18:54 +35,夏,19120942851,1.88,1.8,6,2025-11-06 18:05 +36,陶,18924022151,1.41,2.2,6,2025-10-26 23:52 +37,李,13189179882,1.33,9.2,6,2025-11-24 12:38 +38,李先生,13128264000,2.36,1.8,5,2025-11-07 23:01 +39,卢广贤,18613066220,2.08,14.8,5,2025-12-13 15:27 +40,胡先生,18620043391,2.89,8.0,4,2025-12-01 19:01 +41,林总,13808881180,1.40,10.3,4,2025-12-24 19:18 +42,林先生,18826220332,1.36,17.7,4,2025-12-24 00:28 +43,牛先生,15201265159,3.44,1.0,3,2025-11-24 17:10 +44,贺斌,15017500885,3.15,3.0,3,2025-11-03 18:42 +45,郭先生,15622365001,2.43,3.5,3,2025-11-01 20:06 +46,陈世,13430271938,2.15,24.5,3,2025-11-20 21:45 +47,小熊,13927020145,1.94,9.5,3,2025-11-04 17:36 +48,常总,18570077188,1.84,4.0,3,2025-12-22 19:21 +49,歌神,18819262164,1.75,2.0,3,2025-10-24 21:47 +50,明哥,16620040999,0.66,5.5,3,2025-12-09 22:42 diff --git a/etl_billiards/reports/loyal_customers_input_2025-12-26.txt b/etl_billiards/reports/loyal_customers_input_2025-12-26.txt new file mode 100644 index 0000000..156450c --- /dev/null +++ b/etl_billiards/reports/loyal_customers_input_2025-12-26.txt @@ -0,0 +1,72 @@ +黄生 13609719719 +曾巧明 18688471488 +葛先生 13811638071 +孟紫龙 17631643741 +桂先生 16676777275 +孟紫龙(该会员已注销) 17631643741(1) +陈先生 13922200419 +候 13161960323 +小燕 17802081334 +陈先生 15915782829 +牛先生 15201265159 +李先生 13427574343 +汪先生 13925126339 +林先生 13342871070 +桂先生(该会员已注销) 16676777275(1) +贺斌 15017500885 +谢俊 18620395198 +郑先生 15902794331 +周先生 19350986822 +王先生 18520321125 +胡先生 18620043391 +罗先生 13924036996 +刘女士 17727637538 +魏先生 13726266862 +吴生 13600453341 +王先生 18302299763 +梅 13672464552 +郭先生 15622365001 +艾宇民 15062279958 +阿亮 15920462628 +林先生 13763388785 +李先生 13128264000 +陈腾鑫 17817318218 +昌哥 13798811229 +张先生 13902258852 +黄先生 13570163507 +陈德韩 13431017864 +罗先生 13922289222 +陈世 13430271938 +T 18028579962 +大G 18680114598 +卢广贤 18613066220 +叶总 13711223287 +杜先生 18826454705 +谭先生 13824473185 +杨 13066365960 +小熊 13927020145 +曾先生 13316091235 +游 17267866666 +夏 19120942851 +胡总 13385143091 +轩哥 18826267530 +常总 18570077188 +歌神 18819262164 +君姐 16624614594 +叶先生 13826479539 +江先生 18819484838 +黄先生 15818822109 +张先生 13682854528 +陶 18924022151 +林总 13808881180 +林先生 18826220332 +李 13189179882 +张丹逸 13609066637 +罗超杰 13711268012 +小宇 18745728077 +蔡总 15914338893 +羊 18785445094 +林志铭 13570304233 +钟智豪 18814002803 +明哥 16620040999 +冯先生 15588690348 diff --git a/etl_billiards/reports/loyal_customers_total_hours_for_list.py b/etl_billiards/reports/loyal_customers_total_hours_for_list.py new file mode 100644 index 0000000..41abbac --- /dev/null +++ b/etl_billiards/reports/loyal_customers_total_hours_for_list.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +""" +按自定义名单统计累计打球总时长(同“忠实台球类竞技客户”口径) + +默认口径: +- 时间范围:2025-10-01 至今天(可传参覆盖) +- 仅统计 member_id != 0 且有时长的记录 +- 台桌区域过滤:A区/B区/C区/VIP包厢/斯诺克区/TV台(可传参覆盖) +- 同一客户同一天同时开多台:按时间区间并集计时(避免重复叠加) + +输入:每行 “姓名联系方式” +输出:CSV(UTF-8-SIG,便于 Excel 打开) + +运行方式(需在 etl_billiards/ 下以模块方式运行): + python -m reports.loyal_customers_total_hours_for_list --input reports/loyal_customers_input_2025-12-26.txt +""" + +from __future__ import annotations + +import argparse +import csv +import re +from dataclasses import dataclass +from datetime import datetime, time, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from config.settings import AppConfig +from database.connection import DatabaseConnection +from reports.loyal_billiards_customers_report import DEFAULT_AREAS, SessionRow, _as_tz, _build_member_aggs, _parse_date + + +@dataclass(frozen=True) +class InputRow: + idx: int + name: str + phone_raw: str + phone_digits: str + + +def _digits(s: str) -> str: + return re.sub(r"\D+", "", s or "") + + +def _normalize_name(s: str) -> str: + s = (s or "").strip() + s = re.sub(r"[((].*?[))]", "", s) + s = re.sub(r"\s+", "", s) + return s + + +def _read_input(path: Path) -> list[InputRow]: + rows: list[InputRow] = [] + with path.open("r", encoding="utf-8") as f: + for i, line in enumerate(f, start=1): + line = (line or "").strip() + if not line or line.startswith("#"): + continue + parts = re.split(r"\t+", line) + if len(parts) < 2: + parts = re.split(r"\s+", line) + if len(parts) < 2: + continue + name = parts[0].strip() + phone_raw = parts[1].strip() + rows.append(InputRow(idx=i, name=name, phone_raw=phone_raw, phone_digits=_digits(phone_raw))) + return rows + + +def _load_member_candidates_by_phone( + conn: DatabaseConnection, + *, + store_id: int, + phone_digits_list: list[str], +) -> dict[str, dict[int, str]]: + if not phone_digits_list: + return {} + sql = """ + SELECT DISTINCT + member_id, + member_name, + member_phone + FROM billiards_dwd.dwd_settlement_head + WHERE site_id = %s + AND member_id IS NOT NULL + AND member_id <> 0 + AND regexp_replace(COALESCE(member_phone, ''), '\\D', '', 'g') = ANY(%s) + """ + rows = conn.query(sql, (store_id, phone_digits_list)) + out: dict[str, dict[int, str]] = {} + for r in rows: + mid = int(r.get("member_id") or 0) + if mid <= 0: + continue + phone_digits = _digits(r.get("member_phone") or "") + if not phone_digits: + continue + name = str(r.get("member_name") or "") + out.setdefault(phone_digits, {}) + if mid not in out[phone_digits]: + out[phone_digits][mid] = name + return out + + +def _load_member_candidates_by_mobile( + conn: DatabaseConnection, + *, + phone_digits_list: list[str], +) -> dict[str, dict[int, str]]: + if not phone_digits_list: + return {} + sql = """ + SELECT member_id, nickname, mobile + FROM billiards_dwd.dim_member + WHERE scd2_is_current = 1 + AND member_id IS NOT NULL + AND member_id <> 0 + AND regexp_replace(COALESCE(mobile, ''), '\\D', '', 'g') = ANY(%s) + """ + rows = conn.query(sql, (phone_digits_list,)) + out: dict[str, dict[int, str]] = {} + for r in rows: + mid = int(r.get("member_id") or 0) + if mid <= 0: + continue + phone_digits = _digits(r.get("mobile") or "") + if not phone_digits: + continue + name = str(r.get("nickname") or "") + out.setdefault(phone_digits, {}) + if mid not in out[phone_digits]: + out[phone_digits][mid] = name + return out + + +def _resolve_member_ids( + row: InputRow, + *, + settle_candidates: dict[str, dict[int, str]], + dim_candidates: dict[str, dict[int, str]], +) -> list[int]: + phone_digits = row.phone_digits + candidate_map: dict[int, list[str]] = {} + + for mid, name in (settle_candidates.get(phone_digits) or {}).items(): + candidate_map.setdefault(int(mid), []).append(str(name or "")) + for mid, name in (dim_candidates.get(phone_digits) or {}).items(): + candidate_map.setdefault(int(mid), []).append(str(name or "")) + + candidate_ids = sorted(candidate_map.keys()) + if len(candidate_ids) <= 1: + return candidate_ids + + name_clean = _normalize_name(row.name) + if not name_clean: + return candidate_ids + + filtered: list[int] = [] + for mid in candidate_ids: + names = candidate_map.get(mid) or [] + if any(name_clean in _normalize_name(n) or _normalize_name(n) in name_clean for n in names if n): + filtered.append(mid) + return filtered or candidate_ids + + +def _load_sessions_for_members( + conn: DatabaseConnection, + *, + store_id: int, + tz: ZoneInfo, + start_dt: datetime, + end_dt: datetime, + areas: tuple[str, ...], + member_ids: list[int], +) -> list[SessionRow]: + if not member_ids: + return [] + sql = """ + SELECT + member_id, + start_use_time, + ledger_end_time, + real_table_use_seconds, + site_table_area_name + FROM billiards_dwd.dwd_table_fee_log + WHERE site_id = %s + AND member_id = ANY(%s) + AND start_use_time >= %s + AND start_use_time < %s + AND site_table_area_name = ANY(%s) + """ + rows = conn.query(sql, (store_id, member_ids, start_dt, end_dt, list(areas))) + sessions: list[SessionRow] = [] + for r in rows: + mid = int(r.get("member_id") or 0) + if mid <= 0: + continue + start = r.get("start_use_time") + end = r.get("ledger_end_time") + if not isinstance(start, datetime): + continue + if isinstance(end, datetime): + pass + else: + secs = r.get("real_table_use_seconds") + try: + secs = int(secs or 0) + except Exception: + secs = 0 + if secs > 0: + end = start + timedelta(seconds=secs) + else: + continue + + start = _as_tz(start, tz) + end = _as_tz(end, tz) + if end <= start: + continue + + sessions.append(SessionRow(member_id=mid, start=start, end=end, area_name=r.get("site_table_area_name"))) + return sessions + + +def _write_report(out_path: Path, *, tz: ZoneInfo, rows: list[dict]): + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f) + w.writerow( + [ + "排名", + "客户姓名", + "联系方式", + "10-12月打球总时长(小时)", + "来店日数", + "最后一次到店日期", + "匹配member_id列表", + "匹配状态", + ] + ) + for r in rows: + last_visit = r.get("last_visit_at") + last_visit_str = "" + if isinstance(last_visit, datetime): + last_visit_str = _as_tz(last_visit, tz).strftime("%Y-%m-%d %H:%M") + w.writerow( + [ + r.get("rank", ""), + r.get("name", ""), + r.get("phone_raw", ""), + f"{float(r.get('total_hours') or 0.0):.2f}", + int(r.get("visit_days") or 0), + last_visit_str, + r.get("member_ids_str", ""), + r.get("status", ""), + ] + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Total play hours for a custom customer list") + parser.add_argument("--input", default=str(Path(__file__).with_name("loyal_customers_input_2025-12-26.txt"))) + parser.add_argument("--start-date", default="2025-10-01", help="YYYY-MM-DD") + parser.add_argument("--end-date", default="", help="YYYY-MM-DD (default: today)") + parser.add_argument("--areas", default=",".join(DEFAULT_AREAS), help="comma separated") + parser.add_argument("--out", default="", help="output csv path") + args = parser.parse_args() + + cfg = AppConfig.load({}) + tz = ZoneInfo(cfg.get("app.timezone", "Asia/Taipei")) + store_id = int(cfg.get("app.store_id")) + + input_path = Path(args.input) + if not input_path.is_file(): + raise SystemExit(f"input not found: {input_path}") + + start_date = _parse_date(args.start_date) + end_date = _parse_date(args.end_date) if args.end_date else datetime.now(tz).date() + if end_date < start_date: + raise SystemExit("end_date must be >= start_date") + + start_dt = datetime.combine(start_date, time.min).replace(tzinfo=tz) + end_dt = datetime.combine(end_date + timedelta(days=1), time.min).replace(tzinfo=tz) + + areas = tuple([a.strip() for a in str(args.areas or "").split(",") if a.strip()]) + if not areas: + raise SystemExit("areas is empty") + + input_rows = _read_input(input_path) + phone_digits_list = sorted({r.phone_digits for r in input_rows if r.phone_digits}) + + conn = DatabaseConnection(dsn=cfg["db"]["dsn"], session=cfg["db"].get("session")) + try: + settle_candidates = _load_member_candidates_by_phone(conn, store_id=store_id, phone_digits_list=phone_digits_list) + dim_candidates = _load_member_candidates_by_mobile(conn, phone_digits_list=phone_digits_list) + + per_input_member_ids: dict[int, list[int]] = {} + all_member_ids: set[int] = set() + for r in input_rows: + mids = _resolve_member_ids(r, settle_candidates=settle_candidates, dim_candidates=dim_candidates) + per_input_member_ids[r.idx] = mids + all_member_ids.update(mids) + + sessions = _load_sessions_for_members( + conn, + store_id=store_id, + tz=tz, + start_dt=start_dt, + end_dt=end_dt, + areas=areas, + member_ids=sorted(all_member_ids), + ) + finally: + conn.close() + + aggs = _build_member_aggs(sessions, tz) + agg_by_member = {int(a.member_id): a for a in aggs} + + out_rows: list[dict] = [] + for r in input_rows: + mids = per_input_member_ids.get(r.idx) or [] + if not mids: + out_rows.append( + { + "rank": "", + "name": r.name, + "phone_raw": r.phone_raw, + "total_hours": 0.0, + "visit_days": 0, + "last_visit_at": None, + "member_ids_str": "", + "status": "NOT_FOUND", + } + ) + continue + + total_hours = 0.0 + visit_days = 0 + last_visit_at = None + found_any = False + for mid in mids: + agg = agg_by_member.get(int(mid)) + if not agg: + continue + found_any = True + total_hours += float(agg.total_hours or 0.0) + visit_days += int(agg.visit_days or 0) + if isinstance(agg.last_visit_at, datetime): + if last_visit_at is None or agg.last_visit_at > last_visit_at: + last_visit_at = agg.last_visit_at + + status = "OK" + if len(mids) > 1: + status = "MULTI_MEMBER_ID" + if not found_any: + status = "NO_SESSIONS" + + out_rows.append( + { + "rank": "", + "name": r.name, + "phone_raw": r.phone_raw, + "total_hours": total_hours, + "visit_days": visit_days, + "last_visit_at": last_visit_at, + "member_ids_str": ",".join([str(x) for x in mids]), + "status": status, + } + ) + + out_rows_sorted = sorted(out_rows, key=lambda x: float(x.get("total_hours") or 0.0), reverse=True) + for i, r in enumerate(out_rows_sorted, start=1): + r["rank"] = i + + today = datetime.now(tz).date().isoformat() + base_dir = Path(args.out).parent if args.out else Path(__file__).parent + out_path = Path(args.out) if args.out else base_dir / f"loyal_customers_total_hours_for_list_{today}.csv" + _write_report(out_path, tz=tz, rows=out_rows_sorted) + print(str(out_path)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/etl_billiards/reports/loyal_customers_total_hours_for_list_2025-12-26.csv b/etl_billiards/reports/loyal_customers_total_hours_for_list_2025-12-26.csv new file mode 100644 index 0000000..d7d14fe --- /dev/null +++ b/etl_billiards/reports/loyal_customers_total_hours_for_list_2025-12-26.csv @@ -0,0 +1,73 @@ +排名,客户姓名,联系方式,10-12月打球总时长(小时),来店日数,最后一次到店日期,匹配member_id列表,匹配状态 +1,曾巧明,18688471488,443.80,71,2025-12-25 15:49,2799207403554565,OK +2,黄生,13609719719,323.64,43,2025-12-24 14:51,2799207390349061,OK +3,葛先生,13811638071,226.56,39,2025-12-26 00:55,2799207363643141,OK +4,罗先生,13924036996,185.01,65,2025-12-25 22:25,2799207359858437,OK +5,谢俊,18620395198,163.86,53,2025-12-22 20:03,2799207352715013,OK +6,桂先生,16676777275,117.52,25,2025-12-16 19:05,2933647801731013,OK +7,陈腾鑫,17817318218,117.32,50,2025-12-12 21:39,2799207124305669,OK +8,艾宇民,15062279958,112.83,47,2025-12-25 20:28,2799207328155397,OK +9,小燕,17802081334,103.72,29,2025-12-22 20:10,2969257129938053,OK +10,张先生,13902258852,94.55,43,2025-12-24 18:42,2799207406946053,OK +11,孟紫龙,17631643741,80.36,17,2025-12-23 16:27,2985941423934469,OK +12,黄先生,13570163507,48.36,22,2025-12-24 15:58,2799212430657285,OK +13,李先生,13427574343,47.40,14,2025-12-24 22:49,2970668087594181,OK +14,轩哥,18826267530,46.27,25,2025-12-18 02:11,2799207522600709,OK +15,吴生,13600453341,36.47,14,2025-12-25 17:51,2799207356434181,OK +16,郑先生,15902794331,36.14,12,2025-12-10 22:12,2976361970370373,OK +17,周先生,19350986822,32.16,11,2025-12-26 02:06,2995832745758917,OK +18,孟紫龙(该会员已注销),17631643741(1),27.85,6,2025-10-13 18:57,"2799212728911621,2878376367018757",MULTI_MEMBER_ID +19,江先生,18819484838,22.87,15,2025-12-13 19:24,2820625955784965,OK +20,候,13161960323,22.01,6,2025-12-17 23:13,3003552553390789,OK +21,T,18028579962,19.25,9,2025-12-18 17:36,2935271033079557,OK +22,桂先生(该会员已注销),16676777275(1),19.03,6,2025-10-16 19:37,2881216340641797,OK +23,蔡总,15914338893,17.18,17,2025-12-24 00:30,2799212491392773,OK +24,阿亮,15920462628,16.79,7,2025-12-04 20:05,2976376546117574,OK +25,叶先生,13826479539,15.95,10,2025-12-05 23:07,2799207342704389,OK +26,曾先生,13316091235,15.33,8,2025-12-24 11:53,2799210181019397,OK +27,游,17267866666,15.10,8,2025-12-07 17:36,2799207435323141,OK +28,叶总,13711223287,14.55,7,2025-10-27 20:57,2844990190242821,OK +29,林先生,13763388785,14.34,6,2025-12-02 19:48,2799207067109125,OK +30,陈德韩,13431017864,13.08,6,2025-10-26 19:59,2799209806071557,OK +31,罗先生,13922289222,12.94,6,2025-11-26 18:54,2799209768765189,OK +32,李先生,13128264000,11.81,5,2025-11-07 23:01,2799207545685765,OK +33,胡先生,18620043391,11.56,4,2025-12-01 19:01,2955204541320325,OK +34,夏,19120942851,11.30,6,2025-11-06 18:05,2799207519176453,OK +35,卢广贤,18613066220,10.41,5,2025-12-13 15:27,2799207163447045,OK +36,黄先生,15818822109,10.39,7,2025-12-17 00:58,2846153189592005,OK +37,牛先生,15201265159,10.33,3,2025-11-24 17:10,2973479575832453,OK +38,贺斌,15017500885,9.45,3,2025-11-03 18:42,2938229628340421,OK +39,羊,18785445094,8.91,9,2025-11-23 19:27,2799207378798341,OK +40,陶,18924022151,8.47,6,2025-10-26 23:52,2919518015802181,OK +41,李,13189179882,7.98,6,2025-11-24 12:38,2860039721438277,OK +42,罗超杰,13711268012,7.45,7,2025-11-05 15:50,2799207338198789,OK +43,郭先生,15622365001,7.29,3,2025-11-01 20:06,2847747357002757,OK +44,汪先生,13925126339,6.69,2,2025-10-08 20:59,2799207287523077,OK +45,陈世,13430271938,6.45,3,2025-11-20 21:45,2799207229441797,OK +46,王先生,18520321125,5.82,2,2025-12-02 21:00,2970386005050949,OK +47,小熊,13927020145,5.82,3,2025-11-04 17:36,2799207599212293,OK +48,林总,13808881180,5.61,4,2025-12-24 19:18,2799207256426245,OK +49,常总,18570077188,5.52,3,2025-12-22 19:21,3003185854190085,OK +50,林先生,18826220332,5.46,4,2025-12-24 00:28,2946070922169029,OK +51,魏先生,13726266862,5.39,2,2025-12-05 22:18,2799209794651909,OK +52,歌神,18819262164,5.25,3,2025-10-24 21:47,2799207370163973,OK +53,昌哥,13798811229,4.54,2,2025-12-07 21:05,2974770547348357,OK +54,大G,18680114598,4.20,2,2025-12-02 20:53,2799207533332229,OK +55,杨,13066365960,4.00,2,2025-12-05 19:28,2799212333647621,OK +56,陈先生,13922200419,3.92,1,2025-10-18 12:35,2799210049521413,OK +57,胡总,13385143091,3.72,2,2025-12-20 21:00,2799209753708293,OK +58,陈先生,15915782829,3.48,1,2025-11-07 00:44,2799207290996485,OK +59,君姐,16624614594,3.32,2,2025-11-27 21:22,2983452013021509,OK +60,林先生,13342871070,3.30,1,2025-12-05 15:52,2976465665476741,OK +61,张先生,13682854528,2.96,2,2025-12-19 22:37,2963357031615941,OK +62,刘女士,17727637538,2.80,1,2025-10-14 18:24,2853881398644101,OK +63,王先生,18302299763,2.58,1,2025-12-24 18:30,2973199975761797,OK +64,梅,13672464552,2.47,1,2025-11-22 20:29,2975065345119045,OK +65,张丹逸,13609066637,2.40,2,2025-10-06 22:47,2799207176636165,OK +66,小宇,18745728077,2.06,2,2025-10-13 16:24,2799212906235653,OK +67,杜先生,18826454705,2.03,1,2025-11-02 20:12,2799209786836741,OK +68,谭先生,13824473185,2.03,1,2025-10-20 17:15,2929237914683013,OK +69,明哥,16620040999,1.99,3,2025-12-09 22:42,2799210064873221,OK +70,林志铭,13570304233,1.96,2,2025-11-30 16:19,2799207188170501,OK +71,钟智豪,18814002803,1.76,2,2025-11-28 23:50,2938228399917253,OK +72,冯先生,15588690348,0.93,2,2025-10-31 23:34,2799212808029957,OK diff --git a/etl_billiards/reports/ods_gap_check_20260115_073006.json b/etl_billiards/reports/ods_gap_check_20260115_073006.json new file mode 100644 index 0000000..ead4710 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_073006.json @@ -0,0 +1,385 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T07:28:43.859529+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 64, + "records_with_pk": 64, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.assistant_accounts_master t J...\n ^\n" + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 1, + "records_with_pk": 1, + "missing": 0, + "missing_samples": [], + "pages": 16, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.settlement_records t JOIN (VA...\n ^\n" + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.table_fee_transactions t JOIN...\n ^\n" + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 21, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.assistant_service_records t J...\n ^\n" + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 1, + "records_with_pk": 1, + "missing": 0, + "missing_samples": [], + "pages": 106, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.assistant_cancellation_record...\n ^\n" + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.payment_transactions t JOIN (...\n ^\n" + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 37, + "records_with_pk": 37, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.refund_transactions t JOIN (V...\n ^\n" + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.platform_coupon_redemption_re...\n ^\n" + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.member_profiles t JOIN (VALUE...\n ^\n" + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.member_stored_value_cards t J...\n ^\n" + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.member_balance_changes t JOIN...\n ^\n" + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 1, + "records_with_pk": 1, + "missing": 0, + "missing_samples": [], + "pages": 21, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.recharge_settlements t JOIN (...\n ^\n" + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.group_buy_packages t JOIN (VA...\n ^\n" + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.group_buy_redemption_records ...\n ^\n" + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"sitegoodsid\" 是不明确的\nLINE 1: SELECT \"sitegoodsid\" FROM billiards_ods.goods_stock_summary ...\n ^\n" + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 62, + "records_with_pk": 62, + "missing": 0, + "missing_samples": [], + "pages": 18, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"sitegoodsstockid\" 是不明确的\nLINE 1: SELECT \"sitegoodsstockid\" FROM billiards_ods.goods_stock_mov...\n ^\n" + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.site_tables_master t JOIN (VA...\n ^\n" + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.stock_goods_category_tree t J...\n ^\n" + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.store_goods_master t JOIN (VA...\n ^\n" + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 200, + "records_with_pk": 200, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.table_fee_discount_records t ...\n ^\n" + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "AmbiguousColumn: 错误: 字段关联 \"id\" 是不明确的\nLINE 1: SELECT \"id\" FROM billiards_ods.tenant_goods_master t JOIN (V...\n ^\n" + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22265, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 203, + "skipped_missing_pk": 22265, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 0, + "total_errors": 21, + "generated_at": "2026-01-15T07:30:06.858721+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_073926.json b/etl_billiards/reports/ods_gap_check_20260115_073926.json new file mode 100644 index 0000000..5a89acc --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_073926.json @@ -0,0 +1,838 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T07:30:39.379740+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 12736, + "records_with_pk": 12736, + "missing": 0, + "missing_samples": [], + "pages": 199, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21625, + "records_with_pk": 21625, + "missing": 5554, + "missing_samples": [ + { + "id": 2793005672007237 + }, + { + "id": 2794775029944453 + }, + { + "id": 2794723221669957 + }, + { + "id": 2795846081054981 + }, + { + "id": 2797459120852869 + }, + { + "id": 2797370356928517 + }, + { + "id": 2797170093527045 + }, + { + "id": 2797169961897989 + }, + { + "id": 2798802248288261 + }, + { + "id": 2798636101421317 + }, + { + "id": 2800804425353349 + }, + { + "id": 2800797279504261 + }, + { + "id": 2800796097267589 + }, + { + "id": 2800795223918661 + }, + { + "id": 2800792829986693 + }, + { + "id": 2800791828547461 + }, + { + "id": 2800790859499397 + }, + { + "id": 2800779801462661 + }, + { + "id": 2800779692902277 + }, + { + "id": 2800771371960389 + }, + { + "id": 2800770730772485 + }, + { + "id": 2800770224490629 + }, + { + "id": 2800765767043077 + }, + { + "id": 2800753238165381 + }, + { + "id": 2800745545254789 + }, + { + "id": 2800734778001413 + }, + { + "id": 2800719579875205 + }, + { + "id": 2800714836166661 + }, + { + "id": 2800714753411013 + }, + { + "id": 2800712176470085 + }, + { + "id": 2800711353370501 + }, + { + "id": 2800706200422341 + }, + { + "id": 2800702845536197 + }, + { + "id": 2800701729900485 + }, + { + "id": 2800700704409605 + }, + { + "id": 2800698665420741 + }, + { + "id": 2800693776926661 + }, + { + "id": 2800678221105093 + }, + { + "id": 2800677073487813 + }, + { + "id": 2800675629844549 + }, + { + "id": 2800675308767109 + }, + { + "id": 2800659322996613 + }, + { + "id": 2800653584042053 + }, + { + "id": 2800653414778821 + }, + { + "id": 2800651389634630 + }, + { + "id": 2800643230435205 + }, + { + "id": 2800637690595269 + }, + { + "id": 2800630547269509 + }, + { + "id": 2800620726110149 + }, + { + "id": 2800619316873221 + } + ], + "pages": 202, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9827, + "records_with_pk": 9827, + "missing": 0, + "missing_samples": [], + "pages": 50, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4431, + "records_with_pk": 4431, + "missing": 0, + "missing_samples": [], + "pages": 199, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 199, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11847, + "records_with_pk": 11847, + "missing": 0, + "missing_samples": [], + "pages": 60, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 37, + "records_with_pk": 37, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16006, + "records_with_pk": 16006, + "missing": 0, + "missing_samples": [], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2432, + "records_with_pk": 2432, + "missing": 0, + "missing_samples": [], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 405, + "records_with_pk": 405, + "missing": 122, + "missing_samples": [ + { + "id": 2800763348731909 + }, + { + "id": 2802064357345349 + }, + { + "id": 2801879399532933 + }, + { + "id": 2800824057628741 + }, + { + "id": 2803649174965445 + }, + { + "id": 2803524215835845 + }, + { + "id": 2802480974171333 + }, + { + "id": 2802393839258885 + }, + { + "id": 2802362619840581 + }, + { + "id": 2804767340973317 + }, + { + "id": 2803710575593541 + }, + { + "id": 2803666932010565 + }, + { + "id": 2803661545508421 + }, + { + "id": 2806426635208517 + }, + { + "id": 2806371278325381 + }, + { + "id": 2806244951017285 + }, + { + "id": 2805188835477573 + }, + { + "id": 2805121951074373 + }, + { + "id": 2805111552117957 + }, + { + "id": 2805070136823365 + }, + { + "id": 2807891556897349 + }, + { + "id": 2807815241847557 + }, + { + "id": 2806859908384389 + }, + { + "id": 2806855233947525 + }, + { + "id": 2806708031637381 + }, + { + "id": 2809187656829061 + }, + { + "id": 2808923174783109 + }, + { + "id": 2807912803045957 + }, + { + "id": 2807909672309637 + }, + { + "id": 2810563362048453 + }, + { + "id": 2810546930207237 + }, + { + "id": 2810412839373574 + }, + { + "id": 2810371281799621 + }, + { + "id": 2809504839059909 + }, + { + "id": 2809471788321221 + }, + { + "id": 2809392429549765 + }, + { + "id": 2811829130856901 + }, + { + "id": 2810801124133381 + }, + { + "id": 2810800364193989 + }, + { + "id": 2812352420645317 + }, + { + "id": 2812164953934597 + }, + { + "id": 2812160863095557 + }, + { + "id": 2814476335547909 + }, + { + "id": 2813729032046789 + }, + { + "id": 2816375660284933 + }, + { + "id": 2816013068586693 + }, + { + "id": 2815107864956613 + }, + { + "id": 2817594853805701 + }, + { + "id": 2816455124847557 + }, + { + "id": 2819131204669189 + } + ], + "pages": 199, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8218, + "records_with_pk": 8218, + "missing": 0, + "missing_samples": [], + "pages": 42, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28417, + "records_with_pk": 28417, + "missing": 128, + "missing_samples": [ + { + "sitegoodsstockid": 2796087732473349 + }, + { + "sitegoodsstockid": 2796087732375045 + }, + { + "sitegoodsstockid": 2796085792214469 + }, + { + "sitegoodsstockid": 2796085791641029 + }, + { + "sitegoodsstockid": 2796085792116165 + }, + { + "sitegoodsstockid": 2796085791542725 + }, + { + "sitegoodsstockid": 2796082589486533 + }, + { + "sitegoodsstockid": 2796082589027781 + }, + { + "sitegoodsstockid": 2796082588552645 + }, + { + "sitegoodsstockid": 2796082587995589 + }, + { + "sitegoodsstockid": 2796082587520453 + }, + { + "sitegoodsstockid": 2796082587028933 + }, + { + "sitegoodsstockid": 2796082586570181 + }, + { + "sitegoodsstockid": 2796082586013125 + }, + { + "sitegoodsstockid": 2796082585505221 + }, + { + "sitegoodsstockid": 2796082589584837 + }, + { + "sitegoodsstockid": 2796082589126085 + }, + { + "sitegoodsstockid": 2796082588650949 + }, + { + "sitegoodsstockid": 2796082588110277 + }, + { + "sitegoodsstockid": 2796082587618757 + }, + { + "sitegoodsstockid": 2796082587127237 + }, + { + "sitegoodsstockid": 2796082586668485 + }, + { + "sitegoodsstockid": 2796082586111429 + }, + { + "sitegoodsstockid": 2796082585603525 + }, + { + "sitegoodsstockid": 2796082584784325 + }, + { + "sitegoodsstockid": 2796082584292805 + }, + { + "sitegoodsstockid": 2796082583768517 + }, + { + "sitegoodsstockid": 2796082583260613 + }, + { + "sitegoodsstockid": 2796082582752709 + }, + { + "sitegoodsstockid": 2796082584686021 + }, + { + "sitegoodsstockid": 2796082584194501 + }, + { + "sitegoodsstockid": 2796082583653829 + }, + { + "sitegoodsstockid": 2796082583162309 + }, + { + "sitegoodsstockid": 2796082582654405 + }, + { + "sitegoodsstockid": 2796074872851973 + }, + { + "sitegoodsstockid": 2796074872753669 + }, + { + "sitegoodsstockid": 2796074436742661 + }, + { + "sitegoodsstockid": 2796074436578821 + }, + { + "sitegoodsstockid": 2796074059976133 + }, + { + "sitegoodsstockid": 2796074059894213 + }, + { + "sitegoodsstockid": 2796073653128709 + }, + { + "sitegoodsstockid": 2796073653030405 + }, + { + "sitegoodsstockid": 2796073284095429 + }, + { + "sitegoodsstockid": 2796073283997125 + }, + { + "sitegoodsstockid": 2796072902577605 + }, + { + "sitegoodsstockid": 2796072902675909 + }, + { + "sitegoodsstockid": 2796072030784965 + }, + { + "sitegoodsstockid": 2796072030703045 + }, + { + "sitegoodsstockid": 2796070969183685 + }, + { + "sitegoodsstockid": 2796070969298373 + } + ], + "pages": 238, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1649, + "records_with_pk": 1649, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22265, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 203, + "skipped_missing_pk": 22265, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 5804, + "total_errors": 0, + "generated_at": "2026-01-15T07:39:26.592004+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_103858.json b/etl_billiards/reports/ods_gap_check_20260115_103858.json new file mode 100644 index 0000000..d800600 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_103858.json @@ -0,0 +1,854 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T09:48:49.829033+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 50816, + "records_with_pk": 50816, + "missing": 0, + "missing_samples": [], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21513, + "records_with_pk": 21513, + "missing": 5554, + "missing_samples": [ + { + "id": 2793005672007237 + }, + { + "id": 2794775029944453 + }, + { + "id": 2794723221669957 + }, + { + "id": 2795846081054981 + }, + { + "id": 2797170093527045 + }, + { + "id": 2797169961897989 + }, + { + "id": 2797459120852869 + }, + { + "id": 2797370356928517 + }, + { + "id": 2798636101421317 + }, + { + "id": 2798802248288261 + }, + { + "id": 2800105539552709 + }, + { + "id": 2800050273584581 + }, + { + "id": 2800462764542021 + }, + { + "id": 2800460224120773 + }, + { + "id": 2800454900648005 + }, + { + "id": 2800448023513157 + }, + { + "id": 2800442606962629 + }, + { + "id": 2800433643243525 + }, + { + "id": 2800430651182085 + }, + { + "id": 2800428852725765 + }, + { + "id": 2800423540131717 + }, + { + "id": 2800421742806917 + }, + { + "id": 2800420036233221 + }, + { + "id": 2800410925254597 + }, + { + "id": 2800410227705733 + }, + { + "id": 2800392593475653 + }, + { + "id": 2800388746184581 + }, + { + "id": 2800388462479301 + }, + { + "id": 2800383220615109 + }, + { + "id": 2800379828701189 + }, + { + "id": 2800379750893445 + }, + { + "id": 2800379609925573 + }, + { + "id": 2800378758907845 + }, + { + "id": 2800378535118789 + }, + { + "id": 2800371960039365 + }, + { + "id": 2800363039770693 + }, + { + "id": 2800360608303045 + }, + { + "id": 2800353168738309 + }, + { + "id": 2800353054263173 + }, + { + "id": 2800348877604741 + }, + { + "id": 2800348098742341 + }, + { + "id": 2800343364913157 + }, + { + "id": 2800328443021317 + }, + { + "id": 2800320895223685 + }, + { + "id": 2800316938717189 + }, + { + "id": 2800311915366341 + }, + { + "id": 2800301266225093 + }, + { + "id": 2800272176744389 + }, + { + "id": 2800265338046277 + }, + { + "id": 2800262033213317 + } + ], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9751, + "records_with_pk": 9751, + "missing": 1, + "missing_samples": [ + { + "id": 3051863815030085 + } + ], + "pages": 49, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4415, + "records_with_pk": 4415, + "missing": 1, + "missing_samples": [ + { + "id": 3051863815488837 + } + ], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 78, + "records_with_pk": 78, + "missing": 0, + "missing_samples": [], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11733, + "records_with_pk": 11733, + "missing": 1, + "missing_samples": [ + { + "id": 3051863811867973 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16006, + "records_with_pk": 16006, + "missing": 0, + "missing_samples": [], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2407, + "records_with_pk": 2407, + "missing": 1, + "missing_samples": [ + { + "id": 3051863812408645 + } + ], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 405, + "records_with_pk": 405, + "missing": 122, + "missing_samples": [ + { + "id": 2800763348731909 + }, + { + "id": 2800824057628741 + }, + { + "id": 2801879399532933 + }, + { + "id": 2802064357345349 + }, + { + "id": 2802480974171333 + }, + { + "id": 2802393839258885 + }, + { + "id": 2802362619840581 + }, + { + "id": 2803649174965445 + }, + { + "id": 2803524215835845 + }, + { + "id": 2803710575593541 + }, + { + "id": 2803666932010565 + }, + { + "id": 2803661545508421 + }, + { + "id": 2804767340973317 + }, + { + "id": 2805188835477573 + }, + { + "id": 2805121951074373 + }, + { + "id": 2805111552117957 + }, + { + "id": 2805070136823365 + }, + { + "id": 2806426635208517 + }, + { + "id": 2806371278325381 + }, + { + "id": 2806244951017285 + }, + { + "id": 2806708031637381 + }, + { + "id": 2806859908384389 + }, + { + "id": 2806855233947525 + }, + { + "id": 2807891556897349 + }, + { + "id": 2807815241847557 + }, + { + "id": 2807912803045957 + }, + { + "id": 2807909672309637 + }, + { + "id": 2808923174783109 + }, + { + "id": 2809187656829061 + }, + { + "id": 2809504839059909 + }, + { + "id": 2809471788321221 + }, + { + "id": 2809392429549765 + }, + { + "id": 2810371281799621 + }, + { + "id": 2810563362048453 + }, + { + "id": 2810546930207237 + }, + { + "id": 2810412839373574 + }, + { + "id": 2810801124133381 + }, + { + "id": 2810800364193989 + }, + { + "id": 2811829130856901 + }, + { + "id": 2812352420645317 + }, + { + "id": 2812164953934597 + }, + { + "id": 2812160863095557 + }, + { + "id": 2813729032046789 + }, + { + "id": 2814476335547909 + }, + { + "id": 2815107864956613 + }, + { + "id": 2816013068586693 + }, + { + "id": 2816375660284933 + }, + { + "id": 2816455124847557 + }, + { + "id": 2817594853805701 + }, + { + "id": 2818031067369029 + } + ], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8155, + "records_with_pk": 8155, + "missing": 0, + "missing_samples": [], + "pages": 41, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28249, + "records_with_pk": 28249, + "missing": 128, + "missing_samples": [ + { + "sitegoodsstockid": 2796087732473349 + }, + { + "sitegoodsstockid": 2796087732375045 + }, + { + "sitegoodsstockid": 2796085792214469 + }, + { + "sitegoodsstockid": 2796085791641029 + }, + { + "sitegoodsstockid": 2796085792116165 + }, + { + "sitegoodsstockid": 2796085791542725 + }, + { + "sitegoodsstockid": 2796082589486533 + }, + { + "sitegoodsstockid": 2796082589027781 + }, + { + "sitegoodsstockid": 2796082588552645 + }, + { + "sitegoodsstockid": 2796082587995589 + }, + { + "sitegoodsstockid": 2796082587520453 + }, + { + "sitegoodsstockid": 2796082587028933 + }, + { + "sitegoodsstockid": 2796082586570181 + }, + { + "sitegoodsstockid": 2796082586013125 + }, + { + "sitegoodsstockid": 2796082585505221 + }, + { + "sitegoodsstockid": 2796082589584837 + }, + { + "sitegoodsstockid": 2796082589126085 + }, + { + "sitegoodsstockid": 2796082588650949 + }, + { + "sitegoodsstockid": 2796082588110277 + }, + { + "sitegoodsstockid": 2796082587618757 + }, + { + "sitegoodsstockid": 2796082587127237 + }, + { + "sitegoodsstockid": 2796082586668485 + }, + { + "sitegoodsstockid": 2796082586111429 + }, + { + "sitegoodsstockid": 2796082585603525 + }, + { + "sitegoodsstockid": 2796082584784325 + }, + { + "sitegoodsstockid": 2796082584292805 + }, + { + "sitegoodsstockid": 2796082583768517 + }, + { + "sitegoodsstockid": 2796082583260613 + }, + { + "sitegoodsstockid": 2796082582752709 + }, + { + "sitegoodsstockid": 2796082584686021 + }, + { + "sitegoodsstockid": 2796082584194501 + }, + { + "sitegoodsstockid": 2796082583653829 + }, + { + "sitegoodsstockid": 2796082583162309 + }, + { + "sitegoodsstockid": 2796082582654405 + }, + { + "sitegoodsstockid": 2796074872851973 + }, + { + "sitegoodsstockid": 2796074872753669 + }, + { + "sitegoodsstockid": 2796074436742661 + }, + { + "sitegoodsstockid": 2796074436578821 + }, + { + "sitegoodsstockid": 2796074059976133 + }, + { + "sitegoodsstockid": 2796074059894213 + }, + { + "sitegoodsstockid": 2796073653128709 + }, + { + "sitegoodsstockid": 2796073653030405 + }, + { + "sitegoodsstockid": 2796073284095429 + }, + { + "sitegoodsstockid": 2796073283997125 + }, + { + "sitegoodsstockid": 2796072902577605 + }, + { + "sitegoodsstockid": 2796072902675909 + }, + { + "sitegoodsstockid": 2796072030784965 + }, + { + "sitegoodsstockid": 2796072030703045 + }, + { + "sitegoodsstockid": 2796070969183685 + }, + { + "sitegoodsstockid": 2796070969298373 + } + ], + "pages": 797, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1633, + "records_with_pk": 1633, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22153, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 794, + "skipped_missing_pk": 22153, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 5808, + "total_errors": 0, + "generated_at": "2026-01-15T10:38:58.926701+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_104948.json b/etl_billiards/reports/ods_gap_check_20260115_104948.json new file mode 100644 index 0000000..48d3e56 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_104948.json @@ -0,0 +1,854 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T09:28:34.669129+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 50816, + "records_with_pk": 50816, + "missing": 0, + "missing_samples": [], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21513, + "records_with_pk": 21513, + "missing": 5554, + "missing_samples": [ + { + "id": 2793005672007237 + }, + { + "id": 2794775029944453 + }, + { + "id": 2794723221669957 + }, + { + "id": 2795846081054981 + }, + { + "id": 2797170093527045 + }, + { + "id": 2797169961897989 + }, + { + "id": 2797459120852869 + }, + { + "id": 2797370356928517 + }, + { + "id": 2798636101421317 + }, + { + "id": 2798802248288261 + }, + { + "id": 2800105539552709 + }, + { + "id": 2800050273584581 + }, + { + "id": 2800462764542021 + }, + { + "id": 2800460224120773 + }, + { + "id": 2800454900648005 + }, + { + "id": 2800448023513157 + }, + { + "id": 2800442606962629 + }, + { + "id": 2800433643243525 + }, + { + "id": 2800430651182085 + }, + { + "id": 2800428852725765 + }, + { + "id": 2800423540131717 + }, + { + "id": 2800421742806917 + }, + { + "id": 2800420036233221 + }, + { + "id": 2800410925254597 + }, + { + "id": 2800410227705733 + }, + { + "id": 2800392593475653 + }, + { + "id": 2800388746184581 + }, + { + "id": 2800388462479301 + }, + { + "id": 2800383220615109 + }, + { + "id": 2800379828701189 + }, + { + "id": 2800379750893445 + }, + { + "id": 2800379609925573 + }, + { + "id": 2800378758907845 + }, + { + "id": 2800378535118789 + }, + { + "id": 2800371960039365 + }, + { + "id": 2800363039770693 + }, + { + "id": 2800360608303045 + }, + { + "id": 2800353168738309 + }, + { + "id": 2800353054263173 + }, + { + "id": 2800348877604741 + }, + { + "id": 2800348098742341 + }, + { + "id": 2800343364913157 + }, + { + "id": 2800328443021317 + }, + { + "id": 2800320895223685 + }, + { + "id": 2800316938717189 + }, + { + "id": 2800311915366341 + }, + { + "id": 2800301266225093 + }, + { + "id": 2800272176744389 + }, + { + "id": 2800265338046277 + }, + { + "id": 2800262033213317 + } + ], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9751, + "records_with_pk": 9751, + "missing": 1, + "missing_samples": [ + { + "id": 3051863815030085 + } + ], + "pages": 49, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4415, + "records_with_pk": 4415, + "missing": 1, + "missing_samples": [ + { + "id": 3051863815488837 + } + ], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 78, + "records_with_pk": 78, + "missing": 0, + "missing_samples": [], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11733, + "records_with_pk": 11733, + "missing": 1, + "missing_samples": [ + { + "id": 3051863811867973 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16006, + "records_with_pk": 16006, + "missing": 0, + "missing_samples": [], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2407, + "records_with_pk": 2407, + "missing": 1, + "missing_samples": [ + { + "id": 3051863812408645 + } + ], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 405, + "records_with_pk": 405, + "missing": 122, + "missing_samples": [ + { + "id": 2800763348731909 + }, + { + "id": 2800824057628741 + }, + { + "id": 2801879399532933 + }, + { + "id": 2802064357345349 + }, + { + "id": 2802480974171333 + }, + { + "id": 2802393839258885 + }, + { + "id": 2802362619840581 + }, + { + "id": 2803649174965445 + }, + { + "id": 2803524215835845 + }, + { + "id": 2803710575593541 + }, + { + "id": 2803666932010565 + }, + { + "id": 2803661545508421 + }, + { + "id": 2804767340973317 + }, + { + "id": 2805188835477573 + }, + { + "id": 2805121951074373 + }, + { + "id": 2805111552117957 + }, + { + "id": 2805070136823365 + }, + { + "id": 2806426635208517 + }, + { + "id": 2806371278325381 + }, + { + "id": 2806244951017285 + }, + { + "id": 2806708031637381 + }, + { + "id": 2806859908384389 + }, + { + "id": 2806855233947525 + }, + { + "id": 2807891556897349 + }, + { + "id": 2807815241847557 + }, + { + "id": 2807912803045957 + }, + { + "id": 2807909672309637 + }, + { + "id": 2808923174783109 + }, + { + "id": 2809187656829061 + }, + { + "id": 2809504839059909 + }, + { + "id": 2809471788321221 + }, + { + "id": 2809392429549765 + }, + { + "id": 2810371281799621 + }, + { + "id": 2810563362048453 + }, + { + "id": 2810546930207237 + }, + { + "id": 2810412839373574 + }, + { + "id": 2810801124133381 + }, + { + "id": 2810800364193989 + }, + { + "id": 2811829130856901 + }, + { + "id": 2812352420645317 + }, + { + "id": 2812164953934597 + }, + { + "id": 2812160863095557 + }, + { + "id": 2813729032046789 + }, + { + "id": 2814476335547909 + }, + { + "id": 2815107864956613 + }, + { + "id": 2816013068586693 + }, + { + "id": 2816375660284933 + }, + { + "id": 2816455124847557 + }, + { + "id": 2817594853805701 + }, + { + "id": 2818031067369029 + } + ], + "pages": 794, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8155, + "records_with_pk": 8155, + "missing": 0, + "missing_samples": [], + "pages": 41, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28249, + "records_with_pk": 28249, + "missing": 128, + "missing_samples": [ + { + "sitegoodsstockid": 2796087732473349 + }, + { + "sitegoodsstockid": 2796087732375045 + }, + { + "sitegoodsstockid": 2796085792214469 + }, + { + "sitegoodsstockid": 2796085791641029 + }, + { + "sitegoodsstockid": 2796085792116165 + }, + { + "sitegoodsstockid": 2796085791542725 + }, + { + "sitegoodsstockid": 2796082589486533 + }, + { + "sitegoodsstockid": 2796082589027781 + }, + { + "sitegoodsstockid": 2796082588552645 + }, + { + "sitegoodsstockid": 2796082587995589 + }, + { + "sitegoodsstockid": 2796082587520453 + }, + { + "sitegoodsstockid": 2796082587028933 + }, + { + "sitegoodsstockid": 2796082586570181 + }, + { + "sitegoodsstockid": 2796082586013125 + }, + { + "sitegoodsstockid": 2796082585505221 + }, + { + "sitegoodsstockid": 2796082589584837 + }, + { + "sitegoodsstockid": 2796082589126085 + }, + { + "sitegoodsstockid": 2796082588650949 + }, + { + "sitegoodsstockid": 2796082588110277 + }, + { + "sitegoodsstockid": 2796082587618757 + }, + { + "sitegoodsstockid": 2796082587127237 + }, + { + "sitegoodsstockid": 2796082586668485 + }, + { + "sitegoodsstockid": 2796082586111429 + }, + { + "sitegoodsstockid": 2796082585603525 + }, + { + "sitegoodsstockid": 2796082584784325 + }, + { + "sitegoodsstockid": 2796082584292805 + }, + { + "sitegoodsstockid": 2796082583768517 + }, + { + "sitegoodsstockid": 2796082583260613 + }, + { + "sitegoodsstockid": 2796082582752709 + }, + { + "sitegoodsstockid": 2796082584686021 + }, + { + "sitegoodsstockid": 2796082584194501 + }, + { + "sitegoodsstockid": 2796082583653829 + }, + { + "sitegoodsstockid": 2796082583162309 + }, + { + "sitegoodsstockid": 2796082582654405 + }, + { + "sitegoodsstockid": 2796074872851973 + }, + { + "sitegoodsstockid": 2796074872753669 + }, + { + "sitegoodsstockid": 2796074436742661 + }, + { + "sitegoodsstockid": 2796074436578821 + }, + { + "sitegoodsstockid": 2796074059976133 + }, + { + "sitegoodsstockid": 2796074059894213 + }, + { + "sitegoodsstockid": 2796073653128709 + }, + { + "sitegoodsstockid": 2796073653030405 + }, + { + "sitegoodsstockid": 2796073284095429 + }, + { + "sitegoodsstockid": 2796073283997125 + }, + { + "sitegoodsstockid": 2796072902577605 + }, + { + "sitegoodsstockid": 2796072902675909 + }, + { + "sitegoodsstockid": 2796072030784965 + }, + { + "sitegoodsstockid": 2796072030703045 + }, + { + "sitegoodsstockid": 2796070969183685 + }, + { + "sitegoodsstockid": 2796070969298373 + } + ], + "pages": 797, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1633, + "records_with_pk": 1633, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22153, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 794, + "skipped_missing_pk": 22153, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 5808, + "total_errors": 0, + "generated_at": "2026-01-15T10:49:48.767932+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_131821.json b/etl_billiards/reports/ods_gap_check_20260115_131821.json new file mode 100644 index 0000000..15f3a8f --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_131821.json @@ -0,0 +1,466 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T12:26:36.334001+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 50880, + "records_with_pk": 50880, + "missing": 0, + "missing_samples": [], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21514, + "records_with_pk": 21514, + "missing": 1, + "missing_samples": [ + { + "id": 3052076252251589 + } + ], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9752, + "records_with_pk": 9752, + "missing": 2, + "missing_samples": [ + { + "id": 3052128803980869 + }, + { + "id": 3051863815030085 + } + ], + "pages": 49, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4415, + "records_with_pk": 4415, + "missing": 1, + "missing_samples": [ + { + "id": 3051863815488837 + } + ], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 78, + "records_with_pk": 78, + "missing": 0, + "missing_samples": [], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11736, + "records_with_pk": 11736, + "missing": 4, + "missing_samples": [ + { + "id": 3052137658025541 + }, + { + "id": 3052128801408581 + }, + { + "id": 3052076274943365 + }, + { + "id": 3051863811867973 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16013, + "records_with_pk": 16013, + "missing": 6, + "missing_samples": [ + { + "id": 3052120802903429 + }, + { + "id": 3052110365689221 + }, + { + "id": 3052091273414213 + }, + { + "id": 3052081711859141 + }, + { + "id": 3052063649959365 + }, + { + "id": 3052026368034565 + } + ], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2407, + "records_with_pk": 2407, + "missing": 1, + "missing_samples": [ + { + "id": 3051863812408645 + } + ], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 405, + "records_with_pk": 405, + "missing": 1, + "missing_samples": [ + { + "id": 2832289312279685 + } + ], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8157, + "records_with_pk": 8157, + "missing": 2, + "missing_samples": [ + { + "id": 3052142428931461 + }, + { + "id": 3052128804521541 + } + ], + "pages": 41, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28255, + "records_with_pk": 28255, + "missing": 6, + "missing_samples": [ + { + "sitegoodsstockid": 3052076276811141 + }, + { + "sitegoodsstockid": 3052076276630917 + }, + { + "sitegoodsstockid": 3052093657615493 + }, + { + "sitegoodsstockid": 3052093657549957 + }, + { + "sitegoodsstockid": 3052090913623365 + }, + { + "sitegoodsstockid": 3052090913557829 + } + ], + "pages": 798, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1633, + "records_with_pk": 1633, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22154, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 795, + "skipped_missing_pk": 22154, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 24, + "total_errors": 0, + "generated_at": "2026-01-15T13:18:21.158800+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_161345.json b/etl_billiards/reports/ods_gap_check_20260115_161345.json new file mode 100644 index 0000000..3e51576 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_161345.json @@ -0,0 +1,637 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T15:20:49.891695+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 50880, + "records_with_pk": 50880, + "missing": 0, + "missing_samples": [], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21529, + "records_with_pk": 21529, + "missing": 16, + "missing_samples": [ + { + "id": 3052076252251589 + }, + { + "id": 3052248841586181 + }, + { + "id": 3052246644557253 + }, + { + "id": 3052208895166021 + }, + { + "id": 3052208828270085 + }, + { + "id": 3052206795720133 + }, + { + "id": 3052201188050501 + }, + { + "id": 3052180955448837 + }, + { + "id": 3052173364315589 + }, + { + "id": 3052163277669957 + }, + { + "id": 3052163174696517 + }, + { + "id": 3052147004147205 + }, + { + "id": 3052142531233349 + }, + { + "id": 3052142411367877 + }, + { + "id": 3052137630057989 + }, + { + "id": 3052128780748293 + } + ], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9766, + "records_with_pk": 9766, + "missing": 16, + "missing_samples": [ + { + "id": 3052300682069957 + }, + { + "id": 3052294634882885 + }, + { + "id": 3052248857986437 + }, + { + "id": 3052246666462789 + }, + { + "id": 3052208915236357 + }, + { + "id": 3052208850044485 + }, + { + "id": 3052201204237701 + }, + { + "id": 3052180970997125 + }, + { + "id": 3052173380224389 + }, + { + "id": 3052163300967941 + }, + { + "id": 3052163191883269 + }, + { + "id": 3052147159025221 + }, + { + "id": 3052142428374405 + }, + { + "id": 3052137660286533 + }, + { + "id": 3052128803980869 + }, + { + "id": 3051863815030085 + } + ], + "pages": 49, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4415, + "records_with_pk": 4415, + "missing": 1, + "missing_samples": [ + { + "id": 3051863815488837 + } + ], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 78, + "records_with_pk": 78, + "missing": 0, + "missing_samples": [], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11751, + "records_with_pk": 11751, + "missing": 19, + "missing_samples": [ + { + "id": 3052300679546821 + }, + { + "id": 3052294632818501 + }, + { + "id": 3052248855692677 + }, + { + "id": 3052246663988805 + }, + { + "id": 3052208912549381 + }, + { + "id": 3052208847406661 + }, + { + "id": 3052206817199493 + }, + { + "id": 3052201201649029 + }, + { + "id": 3052180968310149 + }, + { + "id": 3052173377963397 + }, + { + "id": 3052163298772485 + }, + { + "id": 3052163189163525 + }, + { + "id": 3052147156256325 + }, + { + "id": 3052142551893573 + }, + { + "id": 3052142425949573 + }, + { + "id": 3052137658025541 + }, + { + "id": 3052128801408581 + }, + { + "id": 3052076274943365 + }, + { + "id": 3051863811867973 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16036, + "records_with_pk": 16036, + "missing": 3, + "missing_samples": [ + { + "id": 3052311888218053 + }, + { + "id": 3052310918104901 + }, + { + "id": 3052305891788677 + } + ], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2407, + "records_with_pk": 2407, + "missing": 1, + "missing_samples": [ + { + "id": 3051863812408645 + } + ], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 405, + "records_with_pk": 405, + "missing": 1, + "missing_samples": [ + { + "id": 2832289312279685 + } + ], + "pages": 795, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8168, + "records_with_pk": 8168, + "missing": 13, + "missing_samples": [ + { + "id": 3052300682807237 + }, + { + "id": 3052294635407173 + }, + { + "id": 3052248858707333 + }, + { + "id": 3052246667150917 + }, + { + "id": 3052208915777029 + }, + { + "id": 3052208850716229 + }, + { + "id": 3052201204778373 + }, + { + "id": 3052180971554181 + }, + { + "id": 3052173380846981 + }, + { + "id": 3052163301492229 + }, + { + "id": 3052163192456709 + }, + { + "id": 3052142428931461 + }, + { + "id": 3052128804521541 + } + ], + "pages": 41, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28260, + "records_with_pk": 28260, + "missing": 11, + "missing_samples": [ + { + "sitegoodsstockid": 3052076276811141 + }, + { + "sitegoodsstockid": 3052076276630917 + }, + { + "sitegoodsstockid": 3052243917669893 + }, + { + "sitegoodsstockid": 3052222543218053 + }, + { + "sitegoodsstockid": 3052206819034501 + }, + { + "sitegoodsstockid": 3052142553990725 + }, + { + "sitegoodsstockid": 3052142553810501 + }, + { + "sitegoodsstockid": 3052093657615493 + }, + { + "sitegoodsstockid": 3052093657549957 + }, + { + "sitegoodsstockid": 3052090913623365 + }, + { + "sitegoodsstockid": 3052090913557829 + } + ], + "pages": 798, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1633, + "records_with_pk": 1633, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22169, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 795, + "skipped_missing_pk": 22169, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 81, + "total_errors": 0, + "generated_at": "2026-01-15T16:13:45.329617+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_162208.json b/etl_billiards/reports/ods_gap_check_20260115_162208.json new file mode 100644 index 0000000..60e80b5 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_162208.json @@ -0,0 +1,666 @@ +{ + "start": "2026-01-01T00:00:00+08:00", + "end": "2026-01-15T16:15:44.950334+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 3776, + "records_with_pk": 3776, + "missing": 0, + "missing_samples": [], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 1607, + "records_with_pk": 1607, + "missing": 19, + "missing_samples": [ + { + "id": 3052076252251589 + }, + { + "id": 3052322370635653 + }, + { + "id": 3052300651513733 + }, + { + "id": 3052294618351365 + }, + { + "id": 3052248841586181 + }, + { + "id": 3052246644557253 + }, + { + "id": 3052208895166021 + }, + { + "id": 3052208828270085 + }, + { + "id": 3052206795720133 + }, + { + "id": 3052201188050501 + }, + { + "id": 3052180955448837 + }, + { + "id": 3052173364315589 + }, + { + "id": 3052163277669957 + }, + { + "id": 3052163174696517 + }, + { + "id": 3052147004147205 + }, + { + "id": 3052142531233349 + }, + { + "id": 3052142411367877 + }, + { + "id": 3052137630057989 + }, + { + "id": 3052128780748293 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9767, + "records_with_pk": 9767, + "missing": 17, + "missing_samples": [ + { + "id": 3052322387674885 + }, + { + "id": 3052300682069957 + }, + { + "id": 3052294634882885 + }, + { + "id": 3052248857986437 + }, + { + "id": 3052246666462789 + }, + { + "id": 3052208915236357 + }, + { + "id": 3052208850044485 + }, + { + "id": 3052201204237701 + }, + { + "id": 3052180970997125 + }, + { + "id": 3052173380224389 + }, + { + "id": 3052163300967941 + }, + { + "id": 3052163191883269 + }, + { + "id": 3052147159025221 + }, + { + "id": 3052142428374405 + }, + { + "id": 3052137660286533 + }, + { + "id": 3052128803980869 + }, + { + "id": 3051863815030085 + } + ], + "pages": 49, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 357, + "records_with_pk": 357, + "missing": 1, + "missing_samples": [ + { + "id": 3051863815488837 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 24, + "records_with_pk": 24, + "missing": 0, + "missing_samples": [], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11752, + "records_with_pk": 11752, + "missing": 20, + "missing_samples": [ + { + "id": 3052322385266437 + }, + { + "id": 3052300679546821 + }, + { + "id": 3052294632818501 + }, + { + "id": 3052248855692677 + }, + { + "id": 3052246663988805 + }, + { + "id": 3052208912549381 + }, + { + "id": 3052208847406661 + }, + { + "id": 3052206817199493 + }, + { + "id": 3052201201649029 + }, + { + "id": 3052180968310149 + }, + { + "id": 3052173377963397 + }, + { + "id": 3052163298772485 + }, + { + "id": 3052163189163525 + }, + { + "id": 3052147156256325 + }, + { + "id": 3052142551893573 + }, + { + "id": 3052142425949573 + }, + { + "id": 3052137658025541 + }, + { + "id": 3052128801408581 + }, + { + "id": 3052076274943365 + }, + { + "id": 3051863811867973 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16038, + "records_with_pk": 16038, + "missing": 5, + "missing_samples": [ + { + "id": 3052337405658950 + }, + { + "id": 3052337405658949 + }, + { + "id": 3052311888218053 + }, + { + "id": 3052310918104901 + }, + { + "id": 3052305891788677 + } + ], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2407, + "records_with_pk": 2407, + "missing": 1, + "missing_samples": [ + { + "id": 3051863812408645 + } + ], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 34, + "records_with_pk": 34, + "missing": 0, + "missing_samples": [], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8175, + "records_with_pk": 8175, + "missing": 14, + "missing_samples": [ + { + "id": 3052322388297477 + }, + { + "id": 3052300682807237 + }, + { + "id": 3052294635407173 + }, + { + "id": 3052248858707333 + }, + { + "id": 3052246667150917 + }, + { + "id": 3052208915777029 + }, + { + "id": 3052208850716229 + }, + { + "id": 3052201204778373 + }, + { + "id": 3052180971554181 + }, + { + "id": 3052173380846981 + }, + { + "id": 3052163301492229 + }, + { + "id": 3052163192456709 + }, + { + "id": 3052142428931461 + }, + { + "id": 3052128804521541 + } + ], + "pages": 41, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 2121, + "records_with_pk": 2121, + "missing": 14, + "missing_samples": [ + { + "sitegoodsstockid": 3052076276811141 + }, + { + "sitegoodsstockid": 3052076276630917 + }, + { + "sitegoodsstockid": 3052287917508421 + }, + { + "sitegoodsstockid": 3052287916263237 + }, + { + "sitegoodsstockid": 3052287914215237 + }, + { + "sitegoodsstockid": 3052243917669893 + }, + { + "sitegoodsstockid": 3052222543218053 + }, + { + "sitegoodsstockid": 3052206819034501 + }, + { + "sitegoodsstockid": 3052142553990725 + }, + { + "sitegoodsstockid": 3052142553810501 + }, + { + "sitegoodsstockid": 3052093657615493 + }, + { + "sitegoodsstockid": 3052093657549957 + }, + { + "sitegoodsstockid": 3052090913623365 + }, + { + "sitegoodsstockid": 3052090913557829 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1633, + "records_with_pk": 1633, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 1656, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 59, + "skipped_missing_pk": 1656, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 91, + "total_errors": 0, + "generated_at": "2026-01-15T16:22:08.953307+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_185455.json b/etl_billiards/reports/ods_gap_check_20260115_185455.json new file mode 100644 index 0000000..63b4d06 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_185455.json @@ -0,0 +1,793 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T18:31:28.819229+08:00", + "cutoff": null, + "window_days": 1, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 12736, + "records_with_pk": 12736, + "missing": 0, + "missing_samples": [], + "pages": 199, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21555, + "records_with_pk": 21555, + "missing": 20, + "missing_samples": [ + { + "id": 3052456015579909 + }, + { + "id": 3052454986812421 + }, + { + "id": 3052451320171589 + }, + { + "id": 3052450396441349 + }, + { + "id": 3052435016272581 + }, + { + "id": 3052434918165573 + }, + { + "id": 3052434834279173 + }, + { + "id": 3052424705084101 + }, + { + "id": 3052424301988549 + }, + { + "id": 3052422092426309 + }, + { + "id": 3052422042291205 + }, + { + "id": 3052416805587717 + }, + { + "id": 3052416349539013 + }, + { + "id": 3052414381706245 + }, + { + "id": 3052405284129477 + }, + { + "id": 3052372817201861 + }, + { + "id": 3052370135074565 + }, + { + "id": 3052365518669701 + }, + { + "id": 3052360602781445 + }, + { + "id": 3052348366227269 + } + ], + "pages": 202, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9790, + "records_with_pk": 9790, + "missing": 13, + "missing_samples": [ + { + "id": 3052456031243333 + }, + { + "id": 3052455010716357 + }, + { + "id": 3052450413448261 + }, + { + "id": 3052435034573573 + }, + { + "id": 3052434948410437 + }, + { + "id": 3052434862345285 + }, + { + "id": 3052424721894149 + }, + { + "id": 3052422108547781 + }, + { + "id": 3052422059330309 + }, + { + "id": 3052416822201413 + }, + { + "id": 3052416383371973 + }, + { + "id": 3052414398089989 + }, + { + "id": 3052405301316293 + } + ], + "pages": 49, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4416, + "records_with_pk": 4416, + "missing": 2, + "missing_samples": [ + { + "id": 3052424373423109 + }, + { + "id": 3051863815488837 + } + ], + "pages": 199, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 7, + "records_with_pk": 7, + "missing": 0, + "missing_samples": [], + "pages": 126, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "HTTPError: 502 Server Error: Bad Gateway for url: https://pc.ficoo.vip/apiprod/admin/v1/AssistantPerformance/GetAbolitionAssistant" + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 0, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "HTTPError: 502 Server Error: Bad Gateway for url: https://pc.ficoo.vip/apiprod/admin/v1/TenantGoods/GetGoodsSalesList" + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 0, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "HTTPError: 502 Server Error: Bad Gateway for url: https://pc.ficoo.vip/apiprod/admin/v1/PayLog/GetPayLogListPage" + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16058, + "records_with_pk": 16058, + "missing": 25, + "missing_samples": [ + { + "id": 3052472789158981 + }, + { + "id": 3052464759196741 + }, + { + "id": 3052449213533253 + }, + { + "id": 3052427220864005 + }, + { + "id": 3052423892765381 + }, + { + "id": 3052423497239621 + }, + { + "id": 3052423330941957 + }, + { + "id": 3052423193480197 + }, + { + "id": 3052421983881925 + }, + { + "id": 3052417450494725 + }, + { + "id": 3052391048710853 + }, + { + "id": 3052375480896197 + }, + { + "id": 3052373085588229 + }, + { + "id": 3052365403277253 + }, + { + "id": 3052363066066885 + }, + { + "id": 3052362943629253 + }, + { + "id": 3052360961984261 + }, + { + "id": 3052357851465478 + }, + { + "id": 3052355338749893 + }, + { + "id": 3052345004771077 + }, + { + "id": 3052337405658950 + }, + { + "id": 3052337405658949 + }, + { + "id": 3052311888218053 + }, + { + "id": 3052310918104901 + }, + { + "id": 3052305891788677 + } + ], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2408, + "records_with_pk": 2408, + "missing": 2, + "missing_samples": [ + { + "id": 3052424371244037 + }, + { + "id": 3051863812408645 + } + ], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 405, + "records_with_pk": 405, + "missing": 1, + "missing_samples": [ + { + "id": 2832289312279685 + } + ], + "pages": 199, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8201, + "records_with_pk": 8201, + "missing": 46, + "missing_samples": [ + { + "id": 3052482484392069 + }, + { + "id": 3052481203670085 + }, + { + "id": 3052476693515333 + }, + { + "id": 3052456031882310 + }, + { + "id": 3052456031882309 + }, + { + "id": 3052455011551941 + }, + { + "id": 3052450414201925 + }, + { + "id": 3052435035147014 + }, + { + "id": 3052435035147013 + }, + { + "id": 3052434949147717 + }, + { + "id": 3052434862902341 + }, + { + "id": 3052424722467589 + }, + { + "id": 3052422109268677 + }, + { + "id": 3052422059936517 + }, + { + "id": 3052416823086149 + }, + { + "id": 3052416384174790 + }, + { + "id": 3052416384174789 + }, + { + "id": 3052414398679813 + }, + { + "id": 3052405301840581 + }, + { + "id": 3052372838681349 + }, + { + "id": 3052370153048005 + }, + { + "id": 3052365536249669 + }, + { + "id": 3052360620558278 + }, + { + "id": 3052360620558277 + }, + { + "id": 3052348384495366 + }, + { + "id": 3052348384495365 + }, + { + "id": 3052339892700998 + }, + { + "id": 3052339892700997 + }, + { + "id": 3052339838781254 + }, + { + "id": 3052339838781253 + }, + { + "id": 3052339780601670 + }, + { + "id": 3052339780601669 + }, + { + "id": 3052322388297477 + }, + { + "id": 3052300682807237 + }, + { + "id": 3052294635407173 + }, + { + "id": 3052248858707333 + }, + { + "id": 3052246667150917 + }, + { + "id": 3052208915777029 + }, + { + "id": 3052208850716229 + }, + { + "id": 3052201204778373 + }, + { + "id": 3052180971554181 + }, + { + "id": 3052173380846981 + }, + { + "id": 3052163301492229 + }, + { + "id": 3052163192456709 + }, + { + "id": 3052142428931461 + }, + { + "id": 3052128804521541 + } + ], + "pages": 42, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28272, + "records_with_pk": 28272, + "missing": 23, + "missing_samples": [ + { + "sitegoodsstockid": 3052458794765381 + }, + { + "sitegoodsstockid": 3052451366964229 + }, + { + "sitegoodsstockid": 3052421675027205 + }, + { + "sitegoodsstockid": 3052421675502341 + }, + { + "sitegoodsstockid": 3052386208909957 + }, + { + "sitegoodsstockid": 3052386208975493 + }, + { + "sitegoodsstockid": 3052365069420485 + }, + { + "sitegoodsstockid": 3052363193403333 + }, + { + "sitegoodsstockid": 3052350941661061 + }, + { + "sitegoodsstockid": 3052287914215237 + }, + { + "sitegoodsstockid": 3052287916263237 + }, + { + "sitegoodsstockid": 3052287917508421 + }, + { + "sitegoodsstockid": 3052243917669893 + }, + { + "sitegoodsstockid": 3052222543218053 + }, + { + "sitegoodsstockid": 3052206819034501 + }, + { + "sitegoodsstockid": 3052142553990725 + }, + { + "sitegoodsstockid": 3052142553810501 + }, + { + "sitegoodsstockid": 3052093657549957 + }, + { + "sitegoodsstockid": 3052093657615493 + }, + { + "sitegoodsstockid": 3052090913557829 + }, + { + "sitegoodsstockid": 3052090913623365 + }, + { + "sitegoodsstockid": 3052076276811141 + }, + { + "sitegoodsstockid": 3052076276630917 + } + ], + "pages": 238, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1634, + "records_with_pk": 1634, + "missing": 1, + "missing_samples": [ + { + "id": 3052476605991685 + } + ], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22193, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 203, + "skipped_missing_pk": 22193, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 133, + "total_errors": 3, + "generated_at": "2026-01-15T18:54:55.925533+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_190203.json b/etl_billiards/reports/ods_gap_check_20260115_190203.json new file mode 100644 index 0000000..1a6785f --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_190203.json @@ -0,0 +1,933 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T18:54:48.797928+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21561, + "records_with_pk": 21561, + "missing": 26, + "missing_samples": [ + { + "id": 3052486983602373 + }, + { + "id": 3052486257037317 + }, + { + "id": 3052482466484421 + }, + { + "id": 3052481176259653 + }, + { + "id": 3052476676655813 + }, + { + "id": 3052476470053893 + }, + { + "id": 3052456015579909 + }, + { + "id": 3052454986812421 + }, + { + "id": 3052451320171589 + }, + { + "id": 3052450396441349 + }, + { + "id": 3052435016272581 + }, + { + "id": 3052434918165573 + }, + { + "id": 3052434834279173 + }, + { + "id": 3052424705084101 + }, + { + "id": 3052424301988549 + }, + { + "id": 3052422092426309 + }, + { + "id": 3052422042291205 + }, + { + "id": 3052416805587717 + }, + { + "id": 3052416349539013 + }, + { + "id": 3052414381706245 + }, + { + "id": 3052405284129477 + }, + { + "id": 3052372817201861 + }, + { + "id": 3052370135074565 + }, + { + "id": 3052365518669701 + }, + { + "id": 3052360602781445 + }, + { + "id": 3052348366227269 + } + ], + "pages": 113, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9793, + "records_with_pk": 9793, + "missing": 18, + "missing_samples": [ + { + "id": 3052486277812357 + }, + { + "id": 3052482483671173 + }, + { + "id": 3052481203047493 + }, + { + "id": 3052476692925509 + }, + { + "id": 3052476604599045 + }, + { + "id": 3052456031243333 + }, + { + "id": 3052455010716357 + }, + { + "id": 3052450413448261 + }, + { + "id": 3052435034573573 + }, + { + "id": 3052434948410437 + }, + { + "id": 3052434862345285 + }, + { + "id": 3052424721894149 + }, + { + "id": 3052422108547781 + }, + { + "id": 3052422059330309 + }, + { + "id": 3052416822201413 + }, + { + "id": 3052416383371973 + }, + { + "id": 3052414398089989 + }, + { + "id": 3052405301316293 + } + ], + "pages": 49, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4417, + "records_with_pk": 4417, + "missing": 3, + "missing_samples": [ + { + "id": 3052476605221637 + }, + { + "id": 3052424373423109 + }, + { + "id": 3051863815488837 + } + ], + "pages": 27, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 78, + "records_with_pk": 78, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11782, + "records_with_pk": 11782, + "missing": 22, + "missing_samples": [ + { + "id": 3052487098585157 + }, + { + "id": 3052486275354757 + }, + { + "id": 3052485074505861 + }, + { + "id": 3052482481311877 + }, + { + "id": 3052481200557125 + }, + { + "id": 3052476690648133 + }, + { + "id": 3052476601436933 + }, + { + "id": 3052456028834885 + }, + { + "id": 3052455008340677 + }, + { + "id": 3052451365391365 + }, + { + "id": 3052450411056197 + }, + { + "id": 3052435032001285 + }, + { + "id": 3052434945952837 + }, + { + "id": 3052434860067909 + }, + { + "id": 3052424719502085 + }, + { + "id": 3052424370719749 + }, + { + "id": 3052422106254021 + }, + { + "id": 3052422056758021 + }, + { + "id": 3052416819645509 + }, + { + "id": 3052416380701381 + }, + { + "id": 3052414395091717 + }, + { + "id": 3052405299120837 + } + ], + "pages": 59, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16059, + "records_with_pk": 16059, + "missing": 26, + "missing_samples": [ + { + "id": 3052494575456325 + }, + { + "id": 3052472789158981 + }, + { + "id": 3052464759196741 + }, + { + "id": 3052449213533253 + }, + { + "id": 3052427220864005 + }, + { + "id": 3052423892765381 + }, + { + "id": 3052423497239621 + }, + { + "id": 3052423330941957 + }, + { + "id": 3052423193480197 + }, + { + "id": 3052421983881925 + }, + { + "id": 3052417450494725 + }, + { + "id": 3052391048710853 + }, + { + "id": 3052375480896197 + }, + { + "id": 3052373085588229 + }, + { + "id": 3052365403277253 + }, + { + "id": 3052363066066885 + }, + { + "id": 3052362943629253 + }, + { + "id": 3052360961984261 + }, + { + "id": 3052357851465478 + }, + { + "id": 3052355338749893 + }, + { + "id": 3052345004771077 + }, + { + "id": 3052337405658950 + }, + { + "id": 3052337405658949 + }, + { + "id": 3052311888218053 + }, + { + "id": 3052310918104901 + }, + { + "id": 3052305891788677 + } + ], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2411, + "records_with_pk": 2411, + "missing": 5, + "missing_samples": [ + { + "id": 3052487099273285 + }, + { + "id": 3052486652465285 + }, + { + "id": 3052485075095685 + }, + { + "id": 3052424371244037 + }, + { + "id": 3051863812408645 + } + ], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 405, + "records_with_pk": 405, + "missing": 1, + "missing_samples": [ + { + "id": 2832289312279685 + } + ], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8202, + "records_with_pk": 8202, + "missing": 47, + "missing_samples": [ + { + "id": 3052486278779013 + }, + { + "id": 3052482484392069 + }, + { + "id": 3052481203670085 + }, + { + "id": 3052476693515333 + }, + { + "id": 3052456031882310 + }, + { + "id": 3052456031882309 + }, + { + "id": 3052455011551941 + }, + { + "id": 3052450414201925 + }, + { + "id": 3052435035147014 + }, + { + "id": 3052435035147013 + }, + { + "id": 3052434949147717 + }, + { + "id": 3052434862902341 + }, + { + "id": 3052424722467589 + }, + { + "id": 3052422109268677 + }, + { + "id": 3052422059936517 + }, + { + "id": 3052416823086149 + }, + { + "id": 3052416384174790 + }, + { + "id": 3052416384174789 + }, + { + "id": 3052414398679813 + }, + { + "id": 3052405301840581 + }, + { + "id": 3052372838681349 + }, + { + "id": 3052370153048005 + }, + { + "id": 3052365536249669 + }, + { + "id": 3052360620558278 + }, + { + "id": 3052360620558277 + }, + { + "id": 3052348384495366 + }, + { + "id": 3052348384495365 + }, + { + "id": 3052339892700998 + }, + { + "id": 3052339892700997 + }, + { + "id": 3052339838781254 + }, + { + "id": 3052339838781253 + }, + { + "id": 3052339780601670 + }, + { + "id": 3052339780601669 + }, + { + "id": 3052322388297477 + }, + { + "id": 3052300682807237 + }, + { + "id": 3052294635407173 + }, + { + "id": 3052248858707333 + }, + { + "id": 3052246667150917 + }, + { + "id": 3052208915777029 + }, + { + "id": 3052208850716229 + }, + { + "id": 3052201204778373 + }, + { + "id": 3052180971554181 + }, + { + "id": 3052173380846981 + }, + { + "id": 3052163301492229 + }, + { + "id": 3052163192456709 + }, + { + "id": 3052142428931461 + }, + { + "id": 3052128804521541 + } + ], + "pages": 42, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28279, + "records_with_pk": 28279, + "missing": 30, + "missing_samples": [ + { + "sitegoodsstockid": 3052488613105733 + }, + { + "sitegoodsstockid": 3052486962778117 + }, + { + "sitegoodsstockid": 3052486963220485 + }, + { + "sitegoodsstockid": 3052486849335429 + }, + { + "sitegoodsstockid": 3052486782881797 + }, + { + "sitegoodsstockid": 3052485076586629 + }, + { + "sitegoodsstockid": 3052485076783237 + }, + { + "sitegoodsstockid": 3052458794765381 + }, + { + "sitegoodsstockid": 3052451366964229 + }, + { + "sitegoodsstockid": 3052421675027205 + }, + { + "sitegoodsstockid": 3052421675502341 + }, + { + "sitegoodsstockid": 3052386208909957 + }, + { + "sitegoodsstockid": 3052386208975493 + }, + { + "sitegoodsstockid": 3052365069420485 + }, + { + "sitegoodsstockid": 3052363193403333 + }, + { + "sitegoodsstockid": 3052350941661061 + }, + { + "sitegoodsstockid": 3052287914215237 + }, + { + "sitegoodsstockid": 3052287916263237 + }, + { + "sitegoodsstockid": 3052287917508421 + }, + { + "sitegoodsstockid": 3052243917669893 + }, + { + "sitegoodsstockid": 3052222543218053 + }, + { + "sitegoodsstockid": 3052206819034501 + }, + { + "sitegoodsstockid": 3052142553990725 + }, + { + "sitegoodsstockid": 3052142553810501 + }, + { + "sitegoodsstockid": 3052093657549957 + }, + { + "sitegoodsstockid": 3052093657615493 + }, + { + "sitegoodsstockid": 3052090913557829 + }, + { + "sitegoodsstockid": 3052090913623365 + }, + { + "sitegoodsstockid": 3052076276811141 + }, + { + "sitegoodsstockid": 3052076276630917 + } + ], + "pages": 145, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1634, + "records_with_pk": 1634, + "missing": 1, + "missing_samples": [ + { + "id": 3052476605991685 + } + ], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22200, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 116, + "skipped_missing_pk": 22200, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 179, + "total_errors": 0, + "generated_at": "2026-01-15T19:02:03.451627+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_222742.json b/etl_billiards/reports/ods_gap_check_20260115_222742.json new file mode 100644 index 0000000..c1b01ca --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_222742.json @@ -0,0 +1,402 @@ +{ + "start": "2026-01-14T00:00:00+08:00", + "end": "2026-01-15T22:24:35.980217+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 64, + "records_with_pk": 64, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 219, + "records_with_pk": 219, + "missing": 1, + "missing_samples": [ + { + "id": 3052693070926021 + } + ], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9822, + "records_with_pk": 9822, + "missing": 1, + "missing_samples": [ + { + "id": 3052693089456133 + } + ], + "pages": 50, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 63, + "records_with_pk": 63, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 1, + "records_with_pk": 1, + "missing": 1, + "missing_samples": [ + { + "id": 3052536228843589 + } + ], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11814, + "records_with_pk": 11814, + "missing": 0, + "missing_samples": [], + "pages": 60, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16080, + "records_with_pk": 16080, + "missing": 0, + "missing_samples": [], + "pages": 81, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 5, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2421, + "records_with_pk": 2421, + "missing": 0, + "missing_samples": [], + "pages": 13, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 6, + "records_with_pk": 6, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8223, + "records_with_pk": 8223, + "missing": 0, + "missing_samples": [], + "pages": 42, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 258, + "records_with_pk": 258, + "missing": 1, + "missing_samples": [ + { + "sitegoodsstockid": 3052693659717701 + } + ], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1640, + "records_with_pk": 1640, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 227, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 227, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 4, + "total_errors": 0, + "generated_at": "2026-01-15T22:27:42.389451+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_223929.json b/etl_billiards/reports/ods_gap_check_20260115_223929.json new file mode 100644 index 0000000..027aa5c --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_223929.json @@ -0,0 +1,409 @@ +{ + "start": "2026-01-14T00:00:00+08:00", + "end": "2026-01-15T22:34:02.852100+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 64, + "records_with_pk": 64, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 219, + "records_with_pk": 219, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9822, + "records_with_pk": 9822, + "missing": 0, + "missing_samples": [], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 63, + "records_with_pk": 63, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 1, + "records_with_pk": 1, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11814, + "records_with_pk": 11814, + "missing": 0, + "missing_samples": [], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16080, + "records_with_pk": 16080, + "missing": 0, + "missing_samples": [], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2421, + "records_with_pk": 2421, + "missing": 0, + "missing_samples": [], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 6, + "records_with_pk": 6, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8226, + "records_with_pk": 8226, + "missing": 3, + "missing_samples": [ + { + "id": 3052709671030981 + }, + { + "id": 3052709671014598 + }, + { + "id": 3052709671014597 + } + ], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 262, + "records_with_pk": 262, + "missing": 4, + "missing_samples": [ + { + "sitegoodsstockid": 3052703311204549 + }, + { + "sitegoodsstockid": 3052703310713029 + }, + { + "sitegoodsstockid": 3052703210262725 + }, + { + "sitegoodsstockid": 3052702889496709 + } + ], + "pages": 3, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1640, + "records_with_pk": 1640, + "missing": 0, + "missing_samples": [], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 227, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 3, + "skipped_missing_pk": 227, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 7, + "total_errors": 0, + "generated_at": "2026-01-15T22:39:29.785863+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_225414.json b/etl_billiards/reports/ods_gap_check_20260115_225414.json new file mode 100644 index 0000000..7d6a168 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_225414.json @@ -0,0 +1,434 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T22:41:52.530464+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21593, + "records_with_pk": 21593, + "missing": 1, + "missing_samples": [ + { + "id": 3052709425844293 + } + ], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9823, + "records_with_pk": 9823, + "missing": 1, + "missing_samples": [ + { + "id": 3052709670179013 + } + ], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4425, + "records_with_pk": 4425, + "missing": 0, + "missing_samples": [], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11816, + "records_with_pk": 11816, + "missing": 2, + "missing_samples": [ + { + "id": 3052719648346117 + }, + { + "id": 3052709667573957 + } + ], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16080, + "records_with_pk": 16080, + "missing": 0, + "missing_samples": [], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 551, + "records_with_pk": 551, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 936, + "records_with_pk": 936, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 800, + "records_with_pk": 800, + "missing": 1, + "missing_samples": [ + { + "id": 3052719648968709 + } + ], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "HTTPError: 502 Server Error: Bad Gateway for url: https://pc.ficoo.vip/apiprod/admin/v1/MemberProfile/GetMemberCardBalanceChange" + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 0, + "skipped_missing_pk": 0, + "errors": 1, + "error_detail": "HTTPError: 502 Server Error: Bad Gateway for url: https://pc.ficoo.vip/apiprod/admin/v1/Site/GetFormerRechargeSettleList" + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8226, + "records_with_pk": 8226, + "missing": 3, + "missing_samples": [ + { + "id": 3052709671030981 + }, + { + "id": 3052709671014598 + }, + { + "id": 3052709671014597 + } + ], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28353, + "records_with_pk": 28353, + "missing": 6, + "missing_samples": [ + { + "sitegoodsstockid": 3052711404949637 + }, + { + "sitegoodsstockid": 3052710332108805 + }, + { + "sitegoodsstockid": 3052703311204549 + }, + { + "sitegoodsstockid": 3052703310713029 + }, + { + "sitegoodsstockid": 3052703210262725 + }, + { + "sitegoodsstockid": 3052702889496709 + } + ], + "pages": 287, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1640, + "records_with_pk": 1640, + "missing": 0, + "missing_samples": [], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22233, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22233, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 14, + "total_errors": 2, + "generated_at": "2026-01-15T22:54:14.091266+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_225543.json b/etl_billiards/reports/ods_gap_check_20260115_225543.json new file mode 100644 index 0000000..a3ee564 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_225543.json @@ -0,0 +1,57 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T22:54:43.572531+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2422, + "records_with_pk": 2422, + "missing": 1, + "missing_samples": [ + { + "id": 3052719648968709 + } + ], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 406, + "records_with_pk": 406, + "missing": 1, + "missing_samples": [ + { + "id": 2832289312279685 + } + ], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + } + ], + "total_missing": 2, + "total_errors": 0, + "generated_at": "2026-01-15T22:55:43.243233+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_233027.json b/etl_billiards/reports/ods_gap_check_20260115_233027.json new file mode 100644 index 0000000..296df01 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_233027.json @@ -0,0 +1,486 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T23:17:27.865573+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21598, + "records_with_pk": 21598, + "missing": 4, + "missing_samples": [ + { + "id": 3052747511992453 + }, + { + "id": 3052735906484229 + }, + { + "id": 3052734153248901 + }, + { + "id": 3052731804225541 + } + ], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9827, + "records_with_pk": 9827, + "missing": 3, + "missing_samples": [ + { + "id": 3052747812589701 + }, + { + "id": 3052735966400645 + }, + { + "id": 3052734201450565 + } + ], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4428, + "records_with_pk": 4428, + "missing": 3, + "missing_samples": [ + { + "id": 3052734201991237 + }, + { + "id": 3052719654719493 + }, + { + "id": 3052719654260741 + } + ], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11821, + "records_with_pk": 11821, + "missing": 2, + "missing_samples": [ + { + "id": 3052750319341573 + }, + { + "id": 3052747810050181 + } + ], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16083, + "records_with_pk": 16083, + "missing": 3, + "missing_samples": [ + { + "id": 3052741608968197 + }, + { + "id": 3052731064979526 + }, + { + "id": 3052731064979525 + } + ], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 552, + "records_with_pk": 552, + "missing": 1, + "missing_samples": [ + { + "id": 3052749341853317 + } + ], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 938, + "records_with_pk": 938, + "missing": 2, + "missing_samples": [ + { + "id": 3052750402162821 + }, + { + "id": 3052749343442565 + } + ], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2426, + "records_with_pk": 2426, + "missing": 2, + "missing_samples": [ + { + "id": 3052750769868677 + }, + { + "id": 3052750321078277 + } + ], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 406, + "records_with_pk": 406, + "missing": 0, + "missing_samples": [], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8226, + "records_with_pk": 8226, + "missing": 0, + "missing_samples": [], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28362, + "records_with_pk": 28362, + "missing": 8, + "missing_samples": [ + { + "sitegoodsstockid": 3052749480874181 + }, + { + "sitegoodsstockid": 3052746155790533 + }, + { + "sitegoodsstockid": 3052731830964357 + }, + { + "sitegoodsstockid": 3052731830816901 + }, + { + "sitegoodsstockid": 3052731610599429 + }, + { + "sitegoodsstockid": 3052731549978821 + }, + { + "sitegoodsstockid": 3052731205636229 + }, + { + "sitegoodsstockid": 3052731205144709 + } + ], + "pages": 288, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1642, + "records_with_pk": 1642, + "missing": 2, + "missing_samples": [ + { + "id": 3052735967137925 + }, + { + "id": 3052734202728517 + } + ], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22238, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22238, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 30, + "total_errors": 0, + "generated_at": "2026-01-15T23:30:27.730183+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_234709.json b/etl_billiards/reports/ods_gap_check_20260115_234709.json new file mode 100644 index 0000000..3fe3b51 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_234709.json @@ -0,0 +1,429 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T23:34:39.434282+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21600, + "records_with_pk": 21600, + "missing": 2, + "missing_samples": [ + { + "id": 3052765744894085 + }, + { + "id": 3052764742504453 + } + ], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9829, + "records_with_pk": 9829, + "missing": 2, + "missing_samples": [ + { + "id": 3052765763162309 + }, + { + "id": 3052764761280581 + } + ], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4428, + "records_with_pk": 4428, + "missing": 0, + "missing_samples": [], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11823, + "records_with_pk": 11823, + "missing": 2, + "missing_samples": [ + { + "id": 3052765760803013 + }, + { + "id": 3052764758904901 + } + ], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16086, + "records_with_pk": 16086, + "missing": 2, + "missing_samples": [ + { + "id": 3052771420229701 + }, + { + "id": 3052767969185925 + } + ], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 552, + "records_with_pk": 552, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 938, + "records_with_pk": 938, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2426, + "records_with_pk": 2426, + "missing": 0, + "missing_samples": [], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 407, + "records_with_pk": 407, + "missing": 1, + "missing_samples": [ + { + "id": 3052750316949509 + } + ], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8228, + "records_with_pk": 8228, + "missing": 2, + "missing_samples": [ + { + "id": 3052765763981509 + }, + { + "id": 3052764762116165 + } + ], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28364, + "records_with_pk": 28364, + "missing": 1, + "missing_samples": [ + { + "sitegoodsstockid": 3052765709897925 + } + ], + "pages": 288, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1642, + "records_with_pk": 1642, + "missing": 0, + "missing_samples": [], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22241, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22241, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 12, + "total_errors": 0, + "generated_at": "2026-01-15T23:47:09.386589+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260115_235955.json b/etl_billiards/reports/ods_gap_check_20260115_235955.json new file mode 100644 index 0000000..72761ac --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260115_235955.json @@ -0,0 +1,446 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T23:34:39+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21600, + "records_with_pk": 21600, + "missing": 2, + "missing_samples": [ + { + "id": 3052765744894085 + }, + { + "id": 3052764742504453 + } + ], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9830, + "records_with_pk": 9830, + "missing": 3, + "missing_samples": [ + { + "id": 3052778957967493 + }, + { + "id": 3052765763162309 + }, + { + "id": 3052764761280581 + } + ], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4428, + "records_with_pk": 4428, + "missing": 0, + "missing_samples": [], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11824, + "records_with_pk": 11824, + "missing": 3, + "missing_samples": [ + { + "id": 3052778953920645 + }, + { + "id": 3052765760803013 + }, + { + "id": 3052764758904901 + } + ], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16086, + "records_with_pk": 16086, + "missing": 2, + "missing_samples": [ + { + "id": 3052771420229701 + }, + { + "id": 3052767969185925 + } + ], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 552, + "records_with_pk": 552, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 938, + "records_with_pk": 938, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2428, + "records_with_pk": 2428, + "missing": 2, + "missing_samples": [ + { + "id": 3052778954690693 + }, + { + "id": 3052778954494085 + } + ], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 407, + "records_with_pk": 407, + "missing": 1, + "missing_samples": [ + { + "id": 3052750316949509 + } + ], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8228, + "records_with_pk": 8228, + "missing": 2, + "missing_samples": [ + { + "id": 3052765763981509 + }, + { + "id": 3052764762116165 + } + ], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28364, + "records_with_pk": 28364, + "missing": 1, + "missing_samples": [ + { + "sitegoodsstockid": 3052765709897925 + } + ], + "pages": 288, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1643, + "records_with_pk": 1643, + "missing": 1, + "missing_samples": [ + { + "id": 3052778959048837 + } + ], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22241, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22241, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 17, + "total_errors": 0, + "generated_at": "2026-01-15T23:59:55.963574+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260116_001716.json b/etl_billiards/reports/ods_gap_check_20260116_001716.json new file mode 100644 index 0000000..93785f7 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260116_001716.json @@ -0,0 +1,394 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T23:34:39+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21600, + "records_with_pk": 21600, + "missing": 0, + "missing_samples": [], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9831, + "records_with_pk": 9831, + "missing": 0, + "missing_samples": [], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4428, + "records_with_pk": 4428, + "missing": 0, + "missing_samples": [], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11826, + "records_with_pk": 11826, + "missing": 1, + "missing_samples": [ + { + "id": 3052800806752261 + } + ], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16086, + "records_with_pk": 16086, + "missing": 0, + "missing_samples": [], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 552, + "records_with_pk": 552, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 938, + "records_with_pk": 938, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2428, + "records_with_pk": 2428, + "missing": 0, + "missing_samples": [], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 407, + "records_with_pk": 407, + "missing": 0, + "missing_samples": [], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8229, + "records_with_pk": 8229, + "missing": 1, + "missing_samples": [ + { + "id": 3052800809783301 + } + ], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28364, + "records_with_pk": 28364, + "missing": 0, + "missing_samples": [], + "pages": 288, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1644, + "records_with_pk": 1644, + "missing": 0, + "missing_samples": [], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22241, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22241, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 2, + "total_errors": 0, + "generated_at": "2026-01-16T00:17:16.884691+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260116_003620.json b/etl_billiards/reports/ods_gap_check_20260116_003620.json new file mode 100644 index 0000000..a86c884 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260116_003620.json @@ -0,0 +1,390 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T23:34:39+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21600, + "records_with_pk": 21600, + "missing": 0, + "missing_samples": [], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9832, + "records_with_pk": 9832, + "missing": 1, + "missing_samples": [ + { + "id": 3052800809242629 + } + ], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4428, + "records_with_pk": 4428, + "missing": 0, + "missing_samples": [], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11826, + "records_with_pk": 11826, + "missing": 0, + "missing_samples": [], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16086, + "records_with_pk": 16086, + "missing": 0, + "missing_samples": [], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 552, + "records_with_pk": 552, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 938, + "records_with_pk": 938, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2428, + "records_with_pk": 2428, + "missing": 0, + "missing_samples": [], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 407, + "records_with_pk": 407, + "missing": 0, + "missing_samples": [], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8229, + "records_with_pk": 8229, + "missing": 0, + "missing_samples": [], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28364, + "records_with_pk": 28364, + "missing": 0, + "missing_samples": [], + "pages": 288, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1644, + "records_with_pk": 1644, + "missing": 0, + "missing_samples": [], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22241, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22241, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 1, + "total_errors": 0, + "generated_at": "2026-01-16T00:36:20.647772+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260116_005452.json b/etl_billiards/reports/ods_gap_check_20260116_005452.json new file mode 100644 index 0000000..f731105 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260116_005452.json @@ -0,0 +1,426 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T23:34:39+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21600, + "records_with_pk": 21600, + "missing": 0, + "missing_samples": [], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9837, + "records_with_pk": 9837, + "missing": 4, + "missing_samples": [ + { + "id": 3052832498323653 + }, + { + "id": 3052831407345733 + }, + { + "id": 3052831407362117 + }, + { + "id": 3052830659924037 + } + ], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4428, + "records_with_pk": 4428, + "missing": 0, + "missing_samples": [], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11830, + "records_with_pk": 11830, + "missing": 4, + "missing_samples": [ + { + "id": 3052832495997125 + }, + { + "id": 3052831403331653 + }, + { + "id": 3052830657482821 + }, + { + "id": 3052823016361989 + } + ], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16086, + "records_with_pk": 16086, + "missing": 0, + "missing_samples": [], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 552, + "records_with_pk": 552, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 938, + "records_with_pk": 938, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2428, + "records_with_pk": 2428, + "missing": 0, + "missing_samples": [], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 407, + "records_with_pk": 407, + "missing": 0, + "missing_samples": [], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8232, + "records_with_pk": 8232, + "missing": 3, + "missing_samples": [ + { + "id": 3052832498929862 + }, + { + "id": 3052832498929861 + }, + { + "id": 3052830660579397 + } + ], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28364, + "records_with_pk": 28364, + "missing": 0, + "missing_samples": [], + "pages": 288, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1645, + "records_with_pk": 1645, + "missing": 1, + "missing_samples": [ + { + "id": 3052831408820293 + } + ], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22241, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22241, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 12, + "total_errors": 0, + "generated_at": "2026-01-16T00:54:52.208868+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_20260116_020724.json b/etl_billiards/reports/ods_gap_check_20260116_020724.json new file mode 100644 index 0000000..2ee182a --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_20260116_020724.json @@ -0,0 +1,394 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-15T23:34:39+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 100, + "chunk_size": 200, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_ASSISTANT_ACCOUNT", + "table": "billiards_ods.assistant_accounts_master", + "endpoint": "/PersonnelManagement/SearchAssistantInfo", + "pk_columns": [ + "id" + ], + "records": 448, + "records_with_pk": 448, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_RECORDS", + "table": "billiards_ods.settlement_records", + "endpoint": "/Site/GetAllOrderSettleList", + "pk_columns": [ + "id" + ], + "records": 21600, + "records_with_pk": 21600, + "missing": 0, + "missing_samples": [], + "pages": 221, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9841, + "records_with_pk": 9841, + "missing": 0, + "missing_samples": [], + "pages": 99, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_LEDGER", + "table": "billiards_ods.assistant_service_records", + "endpoint": "/AssistantPerformance/GetOrderAssistantDetails", + "pk_columns": [ + "id" + ], + "records": 4428, + "records_with_pk": 4428, + "missing": 0, + "missing_samples": [], + "pages": 48, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_ASSISTANT_ABOLISH", + "table": "billiards_ods.assistant_cancellation_records", + "endpoint": "/AssistantPerformance/GetAbolitionAssistant", + "pk_columns": [ + "id" + ], + "records": 79, + "records_with_pk": 79, + "missing": 0, + "missing_samples": [], + "pages": 7, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS_SALES", + "table": "billiards_ods.store_goods_sales_records", + "endpoint": "/TenantGoods/GetGoodsSalesList", + "pk_columns": [ + "id" + ], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11835, + "records_with_pk": 11835, + "missing": 1, + "missing_samples": [ + { + "id": 3052908806459589 + } + ], + "pages": 119, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_REFUND", + "table": "billiards_ods.refund_transactions", + "endpoint": "/Order/GetRefundPayLogList", + "pk_columns": [ + "id" + ], + "records": 36, + "records_with_pk": 36, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PLATFORM_COUPON", + "table": "billiards_ods.platform_coupon_redemption_records", + "endpoint": "/Promotion/GetOfflineCouponConsumePageList", + "pk_columns": [ + "id" + ], + "records": 16086, + "records_with_pk": 16086, + "missing": 0, + "missing_samples": [], + "pages": 161, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER", + "table": "billiards_ods.member_profiles", + "endpoint": "/MemberProfile/GetTenantMemberList", + "pk_columns": [ + "id" + ], + "records": 552, + "records_with_pk": 552, + "missing": 0, + "missing_samples": [], + "pages": 6, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_CARD", + "table": "billiards_ods.member_stored_value_cards", + "endpoint": "/MemberProfile/GetTenantMemberCardList", + "pk_columns": [ + "id" + ], + "records": 938, + "records_with_pk": 938, + "missing": 0, + "missing_samples": [], + "pages": 10, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_MEMBER_BALANCE", + "table": "billiards_ods.member_balance_changes", + "endpoint": "/MemberProfile/GetMemberCardBalanceChange", + "pk_columns": [ + "id" + ], + "records": 2429, + "records_with_pk": 2429, + "missing": 1, + "missing_samples": [ + { + "id": 3052862467248261 + } + ], + "pages": 25, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_RECHARGE_SETTLE", + "table": "billiards_ods.recharge_settlements", + "endpoint": "/Site/GetRechargeSettleList", + "pk_columns": [ + "id" + ], + "records": 407, + "records_with_pk": 407, + "missing": 0, + "missing_samples": [], + "pages": 8, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_PACKAGE", + "table": "billiards_ods.group_buy_packages", + "endpoint": "/PackageCoupon/QueryPackageCouponList", + "pk_columns": [ + "id" + ], + "records": 18, + "records_with_pk": 18, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8234, + "records_with_pk": 8234, + "missing": 0, + "missing_samples": [], + "pages": 83, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_STOCK", + "table": "billiards_ods.goods_stock_summary", + "endpoint": "/TenantGoods/GetGoodsStockReport", + "pk_columns": [ + "sitegoodsid" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_INVENTORY_CHANGE", + "table": "billiards_ods.goods_stock_movements", + "endpoint": "/GoodsStockManage/QueryGoodsOutboundReceipt", + "pk_columns": [ + "sitegoodsstockid" + ], + "records": 28364, + "records_with_pk": 28364, + "missing": 0, + "missing_samples": [], + "pages": 288, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLES", + "table": "billiards_ods.site_tables_master", + "endpoint": "/Table/GetSiteTables", + "pk_columns": [ + "id" + ], + "records": 74, + "records_with_pk": 74, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GOODS_CATEGORY", + "table": "billiards_ods.stock_goods_category_tree", + "endpoint": "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "pk_columns": [ + "id" + ], + "records": 9, + "records_with_pk": 9, + "missing": 0, + "missing_samples": [], + "pages": 1, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_STORE_GOODS", + "table": "billiards_ods.store_goods_master", + "endpoint": "/TenantGoods/GetGoodsInventoryList", + "pk_columns": [ + "id" + ], + "records": 169, + "records_with_pk": 169, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1646, + "records_with_pk": 1646, + "missing": 0, + "missing_samples": [], + "pages": 17, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TENANT_GOODS", + "table": "billiards_ods.tenant_goods_master", + "endpoint": "/TenantGoods/QueryTenantGoods", + "pk_columns": [ + "id" + ], + "records": 170, + "records_with_pk": 170, + "missing": 0, + "missing_samples": [], + "pages": 2, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": "billiards_ods.settlement_ticket_details", + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": [ + "ordersettleid" + ], + "records": 22241, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 226, + "skipped_missing_pk": 22241, + "errors": 0, + "error_detail": null, + "source_endpoint": "/PayLog/GetPayLogListPage" + } + ], + "total_missing": 2, + "total_errors": 0, + "generated_at": "2026-01-16T02:07:24.368217+08:00" +} diff --git a/etl_billiards/reports/ods_gap_check_after_fill_20260116_024030.json b/etl_billiards/reports/ods_gap_check_after_fill_20260116_024030.json new file mode 100644 index 0000000..39908c6 --- /dev/null +++ b/etl_billiards/reports/ods_gap_check_after_fill_20260116_024030.json @@ -0,0 +1,81 @@ +{ + "start": "2025-07-01T00:00:00+08:00", + "end": "2026-01-16T02:39:19.009379+08:00", + "cutoff": null, + "window_days": 30, + "window_hours": 0, + "page_size": 200, + "chunk_size": 500, + "sample_limit": 50, + "store_id": 2790685415443269, + "base_url": "https://pc.ficoo.vip/apiprod/admin/v1/", + "results": [ + { + "task_code": "ODS_TABLE_USE", + "table": "billiards_ods.table_fee_transactions", + "endpoint": "/Site/GetSiteTableOrderDetails", + "pk_columns": [ + "id" + ], + "records": 9842, + "records_with_pk": 9842, + "missing": 0, + "missing_samples": [], + "pages": 50, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_PAYMENT", + "table": "billiards_ods.payment_transactions", + "endpoint": "/PayLog/GetPayLogListPage", + "pk_columns": [ + "id" + ], + "records": 11836, + "records_with_pk": 11836, + "missing": 0, + "missing_samples": [], + "pages": 60, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_GROUP_BUY_REDEMPTION", + "table": "billiards_ods.group_buy_redemption_records", + "endpoint": "/Site/GetSiteTableUseDetails", + "pk_columns": [ + "id" + ], + "records": 8234, + "records_with_pk": 8234, + "missing": 0, + "missing_samples": [], + "pages": 42, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + }, + { + "task_code": "ODS_TABLE_FEE_DISCOUNT", + "table": "billiards_ods.table_fee_discount_records", + "endpoint": "/Site/GetTaiFeeAdjustList", + "pk_columns": [ + "id" + ], + "records": 1646, + "records_with_pk": 1646, + "missing": 0, + "missing_samples": [], + "pages": 9, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": null + } + ], + "total_missing": 0, + "total_errors": 0, + "generated_at": "2026-01-16T02:40:30.650075+08:00" +} diff --git a/etl_billiards/run_update.py b/etl_billiards/run_update.py new file mode 100644 index 0000000..9cfa6f3 --- /dev/null +++ b/etl_billiards/run_update.py @@ -0,0 +1,517 @@ +# -*- coding: utf-8 -*- +""" +一键增量更新脚本(ODS -> DWD -> DWS)。 + +用法: + cd etl_billiards + python run_update.py +""" + +from __future__ import annotations + +import argparse +import logging +import multiprocessing as mp +import subprocess +import sys +import time as time_mod +from datetime import date, datetime, time, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from api.client import APIClient +from config.settings import AppConfig +from database.connection import DatabaseConnection +from database.operations import DatabaseOperations +from orchestration.scheduler import ETLScheduler +from tasks.check_cutoff_task import CheckCutoffTask +from tasks.dwd_load_task import DwdLoadTask +from tasks.ods_tasks import ENABLED_ODS_CODES +from utils.logging_utils import build_log_path, configure_logging + +STEP_TIMEOUT_SEC = 120 + + + +def _coerce_date(s: str) -> date: + s = (s or "").strip() + if not s: + raise ValueError("empty date") + if len(s) >= 10: + s = s[:10] + return date.fromisoformat(s) + + +def _compute_dws_window( + *, + cfg: AppConfig, + tz: ZoneInfo, + rebuild_days: int, + bootstrap_days: int, + dws_start: date | None, + dws_end: date | None, +) -> tuple[datetime, datetime]: + if dws_start and dws_end and dws_end < dws_start: + raise ValueError("dws_end must be >= dws_start") + + store_id = int(cfg.get("app.store_id")) + dsn = cfg["db"]["dsn"] + session = cfg["db"].get("session") + conn = DatabaseConnection(dsn=dsn, session=session) + try: + if dws_start is None: + row = conn.query( + "SELECT MAX(order_date) AS mx FROM billiards_dws.dws_order_summary WHERE site_id=%s", + (store_id,), + ) + mx = (row[0] or {}).get("mx") if row else None + if isinstance(mx, date): + dws_start = mx - timedelta(days=max(0, int(rebuild_days))) + else: + dws_start = (datetime.now(tz).date()) - timedelta(days=max(1, int(bootstrap_days))) + + if dws_end is None: + dws_end = datetime.now(tz).date() + finally: + conn.close() + + start_dt = datetime.combine(dws_start, time.min).replace(tzinfo=tz) + # end_dt 取到当天 23:59:59,避免只跑到“当前时刻”的 date() 导致少一天 + end_dt = datetime.combine(dws_end, time.max).replace(tzinfo=tz) + return start_dt, end_dt + + +def _run_check_cutoff(cfg: AppConfig, logger: logging.Logger): + dsn = cfg["db"]["dsn"] + session = cfg["db"].get("session") + db_conn = DatabaseConnection(dsn=dsn, session=session) + db_ops = DatabaseOperations(db_conn) + api = APIClient( + base_url=cfg["api"]["base_url"], + token=cfg["api"]["token"], + timeout=cfg["api"]["timeout_sec"], + retry_max=cfg["api"]["retries"]["max_attempts"], + headers_extra=cfg["api"].get("headers_extra"), + ) + try: + CheckCutoffTask(cfg, db_ops, api, logger).execute(None) + finally: + db_conn.close() + + +def _iter_daily_windows(window_start: datetime, window_end: datetime) -> list[tuple[datetime, datetime]]: + if window_start > window_end: + return [] + tz = window_start.tzinfo + windows: list[tuple[datetime, datetime]] = [] + cur = window_start + while cur <= window_end: + day_start = datetime.combine(cur.date(), time.min).replace(tzinfo=tz) + day_end = datetime.combine(cur.date(), time.max).replace(tzinfo=tz) + if day_start < window_start: + day_start = window_start + if day_end > window_end: + day_end = window_end + windows.append((day_start, day_end)) + next_day = cur.date() + timedelta(days=1) + cur = datetime.combine(next_day, time.min).replace(tzinfo=tz) + return windows + + +def _run_step_worker(result_queue: "mp.Queue[dict[str, str]]", step: dict[str, str]) -> None: + if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + log_file = step.get("log_file") or "" + log_level = step.get("log_level") or "INFO" + log_console = bool(step.get("log_console", True)) + log_path = Path(log_file) if log_file else None + + with configure_logging( + "etl_update", + log_path, + level=log_level, + console=log_console, + tee_std=True, + ) as logger: + cfg_base = AppConfig.load({}) + step_type = step.get("type", "") + try: + if step_type == "check_cutoff": + _run_check_cutoff(cfg_base, logger) + elif step_type == "ods_task": + task_code = step["task_code"] + overlap_seconds = int(step.get("overlap_seconds", 0)) + cfg_ods = AppConfig.load( + { + "pipeline": {"flow": "FULL"}, + "run": {"tasks": [task_code], "overlap_seconds": overlap_seconds}, + } + ) + scheduler = ETLScheduler(cfg_ods, logger) + try: + scheduler.run_tasks([task_code]) + finally: + scheduler.close() + elif step_type == "init_dws_schema": + overlap_seconds = int(step.get("overlap_seconds", 0)) + cfg_dwd = AppConfig.load( + { + "pipeline": {"flow": "INGEST_ONLY"}, + "run": {"tasks": ["INIT_DWS_SCHEMA"], "overlap_seconds": overlap_seconds}, + } + ) + scheduler = ETLScheduler(cfg_dwd, logger) + try: + scheduler.run_tasks(["INIT_DWS_SCHEMA"]) + finally: + scheduler.close() + elif step_type == "dwd_table": + dwd_table = step["dwd_table"] + overlap_seconds = int(step.get("overlap_seconds", 0)) + cfg_dwd = AppConfig.load( + { + "pipeline": {"flow": "INGEST_ONLY"}, + "run": {"tasks": ["DWD_LOAD_FROM_ODS"], "overlap_seconds": overlap_seconds}, + "dwd": {"only_tables": [dwd_table]}, + } + ) + scheduler = ETLScheduler(cfg_dwd, logger) + try: + scheduler.run_tasks(["DWD_LOAD_FROM_ODS"]) + finally: + scheduler.close() + elif step_type == "dws_window": + overlap_seconds = int(step.get("overlap_seconds", 0)) + window_start = step["window_start"] + window_end = step["window_end"] + cfg_dws = AppConfig.load( + { + "pipeline": {"flow": "INGEST_ONLY"}, + "run": { + "tasks": ["DWS_BUILD_ORDER_SUMMARY"], + "overlap_seconds": overlap_seconds, + "window_override": {"start": window_start, "end": window_end}, + }, + } + ) + scheduler = ETLScheduler(cfg_dws, logger) + try: + scheduler.run_tasks(["DWS_BUILD_ORDER_SUMMARY"]) + finally: + scheduler.close() + elif step_type == "ods_gap_check": + overlap_hours = int(step.get("overlap_hours", 24)) + window_days = int(step.get("window_days", 1)) + window_hours = int(step.get("window_hours", 0)) + page_size = int(step.get("page_size", 0) or 0) + sleep_per_window = float(step.get("sleep_per_window", 0) or 0) + sleep_per_page = float(step.get("sleep_per_page", 0) or 0) + tag = step.get("tag", "run_update") + task_codes = (step.get("task_codes") or "").strip() + script_dir = Path(__file__).resolve().parent + script_path = script_dir / "scripts" / "check_ods_gaps.py" + cmd = [ + sys.executable, + str(script_path), + "--from-cutoff", + "--cutoff-overlap-hours", + str(overlap_hours), + "--window-days", + str(window_days), + "--tag", + str(tag), + ] + if window_hours > 0: + cmd += ["--window-hours", str(window_hours)] + if page_size > 0: + cmd += ["--page-size", str(page_size)] + if sleep_per_window > 0: + cmd += ["--sleep-per-window-seconds", str(sleep_per_window)] + if sleep_per_page > 0: + cmd += ["--sleep-per-page-seconds", str(sleep_per_page)] + if task_codes: + cmd += ["--task-codes", task_codes] + subprocess.run(cmd, check=True, cwd=str(script_dir)) + else: + raise ValueError(f"Unknown step type: {step_type}") + result_queue.put({"status": "ok"}) + except Exception as exc: + result_queue.put({"status": "error", "error": str(exc)}) + + +def _run_step_with_timeout( + step: dict[str, str], logger: logging.Logger, timeout_sec: int +) -> dict[str, object]: + start = time_mod.monotonic() + step_timeout = timeout_sec + if step.get("timeout_sec"): + try: + step_timeout = int(step.get("timeout_sec")) + except Exception: + step_timeout = timeout_sec + ctx = mp.get_context("spawn") + result_queue: mp.Queue = ctx.Queue() + proc = ctx.Process(target=_run_step_worker, args=(result_queue, step)) + proc.start() + proc.join(timeout=step_timeout) + elapsed = time_mod.monotonic() - start + if proc.is_alive(): + logger.error( + "STEP_TIMEOUT name=%s elapsed=%.2fs limit=%ss", step["name"], elapsed, step_timeout + ) + proc.terminate() + proc.join(10) + return {"name": step["name"], "status": "timeout", "elapsed": elapsed} + + result: dict[str, object] = {"name": step["name"], "status": "error", "elapsed": elapsed} + try: + payload = result_queue.get_nowait() + except Exception: + payload = {} + if payload: + result.update(payload) + + if result.get("status") == "ok": + logger.info("STEP_OK name=%s elapsed=%.2fs", step["name"], elapsed) + else: + logger.error( + "STEP_FAIL name=%s elapsed=%.2fs error=%s", + step["name"], + elapsed, + result.get("error"), + ) + return result + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + parser = argparse.ArgumentParser(description="One-click ETL update (ODS -> DWD -> DWS)") + parser.add_argument("--overlap-seconds", type=int, default=3600, help="overlap seconds (default: 3600)") + parser.add_argument( + "--dws-rebuild-days", + type=int, + default=1, + help="DWS 回算冗余天数(default: 1)", + ) + parser.add_argument( + "--dws-bootstrap-days", + type=int, + default=30, + help="DWS 首次/空表时回算天数(default: 30)", + ) + parser.add_argument("--dws-start", type=str, default="", help="DWS 回算开始日期 YYYY-MM-DD(可选)") + parser.add_argument("--dws-end", type=str, default="", help="DWS 回算结束日期 YYYY-MM-DD(可选)") + parser.add_argument( + "--skip-cutoff", + action="store_true", + help="跳过 CHECK_CUTOFF(默认会在开始/结束各跑一次)", + ) + parser.add_argument( + "--skip-ods", + action="store_true", + help="跳过 ODS 在线抓取(仅跑 DWD/DWS)", + ) + parser.add_argument( + "--ods-tasks", + type=str, + default="", + help="指定要跑的 ODS 任务(逗号分隔),默认跑全部 ENABLED_ODS_CODES", + ) + parser.add_argument( + "--check-ods-gaps", + action="store_true", + help="run ODS gap check after ODS load (default: off)", + ) + parser.add_argument( + "--check-ods-overlap-hours", + type=int, + default=24, + help="gap check overlap hours from cutoff (default: 24)", + ) + parser.add_argument( + "--check-ods-window-days", + type=int, + default=1, + help="gap check window days (default: 1)", + ) + parser.add_argument( + "--check-ods-window-hours", + type=int, + default=0, + help="gap check window hours (default: 0)", + ) + parser.add_argument( + "--check-ods-page-size", + type=int, + default=200, + help="gap check API page size (default: 200)", + ) + parser.add_argument( + "--check-ods-timeout-sec", + type=int, + default=1800, + help="gap check timeout seconds (default: 1800)", + ) + parser.add_argument( + "--check-ods-task-codes", + type=str, + default="", + help="gap check task codes (comma-separated, optional)", + ) + parser.add_argument( + "--check-ods-sleep-per-window-seconds", + type=float, + default=0, + help="gap check sleep seconds after each window (default: 0)", + ) + parser.add_argument( + "--check-ods-sleep-per-page-seconds", + type=float, + default=0, + help="gap check sleep seconds after each page (default: 0)", + ) + parser.add_argument("--log-file", type=str, default="", help="log file path (default: logs/run_update_YYYYMMDD_HHMMSS.log)") + parser.add_argument("--log-dir", type=str, default="", help="log directory (default: logs)") + parser.add_argument("--log-level", type=str, default="INFO", help="log level (default: INFO)") + parser.add_argument("--no-log-console", action="store_true", help="disable console logging") + args = parser.parse_args() + + log_dir = Path(args.log_dir) if args.log_dir else (Path(__file__).resolve().parent / "logs") + log_file = Path(args.log_file) if args.log_file else build_log_path(log_dir, "run_update") + log_console = not args.no_log_console + + with configure_logging( + "etl_update", + log_file, + level=args.log_level, + console=log_console, + tee_std=True, + ) as logger: + cfg_base = AppConfig.load({}) + tz = ZoneInfo(cfg_base.get("app.timezone", "Asia/Taipei")) + + dws_start = _coerce_date(args.dws_start) if args.dws_start else None + dws_end = _coerce_date(args.dws_end) if args.dws_end else None + + steps: list[dict[str, str]] = [] + if not args.skip_cutoff: + steps.append({"name": "CHECK_CUTOFF:before", "type": "check_cutoff"}) + + # ------------------------------------------------------------------ ODS (online fetch + load) + if not args.skip_ods: + if args.ods_tasks: + ods_tasks = [t.strip().upper() for t in args.ods_tasks.split(",") if t.strip()] + else: + ods_tasks = sorted(ENABLED_ODS_CODES) + for task_code in ods_tasks: + steps.append( + { + "name": f"ODS:{task_code}", + "type": "ods_task", + "task_code": task_code, + "overlap_seconds": str(args.overlap_seconds), + } + ) + + if args.check_ods_gaps: + steps.append( + { + "name": "ODS_GAP_CHECK", + "type": "ods_gap_check", + "overlap_hours": str(args.check_ods_overlap_hours), + "window_days": str(args.check_ods_window_days), + "window_hours": str(args.check_ods_window_hours), + "page_size": str(args.check_ods_page_size), + "sleep_per_window": str(args.check_ods_sleep_per_window_seconds), + "sleep_per_page": str(args.check_ods_sleep_per_page_seconds), + "timeout_sec": str(args.check_ods_timeout_sec), + "task_codes": str(args.check_ods_task_codes or ""), + "tag": "run_update", + } + ) + + # ------------------------------------------------------------------ DWD (load from ODS tables) + steps.append( + { + "name": "INIT_DWS_SCHEMA", + "type": "init_dws_schema", + "overlap_seconds": str(args.overlap_seconds), + } + ) + for dwd_table in DwdLoadTask.TABLE_MAP.keys(): + steps.append( + { + "name": f"DWD:{dwd_table}", + "type": "dwd_table", + "dwd_table": dwd_table, + "overlap_seconds": str(args.overlap_seconds), + } + ) + + # ------------------------------------------------------------------ DWS (rebuild by date window) + window_start, window_end = _compute_dws_window( + cfg=cfg_base, + tz=tz, + rebuild_days=int(args.dws_rebuild_days), + bootstrap_days=int(args.dws_bootstrap_days), + dws_start=dws_start, + dws_end=dws_end, + ) + for start_dt, end_dt in _iter_daily_windows(window_start, window_end): + steps.append( + { + "name": f"DWS:{start_dt.date().isoformat()}", + "type": "dws_window", + "window_start": start_dt.strftime("%Y-%m-%d %H:%M:%S"), + "window_end": end_dt.strftime("%Y-%m-%d %H:%M:%S"), + "overlap_seconds": str(args.overlap_seconds), + } + ) + + if not args.skip_cutoff: + steps.append({"name": "CHECK_CUTOFF:after", "type": "check_cutoff"}) + + for step in steps: + step["log_file"] = str(log_file) + step["log_level"] = args.log_level + step["log_console"] = log_console + + step_results: list[dict[str, object]] = [] + for step in steps: + logger.info("STEP_START name=%s timeout=%ss", step["name"], STEP_TIMEOUT_SEC) + result = _run_step_with_timeout(step, logger, STEP_TIMEOUT_SEC) + step_results.append(result) + + total = len(step_results) + ok_count = sum(1 for r in step_results if r.get("status") == "ok") + timeout_count = sum(1 for r in step_results if r.get("status") == "timeout") + fail_count = total - ok_count - timeout_count + logger.info( + "STEP_SUMMARY total=%s ok=%s failed=%s timeout=%s", + total, + ok_count, + fail_count, + timeout_count, + ) + for item in sorted(step_results, key=lambda r: float(r.get("elapsed", 0.0)), reverse=True): + logger.info( + "STEP_RESULT name=%s status=%s elapsed=%.2fs", + item.get("name"), + item.get("status"), + item.get("elapsed", 0.0), + ) + + logger.info("Update done.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/etl_billiards/scripts/build_dws_order_summary.py b/etl_billiards/scripts/build_dws_order_summary.py index ea62ec6..ad964cc 100644 --- a/etl_billiards/scripts/build_dws_order_summary.py +++ b/etl_billiards/scripts/build_dws_order_summary.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Recompute billiards_dws.dws_order_summary from DWD fact tables.""" +"""Recompute billiards_dws.dws_order_summary from DWD tables (dwd_*).""" from __future__ import annotations import argparse @@ -15,119 +15,90 @@ from database.connection import DatabaseConnection # noqa: E402 SQL_BUILD_SUMMARY = r""" -WITH table_fee AS ( +WITH base AS ( + SELECT + sh.site_id, + sh.order_settle_id, + sh.order_trade_no, + COALESCE(sh.pay_time, sh.create_time)::date AS order_date, + sh.tenant_id, + sh.member_id, + COALESCE(sh.is_bind_member, FALSE) AS member_flag, + (COALESCE(sh.consume_money, 0) = 0 AND COALESCE(sh.pay_amount, 0) > 0) AS recharge_order_flag, + COALESCE(sh.member_discount_amount, 0) AS member_discount_amount, + COALESCE(sh.adjust_amount, 0) AS manual_discount_amount, + COALESCE(sh.pay_amount, 0) AS total_paid_amount, + COALESCE(sh.balance_amount, 0) + COALESCE(sh.recharge_card_amount, 0) + COALESCE(sh.gift_card_amount, 0) AS stored_card_deduct, + COALESCE(sh.coupon_amount, 0) AS total_coupon_deduction, + COALESCE(sh.table_charge_money, 0) AS settle_table_fee_amount, + COALESCE(sh.assistant_pd_money, 0) + COALESCE(sh.assistant_cx_money, 0) AS settle_assistant_service_amount, + COALESCE(sh.real_goods_money, 0) AS settle_goods_amount + FROM billiards_dwd.dwd_settlement_head sh + WHERE (%(site_id)s IS NULL OR sh.site_id = %(site_id)s) + AND (%(start_date)s IS NULL OR COALESCE(sh.pay_time, sh.create_time)::date >= %(start_date)s) + AND (%(end_date)s IS NULL OR COALESCE(sh.pay_time, sh.create_time)::date <= %(end_date)s) +), +table_fee AS ( SELECT site_id, order_settle_id, - order_trade_no, - MIN(member_id) AS member_id, - SUM(COALESCE(final_table_fee, 0)) AS table_fee_amount, - SUM(COALESCE(member_discount_amount, 0)) AS member_discount_amount, - SUM(COALESCE(manual_discount_amount, 0)) AS manual_discount_amount, - SUM(COALESCE(original_table_fee, 0)) AS original_table_fee, - MIN(start_time) AS first_time - FROM billiards_dwd.fact_table_usage - WHERE (%(site_id)s IS NULL OR site_id = %(site_id)s) - AND (%(start_date)s IS NULL OR start_time::date >= %(start_date)s) - AND (%(end_date)s IS NULL OR start_time::date <= %(end_date)s) - AND COALESCE(is_canceled, FALSE) = FALSE - GROUP BY site_id, order_settle_id, order_trade_no + SUM(COALESCE(real_table_charge_money, 0)) AS table_fee_amount + FROM billiards_dwd.dwd_table_fee_log + WHERE COALESCE(is_delete, 0) = 0 + AND (%(site_id)s IS NULL OR site_id = %(site_id)s) + AND (%(start_date)s IS NULL OR start_use_time::date >= %(start_date)s) + AND (%(end_date)s IS NULL OR start_use_time::date <= %(end_date)s) + GROUP BY site_id, order_settle_id ), assistant_fee AS ( SELECT site_id, order_settle_id, - order_trade_no, - MIN(member_id) AS member_id, - SUM(COALESCE(final_fee, 0)) AS assistant_service_amount, - SUM(COALESCE(member_discount_amount, 0)) AS member_discount_amount, - SUM(COALESCE(manual_discount_amount, 0)) AS manual_discount_amount, - SUM(COALESCE(original_fee, 0)) AS original_fee, - MIN(start_time) AS first_time - FROM billiards_dwd.fact_assistant_service - WHERE (%(site_id)s IS NULL OR site_id = %(site_id)s) - AND (%(start_date)s IS NULL OR start_time::date >= %(start_date)s) - AND (%(end_date)s IS NULL OR start_time::date <= %(end_date)s) - AND COALESCE(is_canceled, FALSE) = FALSE - GROUP BY site_id, order_settle_id, order_trade_no + SUM(COALESCE(ledger_amount, 0)) AS assistant_service_amount + FROM billiards_dwd.dwd_assistant_service_log + WHERE COALESCE(is_delete, 0) = 0 + AND (%(site_id)s IS NULL OR site_id = %(site_id)s) + AND (%(start_date)s IS NULL OR start_use_time::date >= %(start_date)s) + AND (%(end_date)s IS NULL OR start_use_time::date <= %(end_date)s) + GROUP BY site_id, order_settle_id ), goods_fee AS ( SELECT site_id, order_settle_id, - order_trade_no, - MIN(member_id) AS member_id, - SUM(COALESCE(final_amount, 0)) FILTER (WHERE COALESCE(is_gift, FALSE) = FALSE) AS goods_amount, - SUM(COALESCE(discount_amount, 0)) FILTER (WHERE COALESCE(is_gift, FALSE) = FALSE) AS goods_discount_amount, - SUM(COALESCE(original_amount, 0)) FILTER (WHERE COALESCE(is_gift, FALSE) = FALSE) AS goods_original_amount, - COUNT(*) FILTER (WHERE COALESCE(is_gift, FALSE) = FALSE) AS item_count, - SUM(COALESCE(quantity, 0)) FILTER (WHERE COALESCE(is_gift, FALSE) = FALSE) AS total_item_quantity, - MIN(sale_time) AS first_time - FROM billiards_dwd.fact_sale_item - WHERE (%(site_id)s IS NULL OR site_id = %(site_id)s) - AND (%(start_date)s IS NULL OR sale_time::date >= %(start_date)s) - AND (%(end_date)s IS NULL OR sale_time::date <= %(end_date)s) - GROUP BY site_id, order_settle_id, order_trade_no + COUNT(*) AS item_count, + SUM(COALESCE(ledger_count, 0)) AS total_item_quantity, + SUM(COALESCE(real_goods_money, 0)) AS goods_amount + FROM billiards_dwd.dwd_store_goods_sale + WHERE COALESCE(is_delete, 0) = 0 + AND (%(site_id)s IS NULL OR site_id = %(site_id)s) + AND (%(start_date)s IS NULL OR create_time::date >= %(start_date)s) + AND (%(end_date)s IS NULL OR create_time::date <= %(end_date)s) + GROUP BY site_id, order_settle_id ), -coupon_usage AS ( +group_fee AS ( SELECT site_id, order_settle_id, - order_trade_no, - MIN(member_id) AS member_id, - SUM(COALESCE(deduct_amount, 0)) AS coupon_deduction, - SUM(COALESCE(settle_price, 0)) AS settle_price, - MIN(used_time) AS first_time - FROM billiards_dwd.fact_coupon_usage - WHERE (%(site_id)s IS NULL OR site_id = %(site_id)s) - AND (%(start_date)s IS NULL OR used_time::date >= %(start_date)s) - AND (%(end_date)s IS NULL OR used_time::date <= %(end_date)s) - GROUP BY site_id, order_settle_id, order_trade_no -), -payments AS ( - SELECT - fp.site_id, - fp.order_settle_id, - fp.order_trade_no, - MIN(fp.member_id) AS member_id, - SUM(COALESCE(fp.pay_amount, 0)) AS total_paid_amount, - SUM(COALESCE(fp.pay_amount, 0)) FILTER (WHERE COALESCE(pm.is_stored_value, FALSE)) AS stored_card_deduct, - SUM(COALESCE(fp.pay_amount, 0)) FILTER (WHERE NOT COALESCE(pm.is_stored_value, FALSE)) AS external_paid_amount, - MIN(fp.pay_time) AS first_time - FROM billiards_dwd.fact_payment fp - LEFT JOIN billiards_dwd.dim_pay_method pm ON fp.pay_method_code = pm.pay_method_code - WHERE (%(site_id)s IS NULL OR fp.site_id = %(site_id)s) - AND (%(start_date)s IS NULL OR fp.pay_time::date >= %(start_date)s) - AND (%(end_date)s IS NULL OR fp.pay_time::date <= %(end_date)s) - GROUP BY fp.site_id, fp.order_settle_id, fp.order_trade_no + SUM(COALESCE(ledger_amount, 0)) AS group_amount + FROM billiards_dwd.dwd_groupbuy_redemption + WHERE COALESCE(is_delete, 0) = 0 + AND (%(site_id)s IS NULL OR site_id = %(site_id)s) + AND (%(start_date)s IS NULL OR create_time::date >= %(start_date)s) + AND (%(end_date)s IS NULL OR create_time::date <= %(end_date)s) + GROUP BY site_id, order_settle_id ), refunds AS ( SELECT - site_id, - order_settle_id, - order_trade_no, - SUM(COALESCE(refund_amount, 0)) AS refund_amount - FROM billiards_dwd.fact_refund - WHERE (%(site_id)s IS NULL OR site_id = %(site_id)s) - AND (%(start_date)s IS NULL OR refund_time::date >= %(start_date)s) - AND (%(end_date)s IS NULL OR refund_time::date <= %(end_date)s) - GROUP BY site_id, order_settle_id, order_trade_no -), -combined_ids AS ( - SELECT site_id, order_settle_id, order_trade_no FROM table_fee - UNION - SELECT site_id, order_settle_id, order_trade_no FROM assistant_fee - UNION - SELECT site_id, order_settle_id, order_trade_no FROM goods_fee - UNION - SELECT site_id, order_settle_id, order_trade_no FROM coupon_usage - UNION - SELECT site_id, order_settle_id, order_trade_no FROM payments - UNION - SELECT site_id, order_settle_id, order_trade_no FROM refunds -), -site_dim AS ( - SELECT site_id, tenant_id FROM billiards_dwd.dim_site + r.site_id, + r.relate_id AS order_settle_id, + SUM(COALESCE(rx.refund_amount, 0)) AS refund_amount + FROM billiards_dwd.dwd_refund r + LEFT JOIN billiards_dwd.dwd_refund_ex rx ON r.refund_id = rx.refund_id + WHERE (%(site_id)s IS NULL OR r.site_id = %(site_id)s) + AND (%(start_date)s IS NULL OR r.pay_time::date >= %(start_date)s) + AND (%(end_date)s IS NULL OR r.pay_time::date <= %(end_date)s) + GROUP BY r.site_id, r.relate_id ) INSERT INTO billiards_dws.dws_order_summary ( site_id, @@ -166,58 +137,50 @@ INSERT INTO billiards_dws.dws_order_summary ( updated_at ) SELECT - c.site_id, - c.order_settle_id, - c.order_trade_no, - COALESCE(tf.first_time, af.first_time, gf.first_time, pay.first_time, cu.first_time)::date AS order_date, - sd.tenant_id, - COALESCE(tf.member_id, af.member_id, gf.member_id, cu.member_id, pay.member_id) AS member_id, - COALESCE(tf.member_id, af.member_id, gf.member_id, cu.member_id, pay.member_id) IS NOT NULL AS member_flag, - -- recharge flag: no consumption side but has payments - (COALESCE(tf.table_fee_amount, 0) + COALESCE(af.assistant_service_amount, 0) + COALESCE(gf.goods_amount, 0) + COALESCE(cu.settle_price, 0) = 0) - AND COALESCE(pay.total_paid_amount, 0) > 0 AS recharge_order_flag, + b.site_id, + b.order_settle_id, + b.order_trade_no::text AS order_trade_no, + b.order_date, + b.tenant_id, + b.member_id, + b.member_flag, + b.recharge_order_flag, COALESCE(gf.item_count, 0) AS item_count, COALESCE(gf.total_item_quantity, 0) AS total_item_quantity, - COALESCE(tf.table_fee_amount, 0) AS table_fee_amount, - COALESCE(af.assistant_service_amount, 0) AS assistant_service_amount, - COALESCE(gf.goods_amount, 0) AS goods_amount, - COALESCE(cu.settle_price, 0) AS group_amount, - COALESCE(cu.coupon_deduction, 0) AS total_coupon_deduction, - COALESCE(tf.member_discount_amount, 0) + COALESCE(af.member_discount_amount, 0) + COALESCE(gf.goods_discount_amount, 0) AS member_discount_amount, - COALESCE(tf.manual_discount_amount, 0) + COALESCE(af.manual_discount_amount, 0) AS manual_discount_amount, - COALESCE(tf.original_table_fee, 0) + COALESCE(af.original_fee, 0) + COALESCE(gf.goods_original_amount, 0) AS order_original_amount, - COALESCE(tf.table_fee_amount, 0) + COALESCE(af.assistant_service_amount, 0) + COALESCE(gf.goods_amount, 0) + COALESCE(cu.settle_price, 0) - COALESCE(cu.coupon_deduction, 0) AS order_final_amount, - COALESCE(pay.stored_card_deduct, 0) AS stored_card_deduct, - COALESCE(pay.external_paid_amount, 0) AS external_paid_amount, - COALESCE(pay.total_paid_amount, 0) AS total_paid_amount, - COALESCE(tf.table_fee_amount, 0) AS book_table_flow, - COALESCE(af.assistant_service_amount, 0) AS book_assistant_flow, - COALESCE(gf.goods_amount, 0) AS book_goods_flow, - COALESCE(cu.settle_price, 0) AS book_group_flow, - COALESCE(tf.table_fee_amount, 0) + COALESCE(af.assistant_service_amount, 0) + COALESCE(gf.goods_amount, 0) + COALESCE(cu.settle_price, 0) AS book_order_flow, - CASE - WHEN (COALESCE(tf.table_fee_amount, 0) + COALESCE(af.assistant_service_amount, 0) + COALESCE(gf.goods_amount, 0) + COALESCE(cu.settle_price, 0) = 0) - THEN 0 - ELSE COALESCE(pay.external_paid_amount, 0) - END AS order_effective_consume_cash, - CASE - WHEN (COALESCE(tf.table_fee_amount, 0) + COALESCE(af.assistant_service_amount, 0) + COALESCE(gf.goods_amount, 0) + COALESCE(cu.settle_price, 0) = 0) - THEN COALESCE(pay.external_paid_amount, 0) - ELSE 0 - END AS order_effective_recharge_cash, - COALESCE(pay.external_paid_amount, 0) + COALESCE(cu.settle_price, 0) AS order_effective_flow, + COALESCE(tf.table_fee_amount, b.settle_table_fee_amount) AS table_fee_amount, + COALESCE(af.assistant_service_amount, b.settle_assistant_service_amount) AS assistant_service_amount, + COALESCE(gf.goods_amount, b.settle_goods_amount) AS goods_amount, + COALESCE(gr.group_amount, 0) AS group_amount, + b.total_coupon_deduction AS total_coupon_deduction, + b.member_discount_amount AS member_discount_amount, + b.manual_discount_amount AS manual_discount_amount, + -- approximate original amount: final + discounts/coupon + (b.total_paid_amount + b.total_coupon_deduction + b.member_discount_amount + b.manual_discount_amount) AS order_original_amount, + b.total_paid_amount AS order_final_amount, + b.stored_card_deduct AS stored_card_deduct, + GREATEST(b.total_paid_amount - b.stored_card_deduct, 0) AS external_paid_amount, + b.total_paid_amount AS total_paid_amount, + COALESCE(tf.table_fee_amount, b.settle_table_fee_amount) AS book_table_flow, + COALESCE(af.assistant_service_amount, b.settle_assistant_service_amount) AS book_assistant_flow, + COALESCE(gf.goods_amount, b.settle_goods_amount) AS book_goods_flow, + COALESCE(gr.group_amount, 0) AS book_group_flow, + COALESCE(tf.table_fee_amount, b.settle_table_fee_amount) + + COALESCE(af.assistant_service_amount, b.settle_assistant_service_amount) + + COALESCE(gf.goods_amount, b.settle_goods_amount) + + COALESCE(gr.group_amount, 0) AS book_order_flow, + GREATEST(b.total_paid_amount - b.stored_card_deduct, 0) AS order_effective_consume_cash, + 0 AS order_effective_recharge_cash, + b.total_paid_amount AS order_effective_flow, COALESCE(rf.refund_amount, 0) AS refund_amount, - (COALESCE(pay.external_paid_amount, 0) + COALESCE(cu.settle_price, 0)) - COALESCE(rf.refund_amount, 0) AS net_income, + b.total_paid_amount - COALESCE(rf.refund_amount, 0) AS net_income, now() AS created_at, now() AS updated_at -FROM combined_ids c -LEFT JOIN table_fee tf ON c.site_id = tf.site_id AND c.order_settle_id = tf.order_settle_id -LEFT JOIN assistant_fee af ON c.site_id = af.site_id AND c.order_settle_id = af.order_settle_id -LEFT JOIN goods_fee gf ON c.site_id = gf.site_id AND c.order_settle_id = gf.order_settle_id -LEFT JOIN coupon_usage cu ON c.site_id = cu.site_id AND c.order_settle_id = cu.order_settle_id -LEFT JOIN payments pay ON c.site_id = pay.site_id AND c.order_settle_id = pay.order_settle_id -LEFT JOIN refunds rf ON c.site_id = rf.site_id AND c.order_settle_id = rf.order_settle_id -LEFT JOIN site_dim sd ON c.site_id = sd.site_id +FROM base b +LEFT JOIN table_fee tf ON b.site_id = tf.site_id AND b.order_settle_id = tf.order_settle_id +LEFT JOIN assistant_fee af ON b.site_id = af.site_id AND b.order_settle_id = af.order_settle_id +LEFT JOIN goods_fee gf ON b.site_id = gf.site_id AND b.order_settle_id = gf.order_settle_id +LEFT JOIN group_fee gr ON b.site_id = gr.site_id AND b.order_settle_id = gr.order_settle_id +LEFT JOIN refunds rf ON b.site_id = rf.site_id AND b.order_settle_id = rf.order_settle_id ON CONFLICT (site_id, order_settle_id) DO UPDATE SET order_trade_no = EXCLUDED.order_trade_no, order_date = EXCLUDED.order_date, diff --git a/etl_billiards/scripts/check_ods_gaps.py b/etl_billiards/scripts/check_ods_gaps.py new file mode 100644 index 0000000..6f0b779 --- /dev/null +++ b/etl_billiards/scripts/check_ods_gaps.py @@ -0,0 +1,783 @@ +# -*- coding: utf-8 -*- +""" +Check missing ODS records by comparing API primary keys vs ODS table primary keys. + +Default range: + start = 2025-07-01 00:00:00 + end = now + +For update runs, use --from-cutoff to derive the start time from ODS max(fetched_at), +then backtrack by --cutoff-overlap-hours. +""" +from __future__ import annotations + +import argparse +import json +import logging +import time as time_mod +import sys +from datetime import datetime, time, timedelta +from pathlib import Path +from typing import Iterable, Sequence +from zoneinfo import ZoneInfo + +from dateutil import parser as dtparser +from psycopg2.extras import execute_values + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from api.client import APIClient +from config.settings import AppConfig +from database.connection import DatabaseConnection +from models.parsers import TypeParser +from tasks.ods_tasks import ENABLED_ODS_CODES, ODS_TASK_SPECS +from utils.logging_utils import build_log_path, configure_logging + +DEFAULT_START = "2025-07-01" +MIN_COMPLETENESS_WINDOW_DAYS = 30 + + +def _reconfigure_stdout_utf8() -> None: + if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + +def _parse_dt(value: str, tz: ZoneInfo, *, is_end: bool) -> datetime: + raw = (value or "").strip() + if not raw: + raise ValueError("empty datetime") + has_time = any(ch in raw for ch in (":", "T")) + dt = dtparser.parse(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=tz) + else: + dt = dt.astimezone(tz) + if not has_time: + dt = dt.replace(hour=23 if is_end else 0, minute=59 if is_end else 0, second=59 if is_end else 0, microsecond=0) + return dt + + +def _iter_windows(start: datetime, end: datetime, window_size: timedelta) -> Iterable[tuple[datetime, datetime]]: + if window_size.total_seconds() <= 0: + raise ValueError("window_size must be > 0") + cur = start + while cur < end: + nxt = min(cur + window_size, end) + yield cur, nxt + cur = nxt + + +def _merge_record_layers(record: dict) -> dict: + merged = record + data_part = merged.get("data") + while isinstance(data_part, dict): + merged = {**data_part, **merged} + data_part = data_part.get("data") + settle_inner = merged.get("settleList") + if isinstance(settle_inner, dict): + merged = {**settle_inner, **merged} + return merged + + +def _get_value_case_insensitive(record: dict | None, col: str | None): + if record is None or col is None: + return None + if col in record: + return record.get(col) + col_lower = col.lower() + for k, v in record.items(): + if isinstance(k, str) and k.lower() == col_lower: + return v + return None + + +def _normalize_pk_value(value): + if value is None: + return None + if isinstance(value, str) and value.isdigit(): + try: + return int(value) + except Exception: + return value + return value + + +def _chunked(seq: Sequence, size: int) -> Iterable[Sequence]: + if size <= 0: + size = 500 + for i in range(0, len(seq), size): + yield seq[i : i + size] + + +def _get_table_pk_columns(conn, table: str) -> list[str]: + if "." in table: + schema, name = table.split(".", 1) + else: + schema, name = "public", table + sql = """ + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = %s + AND tc.table_name = %s + ORDER BY kcu.ordinal_position + """ + with conn.cursor() as cur: + cur.execute(sql, (schema, name)) + return [r[0] for r in cur.fetchall()] + + +def _fetch_existing_pk_set(conn, table: str, pk_cols: Sequence[str], pk_values: list[tuple], chunk_size: int) -> set[tuple]: + if not pk_values: + return set() + select_cols = ", ".join(f't."{c}"' for c in pk_cols) + value_cols = ", ".join(f'"{c}"' for c in pk_cols) + join_cond = " AND ".join(f't."{c}" = v."{c}"' for c in pk_cols) + sql = ( + f"SELECT {select_cols} FROM {table} t " + f"JOIN (VALUES %s) AS v({value_cols}) ON {join_cond}" + ) + existing: set[tuple] = set() + with conn.cursor() as cur: + for chunk in _chunked(pk_values, chunk_size): + execute_values(cur, sql, chunk, page_size=len(chunk)) + for row in cur.fetchall(): + existing.add(tuple(row)) + return existing + + +def _merge_common_params(cfg: AppConfig, task_code: str, base: dict) -> dict: + merged: dict = {} + common = cfg.get("api.params", {}) or {} + if isinstance(common, dict): + merged.update(common) + scoped = cfg.get(f"api.params.{task_code.lower()}", {}) or {} + if isinstance(scoped, dict): + merged.update(scoped) + merged.update(base) + return merged + + +def _build_params(cfg: AppConfig, spec, store_id: int, window_start: datetime | None, window_end: datetime | None) -> dict: + base: dict = {} + if spec.include_site_id: + if spec.endpoint == "/TenantGoods/GetGoodsInventoryList": + base["siteId"] = [store_id] + else: + base["siteId"] = store_id + if spec.requires_window and spec.time_fields and window_start and window_end: + start_key, end_key = spec.time_fields + base[start_key] = TypeParser.format_timestamp(window_start, ZoneInfo(cfg.get("app.timezone", "Asia/Taipei"))) + base[end_key] = TypeParser.format_timestamp(window_end, ZoneInfo(cfg.get("app.timezone", "Asia/Taipei"))) + base.update(spec.extra_params or {}) + return _merge_common_params(cfg, spec.code, base) + + +def _pk_tuple_from_record(record: dict, pk_cols: Sequence[str]) -> tuple | None: + merged = _merge_record_layers(record) + values = [] + for col in pk_cols: + val = _normalize_pk_value(_get_value_case_insensitive(merged, col)) + if val is None or val == "": + return None + values.append(val) + return tuple(values) + + +def _pk_tuple_from_ticket_candidate(value) -> tuple | None: + val = _normalize_pk_value(value) + if val is None or val == "": + return None + return (val,) + + +def _format_missing_sample(pk_cols: Sequence[str], pk_tuple: tuple) -> dict: + return {col: pk_tuple[idx] for idx, col in enumerate(pk_cols)} + + +def _check_spec( + *, + client: APIClient, + db_conn, + cfg: AppConfig, + tz: ZoneInfo, + logger: logging.Logger, + spec, + store_id: int, + start: datetime | None, + end: datetime | None, + window_days: int, + window_hours: int, + page_size: int, + chunk_size: int, + sample_limit: int, + sleep_per_window: float, + sleep_per_page: float, +) -> dict: + result = { + "task_code": spec.code, + "table": spec.table_name, + "endpoint": spec.endpoint, + "pk_columns": [], + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 0, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": None, + } + + pk_cols = _get_table_pk_columns(db_conn, spec.table_name) + result["pk_columns"] = pk_cols + if not pk_cols: + result["errors"] = 1 + result["error_detail"] = "no primary key columns found" + return result + + if spec.requires_window and spec.time_fields: + if not start or not end: + result["errors"] = 1 + result["error_detail"] = "missing start/end for windowed endpoint" + return result + window_size = timedelta(hours=window_hours) if window_hours > 0 else timedelta(days=window_days) + windows = list(_iter_windows(start, end, window_size)) + else: + windows = [(None, None)] + + logger.info( + "CHECK_START task=%s table=%s windows=%s start=%s end=%s", + spec.code, + spec.table_name, + len(windows), + start.isoformat() if start else None, + end.isoformat() if end else None, + ) + missing_seen: set[tuple] = set() + + for window_idx, (window_start, window_end) in enumerate(windows, start=1): + window_label = ( + f"{window_start.isoformat()}~{window_end.isoformat()}" + if window_start and window_end + else "FULL" + ) + logger.info( + "WINDOW_START task=%s idx=%s window=%s", + spec.code, + window_idx, + window_label, + ) + window_pages = 0 + window_records = 0 + window_missing = 0 + window_skipped = 0 + params = _build_params(cfg, spec, store_id, window_start, window_end) + try: + for page_no, records, _, _ in client.iter_paginated( + endpoint=spec.endpoint, + params=params, + page_size=page_size, + data_path=spec.data_path, + list_key=spec.list_key, + ): + window_pages += 1 + window_records += len(records) + result["pages"] += 1 + result["records"] += len(records) + pk_tuples: list[tuple] = [] + for rec in records: + if not isinstance(rec, dict): + result["skipped_missing_pk"] += 1 + window_skipped += 1 + continue + pk_tuple = _pk_tuple_from_record(rec, pk_cols) + if not pk_tuple: + result["skipped_missing_pk"] += 1 + window_skipped += 1 + continue + pk_tuples.append(pk_tuple) + + if not pk_tuples: + continue + + result["records_with_pk"] += len(pk_tuples) + pk_unique = list(dict.fromkeys(pk_tuples)) + existing = _fetch_existing_pk_set(db_conn, spec.table_name, pk_cols, pk_unique, chunk_size) + for pk_tuple in pk_unique: + if pk_tuple in existing: + continue + if pk_tuple in missing_seen: + continue + missing_seen.add(pk_tuple) + result["missing"] += 1 + window_missing += 1 + if len(result["missing_samples"]) < sample_limit: + result["missing_samples"].append(_format_missing_sample(pk_cols, pk_tuple)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "PAGE task=%s idx=%s page=%s records=%s missing=%s skipped=%s", + spec.code, + window_idx, + page_no, + len(records), + window_missing, + window_skipped, + ) + if sleep_per_page > 0: + time_mod.sleep(sleep_per_page) + except Exception as exc: + result["errors"] += 1 + result["error_detail"] = f"{type(exc).__name__}: {exc}" + logger.exception( + "WINDOW_ERROR task=%s idx=%s window=%s error=%s", + spec.code, + window_idx, + window_label, + result["error_detail"], + ) + break + logger.info( + "WINDOW_DONE task=%s idx=%s window=%s pages=%s records=%s missing=%s skipped=%s", + spec.code, + window_idx, + window_label, + window_pages, + window_records, + window_missing, + window_skipped, + ) + if sleep_per_window > 0: + logger.debug( + "SLEEP_WINDOW task=%s idx=%s seconds=%.2f", + spec.code, + window_idx, + sleep_per_window, + ) + time_mod.sleep(sleep_per_window) + + return result + + +def _check_settlement_tickets( + *, + client: APIClient, + db_conn, + cfg: AppConfig, + tz: ZoneInfo, + logger: logging.Logger, + store_id: int, + start: datetime | None, + end: datetime | None, + window_days: int, + window_hours: int, + page_size: int, + chunk_size: int, + sample_limit: int, + sleep_per_window: float, + sleep_per_page: float, +) -> dict: + table_name = "billiards_ods.settlement_ticket_details" + pk_cols = _get_table_pk_columns(db_conn, table_name) + result = { + "task_code": "ODS_SETTLEMENT_TICKET", + "table": table_name, + "endpoint": "/Order/GetOrderSettleTicketNew", + "pk_columns": pk_cols, + "records": 0, + "records_with_pk": 0, + "missing": 0, + "missing_samples": [], + "pages": 0, + "skipped_missing_pk": 0, + "errors": 0, + "error_detail": None, + "source_endpoint": "/PayLog/GetPayLogListPage", + } + + if not pk_cols: + result["errors"] = 1 + result["error_detail"] = "no primary key columns found" + return result + if not start or not end: + result["errors"] = 1 + result["error_detail"] = "missing start/end for ticket check" + return result + + missing_seen: set[tuple] = set() + pay_endpoint = "/PayLog/GetPayLogListPage" + + window_size = timedelta(hours=window_hours) if window_hours > 0 else timedelta(days=window_days) + windows = list(_iter_windows(start, end, window_size)) + logger.info( + "CHECK_START task=%s table=%s windows=%s start=%s end=%s", + result["task_code"], + table_name, + len(windows), + start.isoformat() if start else None, + end.isoformat() if end else None, + ) + + for window_idx, (window_start, window_end) in enumerate(windows, start=1): + window_label = f"{window_start.isoformat()}~{window_end.isoformat()}" + logger.info( + "WINDOW_START task=%s idx=%s window=%s", + result["task_code"], + window_idx, + window_label, + ) + window_pages = 0 + window_records = 0 + window_missing = 0 + window_skipped = 0 + base = { + "siteId": store_id, + "StartPayTime": TypeParser.format_timestamp(window_start, tz), + "EndPayTime": TypeParser.format_timestamp(window_end, tz), + } + params = _merge_common_params(cfg, "ODS_PAYMENT", base) + try: + for page_no, records, _, _ in client.iter_paginated( + endpoint=pay_endpoint, + params=params, + page_size=page_size, + data_path=("data",), + list_key=None, + ): + window_pages += 1 + window_records += len(records) + result["pages"] += 1 + result["records"] += len(records) + pk_tuples: list[tuple] = [] + for rec in records: + if not isinstance(rec, dict): + result["skipped_missing_pk"] += 1 + window_skipped += 1 + continue + relate_id = TypeParser.parse_int( + (rec or {}).get("relateId") + or (rec or {}).get("orderSettleId") + or (rec or {}).get("order_settle_id") + ) + pk_tuple = _pk_tuple_from_ticket_candidate(relate_id) + if not pk_tuple: + result["skipped_missing_pk"] += 1 + window_skipped += 1 + continue + pk_tuples.append(pk_tuple) + + if not pk_tuples: + continue + + result["records_with_pk"] += len(pk_tuples) + pk_unique = list(dict.fromkeys(pk_tuples)) + existing = _fetch_existing_pk_set(db_conn, table_name, pk_cols, pk_unique, chunk_size) + for pk_tuple in pk_unique: + if pk_tuple in existing: + continue + if pk_tuple in missing_seen: + continue + missing_seen.add(pk_tuple) + result["missing"] += 1 + window_missing += 1 + if len(result["missing_samples"]) < sample_limit: + result["missing_samples"].append(_format_missing_sample(pk_cols, pk_tuple)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "PAGE task=%s idx=%s page=%s records=%s missing=%s skipped=%s", + result["task_code"], + window_idx, + page_no, + len(records), + window_missing, + window_skipped, + ) + if sleep_per_page > 0: + time_mod.sleep(sleep_per_page) + except Exception as exc: + result["errors"] += 1 + result["error_detail"] = f"{type(exc).__name__}: {exc}" + logger.exception( + "WINDOW_ERROR task=%s idx=%s window=%s error=%s", + result["task_code"], + window_idx, + window_label, + result["error_detail"], + ) + break + logger.info( + "WINDOW_DONE task=%s idx=%s window=%s pages=%s records=%s missing=%s skipped=%s", + result["task_code"], + window_idx, + window_label, + window_pages, + window_records, + window_missing, + window_skipped, + ) + if sleep_per_window > 0: + logger.debug( + "SLEEP_WINDOW task=%s idx=%s seconds=%.2f", + result["task_code"], + window_idx, + sleep_per_window, + ) + time_mod.sleep(sleep_per_window) + + return result + + +def _compute_ods_cutoff(conn, ods_tables: Sequence[str]) -> datetime | None: + values: list[datetime] = [] + with conn.cursor() as cur: + for table in ods_tables: + try: + cur.execute(f"SELECT MAX(fetched_at) FROM {table}") + row = cur.fetchone() + if row and row[0]: + values.append(row[0]) + except Exception: + continue + if not values: + return None + return min(values) + + +def _resolve_window_from_cutoff( + *, + conn, + ods_tables: Sequence[str], + tz: ZoneInfo, + overlap_hours: int, +) -> tuple[datetime, datetime, datetime | None]: + cutoff = _compute_ods_cutoff(conn, ods_tables) + now = datetime.now(tz) + if cutoff is None: + start = now - timedelta(hours=max(1, overlap_hours)) + return start, now, None + if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=tz) + else: + cutoff = cutoff.astimezone(tz) + start = cutoff - timedelta(hours=max(0, overlap_hours)) + return start, now, cutoff + + +def main() -> int: + _reconfigure_stdout_utf8() + ap = argparse.ArgumentParser(description="Check missing ODS records by comparing API vs ODS PKs.") + ap.add_argument("--start", default=DEFAULT_START, help="start datetime (default: 2025-07-01)") + ap.add_argument("--end", default="", help="end datetime (default: now)") + ap.add_argument("--window-days", type=int, default=1, help="days per API window (default: 1)") + ap.add_argument("--window-hours", type=int, default=0, help="hours per API window (default: 0)") + ap.add_argument("--page-size", type=int, default=200, help="API page size (default: 200)") + ap.add_argument("--chunk-size", type=int, default=500, help="DB query chunk size (default: 500)") + ap.add_argument("--sample-limit", type=int, default=50, help="max missing PK samples per table") + ap.add_argument("--sleep-per-window-seconds", type=float, default=0, help="sleep seconds after each window") + ap.add_argument("--sleep-per-page-seconds", type=float, default=0, help="sleep seconds after each page") + ap.add_argument("--task-codes", default="", help="comma-separated task codes to check (optional)") + ap.add_argument("--out", default="", help="output JSON path (optional)") + ap.add_argument("--tag", default="", help="tag suffix for output filename") + ap.add_argument("--from-cutoff", action="store_true", help="derive start from ODS cutoff") + ap.add_argument( + "--cutoff-overlap-hours", + type=int, + default=24, + help="overlap hours when using --from-cutoff (default: 24)", + ) + ap.add_argument("--log-file", default="", help="log file path (default: logs/check_ods_gaps_YYYYMMDD_HHMMSS.log)") + ap.add_argument("--log-dir", default="", help="log directory (default: logs)") + ap.add_argument("--log-level", default="INFO", help="log level (default: INFO)") + ap.add_argument("--no-log-console", action="store_true", help="disable console logging") + args = ap.parse_args() + + log_dir = Path(args.log_dir) if args.log_dir else (PROJECT_ROOT / "logs") + log_file = Path(args.log_file) if args.log_file else build_log_path(log_dir, "check_ods_gaps", args.tag) + log_console = not args.no_log_console + + with configure_logging( + "ods_gap_check", + log_file, + level=args.log_level, + console=log_console, + tee_std=True, + ) as logger: + cfg = AppConfig.load({}) + tz = ZoneInfo(cfg.get("app.timezone", "Asia/Taipei")) + store_id = int(cfg.get("app.store_id")) + + if not cfg.get("api.token"): + logger.error("missing api.token; please set API_TOKEN in .env") + raise SystemExit("missing api.token; please set API_TOKEN in .env") + + window_days = int(args.window_days) + window_hours = int(args.window_hours) + if not args.from_cutoff: + min_hours = MIN_COMPLETENESS_WINDOW_DAYS * 24 + if window_hours > 0: + if window_hours < min_hours: + logger.warning( + "window_hours=%s too small for completeness check; adjust to %s", + window_hours, + min_hours, + ) + window_hours = min_hours + elif window_days < MIN_COMPLETENESS_WINDOW_DAYS: + logger.warning( + "window_days=%s too small for completeness check; adjust to %s", + window_days, + MIN_COMPLETENESS_WINDOW_DAYS, + ) + window_days = MIN_COMPLETENESS_WINDOW_DAYS + + end = datetime.now(tz) if not args.end else _parse_dt(args.end, tz, is_end=True) + if args.from_cutoff: + db_tmp = DatabaseConnection(dsn=cfg["db"]["dsn"], session=cfg["db"].get("session")) + ods_tables = [s.table_name for s in ODS_TASK_SPECS if s.code in ENABLED_ODS_CODES] + start, end, cutoff = _resolve_window_from_cutoff( + conn=db_tmp.conn, + ods_tables=ods_tables, + tz=tz, + overlap_hours=args.cutoff_overlap_hours, + ) + db_tmp.close() + else: + start = _parse_dt(args.start, tz, is_end=False) + cutoff = None + + logger.info( + "START range=%s~%s window_days=%s window_hours=%s page_size=%s chunk_size=%s", + start.isoformat() if start else None, + end.isoformat() if end else None, + window_days, + window_hours, + args.page_size, + args.chunk_size, + ) + if cutoff: + logger.info("CUTOFF=%s overlap_hours=%s", cutoff.isoformat(), args.cutoff_overlap_hours) + + client = APIClient( + base_url=cfg["api"]["base_url"], + token=cfg["api"]["token"], + timeout=int(cfg["api"].get("timeout_sec") or 20), + retry_max=int(cfg["api"].get("retries", {}).get("max_attempts") or 3), + headers_extra=cfg["api"].get("headers_extra") or {}, + ) + + db_conn = DatabaseConnection(dsn=cfg["db"]["dsn"], session=cfg["db"].get("session")) + try: + db_conn.conn.rollback() + except Exception: + pass + db_conn.conn.autocommit = True + try: + task_filter = {t.strip().upper() for t in args.task_codes.split(",") if t.strip()} + specs = [s for s in ODS_TASK_SPECS if s.code in ENABLED_ODS_CODES] + if task_filter: + specs = [s for s in specs if s.code in task_filter] + + results: list[dict] = [] + for spec in specs: + if spec.code == "ODS_SETTLEMENT_TICKET": + continue + result = _check_spec( + client=client, + db_conn=db_conn.conn, + cfg=cfg, + tz=tz, + logger=logger, + spec=spec, + store_id=store_id, + start=start, + end=end, + window_days=window_days, + window_hours=window_hours, + page_size=args.page_size, + chunk_size=args.chunk_size, + sample_limit=args.sample_limit, + sleep_per_window=args.sleep_per_window_seconds, + sleep_per_page=args.sleep_per_page_seconds, + ) + results.append(result) + logger.info( + "CHECK_DONE task=%s missing=%s records=%s errors=%s", + result.get("task_code"), + result.get("missing"), + result.get("records"), + result.get("errors"), + ) + + if (not task_filter) or ("ODS_SETTLEMENT_TICKET" in task_filter): + ticket_result = _check_settlement_tickets( + client=client, + db_conn=db_conn.conn, + cfg=cfg, + tz=tz, + logger=logger, + store_id=store_id, + start=start, + end=end, + window_days=window_days, + window_hours=window_hours, + page_size=args.page_size, + chunk_size=args.chunk_size, + sample_limit=args.sample_limit, + sleep_per_window=args.sleep_per_window_seconds, + sleep_per_page=args.sleep_per_page_seconds, + ) + results.append(ticket_result) + logger.info( + "CHECK_DONE task=%s missing=%s records=%s errors=%s", + ticket_result.get("task_code"), + ticket_result.get("missing"), + ticket_result.get("records"), + ticket_result.get("errors"), + ) + + total_missing = sum(int(r.get("missing") or 0) for r in results) + total_errors = sum(int(r.get("errors") or 0) for r in results) + + if args.out: + out_path = Path(args.out) + else: + tag = f"_{args.tag}" if args.tag else "" + stamp = datetime.now(tz).strftime("%Y%m%d_%H%M%S") + out_path = PROJECT_ROOT / "reports" / f"ods_gap_check{tag}_{stamp}.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + + payload = { + "start": start.isoformat(), + "end": end.isoformat(), + "cutoff": cutoff.isoformat() if cutoff else None, + "window_days": window_days, + "window_hours": window_hours, + "page_size": args.page_size, + "chunk_size": args.chunk_size, + "sample_limit": args.sample_limit, + "store_id": store_id, + "base_url": cfg.get("api.base_url"), + "results": results, + "total_missing": total_missing, + "total_errors": total_errors, + "generated_at": datetime.now(tz).isoformat(), + } + out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + logger.info("REPORT_WRITTEN path=%s", out_path) + logger.info("SUMMARY missing=%s errors=%s", total_missing, total_errors) + finally: + db_conn.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/etl_billiards/scripts/rebuild_db_and_run_ods_to_dwd.py b/etl_billiards/scripts/rebuild_db_and_run_ods_to_dwd.py new file mode 100644 index 0000000..f2c2686 --- /dev/null +++ b/etl_billiards/scripts/rebuild_db_and_run_ods_to_dwd.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- +""" +一键重建 ETL 相关 Schema,并执行 ODS → DWD。 + +本脚本面向“离线示例 JSON 回放”的开发/运维场景,使用当前项目内的任务实现: +1) (可选)DROP 并重建 schema:`etl_admin` / `billiards_ods` / `billiards_dwd` +2) 执行 `INIT_ODS_SCHEMA`:创建 `etl_admin` 元数据表 + 执行 `schema_ODS_doc.sql`(内部会做轻量清洗) +3) 执行 `INIT_DWD_SCHEMA`:执行 `schema_dwd_doc.sql` +4) 执行 `MANUAL_INGEST`:从本地 JSON 目录灌入 ODS +5) 执行 `DWD_LOAD_FROM_ODS`:从 ODS 装载到 DWD + +用法(推荐): + python -m etl_billiards.scripts.rebuild_db_and_run_ods_to_dwd ^ + --dsn "postgresql://user:pwd@host:5432/db" ^ + --store-id 1 ^ + --json-dir "C:\\dev\\LLTQ\\export\\test-json-doc" ^ + --drop-schemas + +环境变量(可选): + PG_DSN、STORE_ID、INGEST_SOURCE_DIR + +日志: + 默认同时输出到控制台与文件;文件路径为 `io.log_root/rebuild_db_<时间戳>.log`。 +""" + +from __future__ import annotations + +import argparse +import logging +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import psycopg2 + +from etl_billiards.config.settings import AppConfig +from etl_billiards.database.connection import DatabaseConnection +from etl_billiards.database.operations import DatabaseOperations +from etl_billiards.tasks.dwd_load_task import DwdLoadTask +from etl_billiards.tasks.init_dwd_schema_task import InitDwdSchemaTask +from etl_billiards.tasks.init_schema_task import InitOdsSchemaTask +from etl_billiards.tasks.manual_ingest_task import ManualIngestTask + + +DEFAULT_JSON_DIR = r"C:\dev\LLTQ\export\test-json-doc" + + +@dataclass(frozen=True) +class RunArgs: + """脚本参数对象(用于减少散落的参数传递)。""" + + dsn: str + store_id: int + json_dir: str + drop_schemas: bool + terminate_own_sessions: bool + demo: bool + only_files: list[str] + only_dwd_tables: list[str] + stop_after: str | None + + +def _attach_file_logger(log_root: str | Path, filename: str, logger: logging.Logger) -> logging.Handler | None: + """ + 给 root logger 附加文件日志处理器(UTF-8)。 + + 说明: + - 使用 root logger 是为了覆盖项目中不同命名的 logger(包含第三方/子模块)。 + - 若创建失败仅记录 warning,不中断主流程。 + + 返回值: + 创建成功返回 handler(调用方负责 removeHandler/close),失败返回 None。 + """ + log_dir = Path(log_root) + try: + log_dir.mkdir(parents=True, exist_ok=True) + except Exception as exc: # noqa: BLE001 + logger.warning("创建日志目录失败:%s(%s)", log_dir, exc) + return None + + log_path = log_dir / filename + try: + handler: logging.Handler = logging.FileHandler(log_path, encoding="utf-8") + except Exception as exc: # noqa: BLE001 + logger.warning("创建文件日志失败:%s(%s)", log_path, exc) + return None + + handler.setLevel(logging.INFO) + handler.setFormatter( + logging.Formatter( + fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logging.getLogger().addHandler(handler) + logger.info("文件日志已启用:%s", log_path) + return handler + + +def _parse_args() -> RunArgs: + """解析命令行/环境变量参数。""" + parser = argparse.ArgumentParser(description="重建 Schema 并执行 ODS→DWD(离线 JSON 回放)") + parser.add_argument("--dsn", default=os.environ.get("PG_DSN"), help="PostgreSQL DSN(默认读取 PG_DSN)") + parser.add_argument( + "--store-id", + type=int, + default=int(os.environ.get("STORE_ID") or 1), + help="门店/租户 store_id(默认读取 STORE_ID,否则为 1)", + ) + parser.add_argument( + "--json-dir", + default=os.environ.get("INGEST_SOURCE_DIR") or DEFAULT_JSON_DIR, + help=f"示例 JSON 目录(默认 {DEFAULT_JSON_DIR},也可读 INGEST_SOURCE_DIR)", + ) + parser.add_argument( + "--drop-schemas", + action=argparse.BooleanOptionalAction, + default=True, + help="是否先 DROP 并重建 etl_admin/billiards_ods/billiards_dwd(默认:是)", + ) + parser.add_argument( + "--terminate-own-sessions", + action=argparse.BooleanOptionalAction, + default=True, + help="执行 DROP 前是否终止当前用户的 idle-in-transaction 会话(默认:是)", + ) + parser.add_argument( + "--demo", + action=argparse.BooleanOptionalAction, + default=False, + help="运行最小 Demo(仅导入 member_profiles 并生成 dim_member/dim_member_ex)", + ) + parser.add_argument( + "--only-files", + default="", + help="仅处理指定 JSON 文件(逗号分隔,不含 .json,例如:member_profiles,settlement_records)", + ) + parser.add_argument( + "--only-dwd-tables", + default="", + help="仅处理指定 DWD 表(逗号分隔,支持完整名或表名,例如:billiards_dwd.dim_member,dim_member_ex)", + ) + parser.add_argument( + "--stop-after", + default="", + help="在指定阶段后停止(可选:DROP_SCHEMAS/INIT_ODS_SCHEMA/INIT_DWD_SCHEMA/MANUAL_INGEST/DWD_LOAD_FROM_ODS/BASIC_VALIDATE)", + ) + args = parser.parse_args() + + if not args.dsn: + raise SystemExit("缺少 DSN:请传入 --dsn 或设置环境变量 PG_DSN") + only_files = [x.strip().lower() for x in str(args.only_files or "").split(",") if x.strip()] + only_dwd_tables = [x.strip().lower() for x in str(args.only_dwd_tables or "").split(",") if x.strip()] + stop_after = str(args.stop_after or "").strip().upper() or None + return RunArgs( + dsn=args.dsn, + store_id=args.store_id, + json_dir=str(args.json_dir), + drop_schemas=bool(args.drop_schemas), + terminate_own_sessions=bool(args.terminate_own_sessions), + demo=bool(args.demo), + only_files=only_files, + only_dwd_tables=only_dwd_tables, + stop_after=stop_after, + ) + + +def _build_config(args: RunArgs) -> AppConfig: + """构建本次执行所需的最小配置覆盖。""" + manual_cfg: dict[str, Any] = {} + dwd_cfg: dict[str, Any] = {} + if args.demo: + manual_cfg["include_files"] = ["member_profiles"] + dwd_cfg["only_tables"] = ["billiards_dwd.dim_member", "billiards_dwd.dim_member_ex"] + if args.only_files: + manual_cfg["include_files"] = args.only_files + if args.only_dwd_tables: + dwd_cfg["only_tables"] = args.only_dwd_tables + + overrides: dict[str, Any] = { + "app": {"store_id": args.store_id}, + "pipeline": {"flow": "INGEST_ONLY", "ingest_source_dir": args.json_dir}, + "manual": manual_cfg, + "dwd": dwd_cfg, + # 离线回放/建仓可能耗时较长,关闭 statement_timeout,避免被默认 30s 中断。 + # 同时关闭 lock_timeout,避免 DROP/DDL 因锁等待稍久就直接失败。 + "db": {"dsn": args.dsn, "session": {"statement_timeout_ms": 0, "lock_timeout_ms": 0}}, + } + return AppConfig.load(overrides) + + +def _drop_schemas(db: DatabaseOperations, logger: logging.Logger) -> None: + """删除并重建 ETL 相关 schema(具备破坏性,请谨慎)。""" + with db.conn.cursor() as cur: + # 避免因为其他会话持锁而无限等待;若确实被占用,提示用户先释放/终止阻塞会话。 + cur.execute("SET lock_timeout TO '5s'") + for schema in ("billiards_dwd", "billiards_ods", "etl_admin"): + logger.info("DROP SCHEMA IF EXISTS %s CASCADE ...", schema) + cur.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE;') + + +def _terminate_own_idle_in_tx(db: DatabaseOperations, logger: logging.Logger) -> int: + """终止当前用户在本库中处于 idle-in-transaction 的会话,避免阻塞 DROP/DDL。""" + with db.conn.cursor() as cur: + cur.execute( + """ + SELECT pid + FROM pg_stat_activity + WHERE datname = current_database() + AND usename = current_user + AND pid <> pg_backend_pid() + AND state = 'idle in transaction' + """ + ) + pids = [r[0] for r in cur.fetchall()] + killed = 0 + for pid in pids: + cur.execute("SELECT pg_terminate_backend(%s)", (pid,)) + ok = bool(cur.fetchone()[0]) + logger.info("终止会话 pid=%s ok=%s", pid, ok) + killed += 1 if ok else 0 + return killed + + +def _run_task(task, logger: logging.Logger) -> dict: + """统一运行任务并打印关键结果。""" + result = task.execute(None) + logger.info("%s: status=%s counts=%s", task.get_task_code(), result.get("status"), result.get("counts")) + return result + + +def _basic_validate(db: DatabaseOperations, logger: logging.Logger) -> None: + """做最基础的可用性校验:schema 存在、关键表行数可查询。""" + checks = [ + ("billiards_ods", "member_profiles"), + ("billiards_ods", "settlement_records"), + ("billiards_dwd", "dim_member"), + ("billiards_dwd", "dwd_settlement_head"), + ] + for schema, table in checks: + try: + rows = db.query(f'SELECT COUNT(1) AS cnt FROM "{schema}"."{table}"') + logger.info("校验行数:%s.%s = %s", schema, table, (rows[0] or {}).get("cnt") if rows else None) + except Exception as exc: # noqa: BLE001 + logger.warning("校验失败:%s.%s(%s)", schema, table, exc) + + +def _connect_db_with_retry(cfg: AppConfig, logger: logging.Logger) -> DatabaseConnection: + """创建数据库连接(带重试),避免短暂网络抖动导致脚本直接失败。""" + dsn = cfg["db"]["dsn"] + session = cfg["db"].get("session") + connect_timeout = cfg["db"].get("connect_timeout_sec") + + backoffs = [1, 2, 4, 8, 16] + last_exc: Exception | None = None + for attempt, wait_sec in enumerate([0] + backoffs, start=1): + if wait_sec: + time.sleep(wait_sec) + try: + return DatabaseConnection(dsn=dsn, session=session, connect_timeout=connect_timeout) + except Exception as exc: # noqa: BLE001 + last_exc = exc + logger.warning("数据库连接失败(第 %s 次):%s", attempt, exc) + raise last_exc or RuntimeError("数据库连接失败") + + +def _is_connection_error(exc: Exception) -> bool: + """判断是否为连接断开/服务端异常导致的可重试错误。""" + return isinstance(exc, (psycopg2.OperationalError, psycopg2.InterfaceError)) + + +def _run_stage_with_reconnect( + cfg: AppConfig, + logger: logging.Logger, + stage_name: str, + fn, + max_attempts: int = 3, +) -> dict | None: + """ + 运行单个阶段:失败(尤其是连接断开)时自动重连并重试。 + + fn: (db_ops) -> dict | None + """ + last_exc: Exception | None = None + for attempt in range(1, max_attempts + 1): + db_conn = _connect_db_with_retry(cfg, logger) + db_ops = DatabaseOperations(db_conn) + try: + logger.info("阶段开始:%s(第 %s/%s 次)", stage_name, attempt, max_attempts) + result = fn(db_ops) + logger.info("阶段完成:%s", stage_name) + return result + except Exception as exc: # noqa: BLE001 + last_exc = exc + logger.exception("阶段失败:%s(第 %s/%s 次):%s", stage_name, attempt, max_attempts, exc) + # 连接类错误允许重试;非连接错误直接抛出,避免掩盖逻辑问题。 + if not _is_connection_error(exc): + raise + time.sleep(min(2**attempt, 10)) + finally: + try: + db_ops.close() # type: ignore[attr-defined] + except Exception: + pass + try: + db_conn.close() + except Exception: + pass + raise last_exc or RuntimeError(f"阶段失败:{stage_name}") + + +def main() -> int: + """脚本主入口:按顺序重建并跑通 ODS→DWD。""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + logger = logging.getLogger("etl_billiards.rebuild_db") + + args = _parse_args() + cfg = _build_config(args) + + # 默认启用文件日志,便于事后追溯(即便运行失败也应尽早落盘)。 + file_handler = _attach_file_logger( + log_root=cfg["io"]["log_root"], + filename=time.strftime("rebuild_db_%Y%m%d-%H%M%S.log"), + logger=logger, + ) + + try: + json_dir = Path(args.json_dir) + if not json_dir.exists(): + logger.error("示例 JSON 目录不存在:%s", json_dir) + return 2 + + def stage_drop(db_ops: DatabaseOperations): + if not args.drop_schemas: + return None + if args.terminate_own_sessions: + killed = _terminate_own_idle_in_tx(db_ops, logger) + if killed: + db_ops.commit() + _drop_schemas(db_ops, logger) + db_ops.commit() + return None + + def stage_init_ods(db_ops: DatabaseOperations): + return _run_task(InitOdsSchemaTask(cfg, db_ops, None, logger), logger) + + def stage_init_dwd(db_ops: DatabaseOperations): + return _run_task(InitDwdSchemaTask(cfg, db_ops, None, logger), logger) + + def stage_manual_ingest(db_ops: DatabaseOperations): + logger.info("开始执行:MANUAL_INGEST(json_dir=%s)", json_dir) + return _run_task(ManualIngestTask(cfg, db_ops, None, logger), logger) + + def stage_dwd_load(db_ops: DatabaseOperations): + logger.info("开始执行:DWD_LOAD_FROM_ODS") + return _run_task(DwdLoadTask(cfg, db_ops, None, logger), logger) + + _run_stage_with_reconnect(cfg, logger, "DROP_SCHEMAS", stage_drop, max_attempts=3) + if args.stop_after == "DROP_SCHEMAS": + return 0 + _run_stage_with_reconnect(cfg, logger, "INIT_ODS_SCHEMA", stage_init_ods, max_attempts=3) + if args.stop_after == "INIT_ODS_SCHEMA": + return 0 + _run_stage_with_reconnect(cfg, logger, "INIT_DWD_SCHEMA", stage_init_dwd, max_attempts=3) + if args.stop_after == "INIT_DWD_SCHEMA": + return 0 + _run_stage_with_reconnect(cfg, logger, "MANUAL_INGEST", stage_manual_ingest, max_attempts=5) + if args.stop_after == "MANUAL_INGEST": + return 0 + _run_stage_with_reconnect(cfg, logger, "DWD_LOAD_FROM_ODS", stage_dwd_load, max_attempts=5) + if args.stop_after == "DWD_LOAD_FROM_ODS": + return 0 + + # 校验阶段复用一条新连接即可 + _run_stage_with_reconnect( + cfg, + logger, + "BASIC_VALIDATE", + lambda db_ops: _basic_validate(db_ops, logger), + max_attempts=3, + ) + if args.stop_after == "BASIC_VALIDATE": + return 0 + return 0 + finally: + if file_handler is not None: + try: + logging.getLogger().removeHandler(file_handler) + except Exception: + pass + try: + file_handler.close() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/etl_billiards/scripts/reload_ods_windowed.py b/etl_billiards/scripts/reload_ods_windowed.py new file mode 100644 index 0000000..c18570d --- /dev/null +++ b/etl_billiards/scripts/reload_ods_windowed.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +""" +Reload ODS tasks by fixed time windows with optional sleep between windows. +""" +from __future__ import annotations + +import argparse +import logging +import subprocess +import sys +import time as time_mod +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from dateutil import parser as dtparser + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from config.settings import AppConfig +from utils.logging_utils import build_log_path, configure_logging + +MIN_RELOAD_WINDOW_DAYS = 30 + +def _parse_dt(value: str, tz: ZoneInfo, *, is_end: bool) -> datetime: + raw = (value or "").strip() + if not raw: + raise ValueError("empty datetime") + has_time = any(ch in raw for ch in (":", "T")) + dt = dtparser.parse(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=tz) + else: + dt = dt.astimezone(tz) + if not has_time: + dt = dt.replace(hour=23 if is_end else 0, minute=59 if is_end else 0, second=59 if is_end else 0, microsecond=0) + return dt + + +def _iter_windows(start: datetime, end: datetime, window_size: timedelta): + if window_size.total_seconds() <= 0: + raise ValueError("window_size must be > 0") + cur = start + while cur < end: + nxt = min(cur + window_size, end) + yield cur, nxt + cur = nxt + + +def _run_task_window( + task_code: str, + window_start: datetime, + window_end: datetime, + api_page_size: int, + api_timeout: int, + logger: logging.Logger, +) -> None: + cmd = [ + sys.executable, + "-m", + "cli.main", + "--pipeline-flow", + "FULL", + "--tasks", + task_code, + "--window-start", + window_start.strftime("%Y-%m-%d %H:%M:%S"), + "--window-end", + window_end.strftime("%Y-%m-%d %H:%M:%S"), + "--force-window-override", + ] + if api_page_size > 0: + cmd += ["--api-page-size", str(api_page_size)] + if api_timeout > 0: + cmd += ["--api-timeout", str(api_timeout)] + logger.info( + "RUN_TASK task=%s window_start=%s window_end=%s", + task_code, + window_start.isoformat(), + window_end.isoformat(), + ) + logger.debug("CMD %s", " ".join(cmd)) + subprocess.run(cmd, check=True, cwd=str(PROJECT_ROOT)) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Reload ODS tasks by window slices.") + ap.add_argument("--tasks", required=True, help="comma-separated ODS task codes") + ap.add_argument("--start", required=True, help="start datetime, e.g. 2025-07-01") + ap.add_argument("--end", default="", help="end datetime (default: now)") + ap.add_argument("--window-days", type=int, default=1, help="days per window (default: 1)") + ap.add_argument("--window-hours", type=int, default=0, help="hours per window (default: 0)") + ap.add_argument("--sleep-seconds", type=float, default=0, help="sleep seconds after each window") + ap.add_argument("--api-page-size", type=int, default=200, help="API page size override") + ap.add_argument("--api-timeout", type=int, default=20, help="API timeout seconds override") + ap.add_argument("--log-file", default="", help="log file path (default: logs/reload_ods_windowed_YYYYMMDD_HHMMSS.log)") + ap.add_argument("--log-dir", default="", help="log directory (default: logs)") + ap.add_argument("--log-level", default="INFO", help="log level (default: INFO)") + ap.add_argument("--no-log-console", action="store_true", help="disable console logging") + args = ap.parse_args() + + log_dir = Path(args.log_dir) if args.log_dir else (PROJECT_ROOT / "logs") + log_file = Path(args.log_file) if args.log_file else build_log_path(log_dir, "reload_ods_windowed") + log_console = not args.no_log_console + + with configure_logging( + "reload_ods_windowed", + log_file, + level=args.log_level, + console=log_console, + tee_std=True, + ) as logger: + cfg = AppConfig.load({}) + tz = ZoneInfo(cfg.get("app.timezone", "Asia/Taipei")) + + start = _parse_dt(args.start, tz, is_end=False) + end = datetime.now(tz) if not args.end else _parse_dt(args.end, tz, is_end=True) + window_days = int(args.window_days) + window_hours = int(args.window_hours) + min_hours = MIN_RELOAD_WINDOW_DAYS * 24 + if window_hours > 0: + if window_hours < min_hours: + logger.warning( + "window_hours=%s too small; adjust to %s", + window_hours, + min_hours, + ) + window_hours = min_hours + elif window_days < MIN_RELOAD_WINDOW_DAYS: + logger.warning( + "window_days=%s too small; adjust to %s", + window_days, + MIN_RELOAD_WINDOW_DAYS, + ) + window_days = MIN_RELOAD_WINDOW_DAYS + window_size = timedelta(hours=window_hours) if window_hours > 0 else timedelta(days=window_days) + + task_codes = [t.strip().upper() for t in args.tasks.split(",") if t.strip()] + if not task_codes: + raise SystemExit("no tasks specified") + + logger.info( + "START range=%s~%s window_days=%s window_hours=%s sleep=%.2f", + start.isoformat(), + end.isoformat(), + window_days, + window_hours, + args.sleep_seconds, + ) + + for task_code in task_codes: + logger.info("TASK_START task=%s", task_code) + for window_start, window_end in _iter_windows(start, end, window_size): + start_ts = time_mod.monotonic() + _run_task_window( + task_code=task_code, + window_start=window_start, + window_end=window_end, + api_page_size=args.api_page_size, + api_timeout=args.api_timeout, + logger=logger, + ) + elapsed = time_mod.monotonic() - start_ts + logger.info( + "WINDOW_DONE task=%s window_start=%s window_end=%s elapsed=%.2fs", + task_code, + window_start.isoformat(), + window_end.isoformat(), + elapsed, + ) + if args.sleep_seconds > 0: + logger.debug("SLEEP seconds=%.2f", args.sleep_seconds) + time_mod.sleep(args.sleep_seconds) + logger.info("TASK_DONE task=%s", task_code) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/etl_billiards/scripts/test_db_performance.py b/etl_billiards/scripts/test_db_performance.py new file mode 100644 index 0000000..d30e61d --- /dev/null +++ b/etl_billiards/scripts/test_db_performance.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +"""PostgreSQL connection performance test (ASCII-only output).""" +from __future__ import annotations + +import argparse +import math +import os +import statistics +import sys +import time +from typing import Dict, Iterable, List + +from psycopg2.extensions import make_dsn, parse_dsn + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +from database.connection import DatabaseConnection + + +def _load_env() -> Dict[str, str]: + env: Dict[str, str] = {} + try: + from config.env_parser import _load_dotenv_values + except Exception: + _load_dotenv_values = None + if _load_dotenv_values: + try: + env.update(_load_dotenv_values()) + except Exception: + pass + env.update(os.environ) + return env + + +def _apply_dsn_overrides(dsn: str, host: str | None, port: int | None) -> str: + overrides = {} + if host: + overrides["host"] = host + if port: + overrides["port"] = str(port) + if not overrides: + return dsn + return make_dsn(dsn, **overrides) + + +def _build_dsn_from_env( + host: str, + port: int, + user: str | None, + password: str | None, + dbname: str | None, +) -> str | None: + if not user or not dbname: + return None + params = { + "host": host, + "port": str(port), + "user": user, + "dbname": dbname, + } + if password: + params["password"] = password + return make_dsn("", **params) + + +def _safe_dsn_summary(dsn: str, host: str | None, port: int | None) -> str: + try: + info = parse_dsn(dsn) + except Exception: + info = {} + if host: + info["host"] = host + if port: + info["port"] = str(port) + info.pop("password", None) + if not info: + return "dsn=(hidden)" + items = " ".join(f"{k}={info[k]}" for k in sorted(info.keys())) + return items + + +def _percentile(values: List[float], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + rank = (len(ordered) - 1) * (pct / 100.0) + low = int(math.floor(rank)) + high = int(math.ceil(rank)) + if low == high: + return ordered[low] + return ordered[low] + (ordered[high] - ordered[low]) * (rank - low) + + +def _format_stats(label: str, values: Iterable[float]) -> str: + data = list(values) + if not data: + return f"{label}: no samples" + avg = statistics.mean(data) + stdev = statistics.stdev(data) if len(data) > 1 else 0.0 + return ( + f"{label}: count={len(data)} " + f"min={min(data):.2f}ms avg={avg:.2f}ms " + f"p50={_percentile(data, 50):.2f}ms " + f"p95={_percentile(data, 95):.2f}ms " + f"max={max(data):.2f}ms stdev={stdev:.2f}ms" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="PostgreSQL connection performance test") + parser.add_argument("--dsn", help="Override PG_DSN/TEST_DB_DSN/.env value") + parser.add_argument( + "--host", + default="100.64.0.4", + help="Override host in DSN (default: 100.64.0.4)", + ) + parser.add_argument("--port", type=int, help="Override port in DSN") + parser.add_argument("--user", help="User when building DSN from PG_* env") + parser.add_argument("--password", help="Password when building DSN from PG_* env") + parser.add_argument("--dbname", help="Database name when building DSN from PG_* env") + parser.add_argument("--rounds", type=int, default=20, help="Measured connection rounds") + parser.add_argument("--warmup", type=int, default=2, help="Warmup rounds (not recorded)") + parser.add_argument("--query", default="SELECT 1", help="SQL to run after connect") + parser.add_argument( + "--query-repeat", + type=int, + default=1, + help="Query repetitions per connection (0 to skip)", + ) + parser.add_argument( + "--connect-timeout", + type=int, + default=10, + help="connect_timeout seconds (capped at 20, default: 10)", + ) + parser.add_argument( + "--statement-timeout-ms", + type=int, + help="Optional statement_timeout applied per connection", + ) + parser.add_argument( + "--sleep-ms", + type=int, + default=0, + help="Sleep between rounds in milliseconds", + ) + parser.add_argument( + "--continue-on-error", + action="store_true", + help="Continue even if a round fails", + ) + parser.add_argument("--verbose", action="store_true", help="Print per-round timings") + return parser.parse_args() + + +def _run_round( + dsn: str, + timeout: int, + query: str, + query_repeat: int, + session: Dict[str, int] | None, +) -> tuple[float, List[float]]: + start = time.perf_counter() + conn = DatabaseConnection(dsn, connect_timeout=timeout, session=session) + connect_ms = (time.perf_counter() - start) * 1000.0 + query_times: List[float] = [] + try: + for _ in range(query_repeat): + q_start = time.perf_counter() + conn.query(query) + query_times.append((time.perf_counter() - q_start) * 1000.0) + return connect_ms, query_times + finally: + try: + conn.rollback() + except Exception: + pass + conn.close() + + +def main() -> int: + args = parse_args() + if args.rounds < 0 or args.warmup < 0 or args.query_repeat < 0: + print("rounds/warmup/query-repeat must be >= 0", file=sys.stderr) + return 2 + env = _load_env() + + dsn = args.dsn or env.get("PG_DSN") or env.get("TEST_DB_DSN") + host = args.host + port = args.port + + if not dsn: + user = args.user or env.get("PG_USER") + password = args.password if args.password is not None else env.get("PG_PASSWORD") + dbname = args.dbname or env.get("PG_NAME") + try: + resolved_port = port or int(env.get("PG_PORT", "5432")) + except ValueError: + resolved_port = port or 5432 + dsn = _build_dsn_from_env(host, resolved_port, user, password, dbname) + if not dsn: + print( + "Missing DSN. Provide --dsn or set PG_DSN/TEST_DB_DSN, or PG_USER + PG_NAME.", + file=sys.stderr, + ) + return 2 + dsn = _apply_dsn_overrides(dsn, host, port) + + timeout = max(1, min(int(args.connect_timeout), 20)) + session = None + if args.statement_timeout_ms is not None: + session = {"statement_timeout_ms": int(args.statement_timeout_ms)} + + print("Target:", _safe_dsn_summary(dsn, host, port)) + print( + f"Rounds: {args.rounds} (warmup {args.warmup}), " + f"query_repeat={args.query_repeat}, timeout={timeout}s" + ) + if args.query_repeat > 0: + print("Query:", args.query) + + connect_times: List[float] = [] + query_times: List[float] = [] + failures: List[str] = [] + + total = args.warmup + args.rounds + for idx in range(total): + is_warmup = idx < args.warmup + try: + c_ms, q_times = _run_round( + dsn, timeout, args.query, args.query_repeat, session + ) + if not is_warmup: + connect_times.append(c_ms) + query_times.extend(q_times) + if args.verbose: + tag = "warmup" if is_warmup else "sample" + q_msg = "" + if args.query_repeat > 0: + q_avg = statistics.mean(q_times) if q_times else 0.0 + q_msg = f", query_avg={q_avg:.2f}ms" + print(f"[{tag} {idx + 1}/{total}] connect={c_ms:.2f}ms{q_msg}") + except Exception as exc: + msg = f"round {idx + 1}: {exc}" + failures.append(msg) + print("Failure:", msg, file=sys.stderr) + if not args.continue_on_error: + break + if args.sleep_ms > 0: + time.sleep(args.sleep_ms / 1000.0) + + if connect_times: + print(_format_stats("Connect", connect_times)) + if args.query_repeat > 0: + print(_format_stats("Query", query_times)) + if failures: + print(f"Failures: {len(failures)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/etl_billiards/tasks/base_task.py b/etl_billiards/tasks/base_task.py index a785528..592def8 100644 --- a/etl_billiards/tasks/base_task.py +++ b/etl_billiards/tasks/base_task.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta from zoneinfo import ZoneInfo +from dateutil import parser as dtparser @dataclass(frozen=True) @@ -92,6 +93,36 @@ class BaseTask: """计算时间窗口""" now = datetime.now(self.tz) + override_start = self.config.get("run.window_override.start") + override_end = self.config.get("run.window_override.end") + if override_start or override_end: + if not (override_start and override_end): + raise ValueError("run.window_override.start/end 需要同时提供") + + window_start = override_start + if isinstance(window_start, str): + window_start = dtparser.parse(window_start) + if isinstance(window_start, datetime) and window_start.tzinfo is None: + window_start = window_start.replace(tzinfo=self.tz) + elif isinstance(window_start, datetime): + window_start = window_start.astimezone(self.tz) + + window_end = override_end + if isinstance(window_end, str): + window_end = dtparser.parse(window_end) + if isinstance(window_end, datetime) and window_end.tzinfo is None: + window_end = window_end.replace(tzinfo=self.tz) + elif isinstance(window_end, datetime): + window_end = window_end.astimezone(self.tz) + + if not isinstance(window_start, datetime) or not isinstance(window_end, datetime): + raise ValueError("run.window_override.start/end 解析失败") + if window_end <= window_start: + raise ValueError("run.window_override.end 必须大于 start") + + window_minutes = max(1, int((window_end - window_start).total_seconds() // 60)) + return window_start, window_end, window_minutes + 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) diff --git a/etl_billiards/tasks/check_cutoff_task.py b/etl_billiards/tasks/check_cutoff_task.py new file mode 100644 index 0000000..e9f1961 --- /dev/null +++ b/etl_billiards/tasks/check_cutoff_task.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +"""Task: report last successful cursor cutoff times from etl_admin.""" + +from __future__ import annotations + +from typing import Any + +from .base_task import BaseTask + + +class CheckCutoffTask(BaseTask): + """Report per-task cursor cutoff times (etl_admin.etl_cursor.last_end).""" + + def get_task_code(self) -> str: + return "CHECK_CUTOFF" + + def execute(self, cursor_data: dict | None = None) -> dict: + store_id = int(self.config.get("app.store_id")) + filter_codes = self.config.get("run.cutoff_task_codes") or None + if isinstance(filter_codes, str): + filter_codes = [c.strip().upper() for c in filter_codes.split(",") if c.strip()] + + sql = """ + SELECT + t.task_code, + c.last_start, + c.last_end, + c.last_id, + c.last_run_id, + c.updated_at + FROM etl_admin.etl_task t + LEFT JOIN etl_admin.etl_cursor c + ON c.task_id = t.task_id AND c.store_id = t.store_id + WHERE t.store_id = %s + AND t.enabled = TRUE + ORDER BY t.task_code + """ + rows = self.db.query(sql, (store_id,)) + + if filter_codes: + wanted = {str(c).upper() for c in filter_codes} + rows = [r for r in rows if str(r.get("task_code", "")).upper() in wanted] + + def _ts(v: Any) -> str: + return "-" if not v else str(v) + + self.logger.info("CHECK_CUTOFF: store_id=%s enabled_tasks=%s", store_id, len(rows)) + for r in rows: + self.logger.info( + "CHECK_CUTOFF: %-24s last_end=%s last_start=%s last_run_id=%s", + str(r.get("task_code") or ""), + _ts(r.get("last_end")), + _ts(r.get("last_start")), + _ts(r.get("last_run_id")), + ) + + cutoff_candidates = [ + r.get("last_end") + for r in rows + if r.get("last_end") is not None and not str(r.get("task_code", "")).upper().startswith("INIT_") + ] + cutoff = min(cutoff_candidates) if cutoff_candidates else None + self.logger.info("CHECK_CUTOFF: overall_cutoff(min last_end, excl INIT_*)=%s", _ts(cutoff)) + + ods_fetched = self._probe_ods_fetched_at(store_id) + if ods_fetched: + non_null = [v["max_fetched_at"] for v in ods_fetched.values() if v.get("max_fetched_at") is not None] + ods_cutoff = min(non_null) if non_null else None + self.logger.info("CHECK_CUTOFF: ODS cutoff(min MAX(fetched_at))=%s", _ts(ods_cutoff)) + worst = sorted( + ((k, v.get("max_fetched_at")) for k, v in ods_fetched.items()), + key=lambda kv: (kv[1] is None, kv[1]), + )[:8] + for table, mx in worst: + self.logger.info("CHECK_CUTOFF: ODS table=%s max_fetched_at=%s", table, _ts(mx)) + + dw_checks = self._probe_dw_time_columns() + for name, value in dw_checks.items(): + self.logger.info("CHECK_CUTOFF: %s=%s", name, _ts(value)) + + return { + "status": "SUCCESS", + "counts": {"fetched": len(rows), "inserted": 0, "updated": 0, "skipped": 0, "errors": 0}, + "window": None, + "request_params": {"store_id": store_id, "filter_task_codes": filter_codes or []}, + "report": { + "rows": rows, + "overall_cutoff": cutoff, + "ods_fetched_at": ods_fetched, + "dw_max_times": dw_checks, + }, + } + + def _probe_ods_fetched_at(self, store_id: int) -> dict[str, dict[str, Any]]: + try: + from tasks.dwd_load_task import DwdLoadTask # local import to avoid circulars + except Exception: + return {} + + ods_tables = sorted({str(t) for t in DwdLoadTask.TABLE_MAP.values() if str(t).startswith("billiards_ods.")}) + results: dict[str, dict[str, Any]] = {} + for table in ods_tables: + try: + row = self.db.query(f"SELECT MAX(fetched_at) AS mx, COUNT(*) AS cnt FROM {table}")[0] + results[table] = {"max_fetched_at": row.get("mx"), "count": row.get("cnt")} + except Exception as exc: # noqa: BLE001 + results[table] = {"max_fetched_at": None, "count": None, "error": str(exc)} + return results + + def _probe_dw_time_columns(self) -> dict[str, Any]: + checks: dict[str, Any] = {} + probes = { + "DWD.max_settlement_pay_time": "SELECT MAX(pay_time) AS mx FROM billiards_dwd.dwd_settlement_head", + "DWD.max_payment_pay_time": "SELECT MAX(pay_time) AS mx FROM billiards_dwd.dwd_payment", + "DWD.max_refund_pay_time": "SELECT MAX(pay_time) AS mx FROM billiards_dwd.dwd_refund", + "DWS.max_order_date": "SELECT MAX(order_date) AS mx FROM billiards_dws.dws_order_summary", + "DWS.max_updated_at": "SELECT MAX(updated_at) AS mx FROM billiards_dws.dws_order_summary", + } + for name, sql2 in probes.items(): + try: + row = self.db.query(sql2)[0] + checks[name] = row.get("mx") + except Exception as exc: # noqa: BLE001 + checks[name] = f"ERROR: {exc}" + return checks diff --git a/etl_billiards/tasks/dwd_load_task.py b/etl_billiards/tasks/dwd_load_task.py index a546e0e..5f1265f 100644 --- a/etl_billiards/tasks/dwd_load_task.py +++ b/etl_billiards/tasks/dwd_load_task.py @@ -2,10 +2,11 @@ """DWD 装载任务:从 ODS 增量写入 DWD(维度 SCD2,事实按时间增量)。""" from __future__ import annotations +import time from datetime import datetime from typing import Any, Dict, Iterable, List, Sequence -from psycopg2.extras import RealDictCursor +from psycopg2.extras import RealDictCursor, execute_batch, execute_values from .base_task import BaseTask, TaskContext @@ -61,14 +62,15 @@ class DwdLoadTask(BaseTask): } SCD_COLS = {"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version"} + # 增量/窗口过滤优先使用业务时间;fetched_at(入库时间)放最后,避免回溯窗口被“当前入库时间”干扰。 FACT_ORDER_CANDIDATES = [ - "fetched_at", "pay_time", "create_time", "update_time", "occur_time", "settle_time", "start_use_time", + "fetched_at", ] # 特殊列映射:dwd 列名 -> 源列表达式(可选 CAST) @@ -457,30 +459,69 @@ class DwdLoadTask(BaseTask): return {"now": datetime.now()} def load(self, extracted: dict[str, Any], context: TaskContext) -> dict[str, Any]: - """遍历映射关系,维度执行 SCD2 合并,事实表按时间增量插入。""" + """ + 遍历映射关系,维度执行 SCD2 合并,事实表按时间增量插入。 + + 说明: + - 为避免长事务导致锁堆积/中断后遗留 idle-in-tx,本任务按“每张表一次事务”提交; + - 单表失败会回滚该表并继续后续表,最终在结果中汇总错误信息。 + """ now = extracted["now"] summary: List[Dict[str, Any]] = [] + errors: List[Dict[str, Any]] = [] + only_tables_cfg = self.config.get("dwd.only_tables") or [] + only_tables = {str(t).strip().lower() for t in only_tables_cfg if str(t).strip()} if only_tables_cfg else set() with self.db.conn.cursor(cursor_factory=RealDictCursor) as cur: for dwd_table, ods_table in self.TABLE_MAP.items(): - dwd_cols = self._get_columns(cur, dwd_table) - ods_cols = self._get_columns(cur, ods_table) - if not dwd_cols: - self.logger.warning("跳过 %s,未能获取 DWD 列信息", dwd_table) + if only_tables and dwd_table.lower() not in only_tables and self._table_base(dwd_table).lower() not in only_tables: + continue + started = time.monotonic() + self.logger.info("DWD 装载开始:%s <= %s", dwd_table, ods_table) + try: + dwd_cols = self._get_columns(cur, dwd_table) + ods_cols = self._get_columns(cur, ods_table) + if not dwd_cols: + self.logger.warning("跳过 %s:未能获取 DWD 列信息", dwd_table) + continue + + if self._table_base(dwd_table).startswith("dim_"): + processed = self._merge_dim(cur, dwd_table, ods_table, dwd_cols, ods_cols, now) + self.db.conn.commit() + summary.append({"table": dwd_table, "mode": "SCD2", "processed": processed}) + else: + dwd_types = self._get_column_types(cur, dwd_table, "billiards_dwd") + ods_types = self._get_column_types(cur, ods_table, "billiards_ods") + use_window = bool( + self.config.get("run.window_override.start") + and self.config.get("run.window_override.end") + ) + inserted = self._merge_fact_increment( + cur, + dwd_table, + ods_table, + dwd_cols, + ods_cols, + dwd_types, + ods_types, + window_start=context.window_start if use_window else None, + window_end=context.window_end if use_window else None, + ) + self.db.conn.commit() + summary.append({"table": dwd_table, "mode": "INCREMENT", "inserted": inserted}) + + elapsed = time.monotonic() - started + self.logger.info("DWD 装载完成:%s,用时 %.2fs", dwd_table, elapsed) + except Exception as exc: # noqa: BLE001 + try: + self.db.conn.rollback() + except Exception: + pass + elapsed = time.monotonic() - started + self.logger.exception("DWD 装载失败:%s,用时 %.2fs,err=%s", dwd_table, elapsed, exc) + errors.append({"table": dwd_table, "error": str(exc)}) continue - if self._table_base(dwd_table).startswith("dim_"): - processed = self._merge_dim_scd2(cur, dwd_table, ods_table, dwd_cols, ods_cols, now) - summary.append({"table": dwd_table, "mode": "SCD2", "processed": processed}) - else: - dwd_types = self._get_column_types(cur, dwd_table, "billiards_dwd") - ods_types = self._get_column_types(cur, ods_table, "billiards_ods") - inserted = self._merge_fact_increment( - cur, dwd_table, ods_table, dwd_cols, ods_cols, dwd_types, ods_types - ) - summary.append({"table": dwd_table, "mode": "INCREMENT", "inserted": inserted}) - - self.db.conn.commit() - return {"tables": summary} + return {"tables": summary, "errors": errors} # ---------------------- helpers ---------------------- def _get_columns(self, cur, table: str) -> List[str]: @@ -589,6 +630,135 @@ class DwdLoadTask(BaseTask): expanded.append(child_row) return expanded + def _merge_dim( + self, + cur, + dwd_table: str, + ods_table: str, + dwd_cols: Sequence[str], + ods_cols: Sequence[str], + now: datetime, + ) -> int: + """ + 维表合并策略: + - 若主键包含 scd2 列(如 scd2_start_time/scd2_version),执行真正的 SCD2(关闭旧版+插入新版)。 + - 否则(多数现有表主键仅为业务主键),执行 Type1 Upsert,避免重复键异常并保证可重复回放。 + """ + pk_cols = self._get_primary_keys(cur, dwd_table) + if not pk_cols: + raise ValueError(f"{dwd_table} 未配置主键,无法执行维表合并") + + pk_has_scd = any(pk.lower() in self.SCD_COLS for pk in pk_cols) + scd_cols_present = any(c.lower() in self.SCD_COLS for c in dwd_cols) + if scd_cols_present and pk_has_scd: + return self._merge_dim_scd2(cur, dwd_table, ods_table, dwd_cols, ods_cols, now) + return self._merge_dim_type1_upsert(cur, dwd_table, ods_table, dwd_cols, ods_cols, pk_cols, now) + + def _merge_dim_type1_upsert( + self, + cur, + dwd_table: str, + ods_table: str, + dwd_cols: Sequence[str], + ods_cols: Sequence[str], + pk_cols: Sequence[str], + now: datetime, + ) -> int: + """维表 Type1 Upsert(主键冲突则更新),兼容带 scd2 字段但主键不支持多版本的表。""" + mapping = self._build_column_mapping(dwd_table, pk_cols, ods_cols) + ods_set = {c.lower() for c in ods_cols} + ods_table_sql = self._format_table(ods_table, "billiards_ods") + + select_exprs: list[str] = [] + added: set[str] = set() + for col in dwd_cols: + lc = col.lower() + if lc in self.SCD_COLS: + continue + if lc in mapping: + src, cast_type = mapping[lc] + select_exprs.append(f"{self._cast_expr(src, cast_type)} AS \"{lc}\"") + added.add(lc) + elif lc in ods_set: + select_exprs.append(f'\"{lc}\" AS \"{lc}\"') + added.add(lc) + + for pk in pk_cols: + lc = pk.lower() + if lc in added: + continue + if lc in mapping: + src, cast_type = mapping[lc] + select_exprs.append(f"{self._cast_expr(src, cast_type)} AS \"{lc}\"") + elif lc in ods_set: + select_exprs.append(f'\"{lc}\" AS \"{lc}\"') + added.add(lc) + + if not select_exprs: + return 0 + + cur.execute(f"SELECT {', '.join(select_exprs)} FROM {ods_table_sql}") + rows = [{k.lower(): v for k, v in r.items()} for r in cur.fetchall()] + + if dwd_table == "billiards_dwd.dim_goods_category": + rows = self._expand_goods_category_rows(rows) + + # 按主键去重 + seen_pk: set[tuple[Any, ...]] = set() + src_rows: list[Dict[str, Any]] = [] + pk_lower = [c.lower() for c in pk_cols] + for row in rows: + pk_key = tuple(row.get(pk) for pk in pk_lower) + if pk_key in seen_pk: + continue + if any(v is None for v in pk_key): + self.logger.warning("跳过 %s:主键缺失 %s", dwd_table, dict(zip(pk_cols, pk_key))) + continue + seen_pk.add(pk_key) + src_rows.append(row) + + if not src_rows: + return 0 + + dwd_table_sql = self._format_table(dwd_table, "billiards_dwd") + sorted_cols = [c.lower() for c in sorted(dwd_cols)] + insert_cols_sql = ", ".join(f'\"{c}\"' for c in sorted_cols) + + def build_row(src_row: Dict[str, Any]) -> list[Any]: + values: list[Any] = [] + for c in sorted_cols: + if c == "scd2_start_time": + values.append(now) + elif c == "scd2_end_time": + values.append(datetime(9999, 12, 31, 0, 0, 0)) + elif c == "scd2_is_current": + values.append(1) + elif c == "scd2_version": + values.append(1) + else: + values.append(src_row.get(c)) + return values + + pk_sql = ", ".join(f'\"{c.lower()}\"' for c in pk_cols) + pk_lower_set = {c.lower() for c in pk_cols} + set_exprs: list[str] = [] + for c in sorted_cols: + if c in pk_lower_set: + continue + if c == "scd2_start_time": + set_exprs.append(f'\"{c}\" = COALESCE({dwd_table_sql}.\"{c}\", EXCLUDED.\"{c}\")') + elif c == "scd2_version": + set_exprs.append(f'\"{c}\" = COALESCE({dwd_table_sql}.\"{c}\", EXCLUDED.\"{c}\")') + else: + set_exprs.append(f'\"{c}\" = EXCLUDED.\"{c}\"') + + upsert_sql = ( + f"INSERT INTO {dwd_table_sql} ({insert_cols_sql}) VALUES %s " + f"ON CONFLICT ({pk_sql}) DO UPDATE SET {', '.join(set_exprs)}" + ) + execute_values(cur, upsert_sql, [build_row(r) for r in src_rows], page_size=500) + return len(src_rows) + def _merge_dim_scd2( self, cur, @@ -646,8 +816,9 @@ class DwdLoadTask(BaseTask): if dwd_table == "billiards_dwd.dim_goods_category": rows = self._expand_goods_category_rows(rows) - inserted_or_updated = 0 + # 归一化源行并按主键去重 seen_pk = set() + src_rows_by_pk: dict[tuple[Any, ...], Dict[str, Any]] = {} for row in rows: mapped_row: Dict[str, Any] = {} for col in dwd_cols: @@ -663,10 +834,110 @@ class DwdLoadTask(BaseTask): pk_key = tuple(mapped_row.get(pk) for pk in pk_cols) if pk_key in seen_pk: continue + if any(v is None for v in pk_key): + self.logger.warning("跳过 %s:主键缺失 %s", dwd_table, dict(zip(pk_cols, pk_key))) + continue seen_pk.add(pk_key) - if self._upsert_scd2_row(cur, dwd_table, dwd_cols, pk_cols, mapped_row, now): - inserted_or_updated += 1 - return len(rows) + src_rows_by_pk[pk_key] = mapped_row + + if not src_rows_by_pk: + return 0 + + # 预加载当前版本(scd2_is_current=1),避免逐行 SELECT 造成大量 round-trip + table_sql_dwd = self._format_table(dwd_table, "billiards_dwd") + where_current = " AND ".join([f"COALESCE(scd2_is_current,1)=1"]) + cur.execute(f"SELECT * FROM {table_sql_dwd} WHERE {where_current}") + current_rows = cur.fetchall() or [] + current_by_pk: dict[tuple[Any, ...], Dict[str, Any]] = {} + for r in current_rows: + rr = {k.lower(): v for k, v in r.items()} + pk_key = tuple(rr.get(pk) for pk in pk_cols) + current_by_pk[pk_key] = rr + + # 计算需要关闭/插入的主键集合 + to_close: list[tuple[Any, ...]] = [] + to_insert: list[tuple[Dict[str, Any], int]] = [] + for pk_key, incoming in src_rows_by_pk.items(): + current = current_by_pk.get(pk_key) + if current and not self._is_row_changed(current, incoming, dwd_cols): + continue + if current: + version = (current.get("scd2_version") or 1) + 1 + to_close.append(pk_key) + else: + version = 1 + to_insert.append((incoming, version)) + + # 先关闭旧版本(同一批次统一 end_time) + if to_close: + self._close_current_dim_bulk(cur, dwd_table, pk_cols, to_close, now) + + # 批量插入新版本 + if to_insert: + self._insert_dim_rows_bulk(cur, dwd_table, dwd_cols, to_insert, now) + + return len(src_rows_by_pk) + + def _close_current_dim_bulk( + self, + cur, + table: str, + pk_cols: Sequence[str], + pk_keys: Sequence[tuple[Any, ...]], + now: datetime, + ) -> None: + """批量关闭当前版本(scd2_is_current=0 + 填充结束时间)。""" + table_sql = self._format_table(table, "billiards_dwd") + if len(pk_cols) == 1: + pk = pk_cols[0] + ids = [k[0] for k in pk_keys] + cur.execute( + f'UPDATE {table_sql} SET scd2_end_time=%s, scd2_is_current=0 ' + f'WHERE COALESCE(scd2_is_current,1)=1 AND "{pk}" = ANY(%s)', + (now, ids), + ) + return + + # 复合主键:对“发生变更的键”逐条关闭(数量通常远小于全量行数) + where_clause = " AND ".join(f'"{pk}" = %s' for pk in pk_cols) + sql = ( + f"UPDATE {table_sql} SET scd2_end_time=%s, scd2_is_current=0 " + f"WHERE COALESCE(scd2_is_current,1)=1 AND {where_clause}" + ) + args_list = [(now, *pk_key) for pk_key in pk_keys] + execute_batch(cur, sql, args_list, page_size=500) + + def _insert_dim_rows_bulk( + self, + cur, + table: str, + dwd_cols: Sequence[str], + rows_with_version: Sequence[tuple[Dict[str, Any], int]], + now: datetime, + ) -> None: + """批量插入新的 SCD2 版本行。""" + sorted_cols = [c.lower() for c in sorted(dwd_cols)] + insert_cols_sql = ", ".join(f'"{c}"' for c in sorted_cols) + table_sql = self._format_table(table, "billiards_dwd") + + def build_row(src_row: Dict[str, Any], version: int) -> list[Any]: + values: list[Any] = [] + for c in sorted_cols: + if c == "scd2_start_time": + values.append(now) + elif c == "scd2_end_time": + values.append(datetime(9999, 12, 31, 0, 0, 0)) + elif c == "scd2_is_current": + values.append(1) + elif c == "scd2_version": + values.append(version) + else: + values.append(src_row.get(c)) + return values + + values_rows = [build_row(r, ver) for r, ver in rows_with_version] + insert_sql = f"INSERT INTO {table_sql} ({insert_cols_sql}) VALUES %s" + execute_values(cur, insert_sql, values_rows, page_size=500) def _upsert_scd2_row( self, @@ -762,6 +1033,8 @@ class DwdLoadTask(BaseTask): ods_cols: Sequence[str], dwd_types: Dict[str, str], ods_types: Dict[str, str], + window_start: datetime | None = None, + window_end: datetime | None = None, ) -> int: """事实表按时间增量插入,默认按列名交集写入。""" mapping_entries = self.FACT_MAPPINGS.get(dwd_table) or [] @@ -813,7 +1086,10 @@ class DwdLoadTask(BaseTask): params: List[Any] = [] dwd_table_sql = self._format_table(dwd_table, "billiards_dwd") ods_table_sql = self._format_table(ods_table, "billiards_ods") - if order_col: + if order_col and window_start and window_end: + where_sql = f'WHERE "{order_col}" >= %s AND "{order_col}" < %s' + params.extend([window_start, window_end]) + elif order_col: cur.execute(f'SELECT COALESCE(MAX("{order_col}"), %s) FROM {dwd_table_sql}', ("1970-01-01",)) row = cur.fetchone() or {} watermark = list(row.values())[0] if row else "1970-01-01" diff --git a/etl_billiards/tasks/dws_build_order_summary_task.py b/etl_billiards/tasks/dws_build_order_summary_task.py new file mode 100644 index 0000000..9b62dc2 --- /dev/null +++ b/etl_billiards/tasks/dws_build_order_summary_task.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""Build DWS order summary table from DWD fact tables.""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from .base_task import BaseTask, TaskContext +from scripts.build_dws_order_summary import SQL_BUILD_SUMMARY + + +class DwsBuildOrderSummaryTask(BaseTask): + """Recompute/refresh `billiards_dws.dws_order_summary` for a date window.""" + + def get_task_code(self) -> str: + return "DWS_BUILD_ORDER_SUMMARY" + + def execute(self, cursor_data: dict | None = None) -> dict: + context = self._build_context(cursor_data) + task_code = self.get_task_code() + self.logger.info( + "%s: start, window[%s ~ %s]", + task_code, + context.window_start, + context.window_end, + ) + + try: + extracted = self.extract(context) + transformed = self.transform(extracted, context) + load_result = self.load(transformed, context) or {} + self.db.commit() + except Exception: + self.db.rollback() + self.logger.error("%s: failed", task_code, exc_info=True) + raise + + counts = load_result.get("counts") or {} + result = {"status": "SUCCESS", "counts": counts} + result["window"] = { + "start": context.window_start, + "end": context.window_end, + "minutes": context.window_minutes, + } + if "request_params" in load_result: + result["request_params"] = load_result["request_params"] + if "extra" in load_result: + result["extra"] = load_result["extra"] + self.logger.info("%s: done, counts=%s", task_code, counts) + return result + + def extract(self, context: TaskContext) -> dict[str, Any]: + store_id = int(self.config.get("app.store_id")) + + full_refresh = bool(self.config.get("dws.order_summary.full_refresh", False)) + site_id = self.config.get("dws.order_summary.site_id", store_id) + if site_id in ("", None, "null", "NULL"): + site_id = None + + start_date = self.config.get("dws.order_summary.start_date") + end_date = self.config.get("dws.order_summary.end_date") + if not full_refresh: + if not start_date: + start_date = context.window_start.date() + if not end_date: + end_date = context.window_end.date() + else: + start_date = None + end_date = None + + delete_before_insert = bool(self.config.get("dws.order_summary.delete_before_insert", True)) + return { + "site_id": site_id, + "start_date": start_date, + "end_date": end_date, + "full_refresh": full_refresh, + "delete_before_insert": delete_before_insert, + } + + def load(self, extracted: dict[str, Any], context: TaskContext) -> dict: + sql_params = { + "site_id": extracted["site_id"], + "start_date": extracted["start_date"], + "end_date": extracted["end_date"], + } + request_params = { + "site_id": extracted["site_id"], + "start_date": _jsonable_date(extracted["start_date"]), + "end_date": _jsonable_date(extracted["end_date"]), + } + + with self.db.conn.cursor() as cur: + cur.execute("SELECT to_regclass('billiards_dws.dws_order_summary') AS reg;") + row = cur.fetchone() + reg = row[0] if row else None + if not reg: + raise RuntimeError("DWS 表不存在:请先运行任务 INIT_DWS_SCHEMA") + + deleted = 0 + if extracted["delete_before_insert"]: + if extracted["full_refresh"] and extracted["site_id"] is None: + cur.execute("TRUNCATE TABLE billiards_dws.dws_order_summary;") + self.logger.info("DWS_BUILD_ORDER_SUMMARY: truncated billiards_dws.dws_order_summary") + else: + delete_sql = "DELETE FROM billiards_dws.dws_order_summary WHERE 1=1" + delete_args: list[Any] = [] + if extracted["site_id"] is not None: + delete_sql += " AND site_id = %s" + delete_args.append(extracted["site_id"]) + if extracted["start_date"] is not None: + delete_sql += " AND order_date >= %s" + delete_args.append(_as_date(extracted["start_date"])) + if extracted["end_date"] is not None: + delete_sql += " AND order_date <= %s" + delete_args.append(_as_date(extracted["end_date"])) + cur.execute(delete_sql, delete_args) + deleted = cur.rowcount + self.logger.info("DWS_BUILD_ORDER_SUMMARY: deleted=%s sql=%s", deleted, delete_sql) + + cur.execute(SQL_BUILD_SUMMARY, sql_params) + affected = cur.rowcount + + return { + "counts": {"fetched": 0, "inserted": affected, "updated": 0, "skipped": 0, "errors": 0}, + "request_params": request_params, + "extra": {"deleted": deleted}, + } + + +def _as_date(v: Any) -> date: + if isinstance(v, date): + return v + return date.fromisoformat(str(v)) + + +def _jsonable_date(v: Any): + if v is None: + return None + if isinstance(v, date): + return v.isoformat() + return str(v) diff --git a/etl_billiards/tasks/init_dws_schema_task.py b/etl_billiards/tasks/init_dws_schema_task.py new file mode 100644 index 0000000..8da483a --- /dev/null +++ b/etl_billiards/tasks/init_dws_schema_task.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Initialize DWS schema (billiards_dws).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .base_task import BaseTask, TaskContext + + +class InitDwsSchemaTask(BaseTask): + """Apply DWS schema SQL.""" + + def get_task_code(self) -> str: + return "INIT_DWS_SCHEMA" + + def extract(self, context: TaskContext) -> dict[str, Any]: + base_dir = Path(__file__).resolve().parents[1] / "database" + dws_path = Path(self.config.get("schema.dws_file", base_dir / "schema_dws.sql")) + if not dws_path.exists(): + raise FileNotFoundError(f"未找到 DWS schema 文件: {dws_path}") + drop_first = bool(self.config.get("dws.drop_schema_first", False)) + return {"dws_sql": dws_path.read_text(encoding="utf-8"), "dws_file": str(dws_path), "drop_first": drop_first} + + def load(self, extracted: dict[str, Any], context: TaskContext) -> dict: + with self.db.conn.cursor() as cur: + if extracted["drop_first"]: + cur.execute("DROP SCHEMA IF EXISTS billiards_dws CASCADE;") + self.logger.info("已执行 DROP SCHEMA billiards_dws CASCADE") + self.logger.info("执行 DWS schema 文件: %s", extracted["dws_file"]) + cur.execute(extracted["dws_sql"]) + return {"executed": 1, "files": [extracted["dws_file"]]} + diff --git a/etl_billiards/tasks/manual_ingest_task.py b/etl_billiards/tasks/manual_ingest_task.py index 14ed8ae..f55b300 100644 --- a/etl_billiards/tasks/manual_ingest_task.py +++ b/etl_billiards/tasks/manual_ingest_task.py @@ -7,7 +7,7 @@ import os from datetime import datetime from typing import Any, Iterable -from psycopg2.extras import Json +from psycopg2.extras import Json, execute_values from .base_task import BaseTask @@ -75,7 +75,7 @@ class ManualIngestTask(BaseTask): return "MANUAL_INGEST" def execute(self, cursor_data: dict | None = None) -> dict: - """从目录读取 JSON,按表定义批量入库。""" + """从目录读取 JSON,按表定义批量入库(按文件提交事务,避免长事务导致连接不稳定)。""" data_dir = ( self.config.get("manual.data_dir") or self.config.get("pipeline.ingest_source_dir") @@ -87,9 +87,15 @@ class ManualIngestTask(BaseTask): counts = {"fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "errors": 0} + include_files_cfg = self.config.get("manual.include_files") or [] + include_files = {str(x).strip().lower() for x in include_files_cfg if str(x).strip()} if include_files_cfg else set() + for filename in sorted(os.listdir(data_dir)): if not filename.endswith(".json"): continue + stem = os.path.splitext(filename)[0].lower() + if include_files and stem not in include_files: + continue filepath = os.path.join(data_dir, filename) try: with open(filepath, "r", encoding="utf-8") as fh: @@ -113,22 +119,25 @@ class ManualIngestTask(BaseTask): self.logger.info("Ingesting %s into %s", filename, target_table) try: - inserted, updated = self._ingest_table(target_table, records, filename) + inserted, updated, row_errors = self._ingest_table(target_table, records, filename) counts["inserted"] += inserted counts["updated"] += updated counts["fetched"] += len(records) + counts["errors"] += row_errors + # 每个文件一次提交:降低单次事务体积,避免长事务/连接异常导致整体回滚失败。 + self.db.commit() except Exception: counts["errors"] += 1 self.logger.exception("Error processing %s", filename) - self.db.rollback() + try: + self.db.rollback() + except Exception: + pass + # 若连接已断开,后续文件无法继续,直接抛出让上层处理(重连/重跑)。 + if getattr(self.db.conn, "closed", 0): + raise continue - try: - self.db.commit() - except Exception: - self.db.rollback() - raise - return {"status": "SUCCESS", "counts": counts} def _match_by_filename(self, filename: str) -> str | None: @@ -211,8 +220,15 @@ class ManualIngestTask(BaseTask): self._table_columns_cache = cache return cols - def _ingest_table(self, table: str, records: list[dict], source_file: str) -> tuple[int, int]: - """构建 INSERT/ON CONFLICT 语句并批量执行。""" + def _ingest_table(self, table: str, records: list[dict], source_file: str) -> tuple[int, int, int]: + """ + 构建 INSERT/ON CONFLICT 语句并批量执行(优先向量化,小批次提交)。 + + 设计目标: + - 控制单条 SQL 体积(避免一次性 VALUES 过大导致服务端 backend 被 OOM/异常终止); + - 发生异常时,可降级逐行并用 SAVEPOINT 跳过异常行; + - 统计口径偏“尽量可跑通”,插入/更新计数为近似值(不强依赖 RETURNING)。 + """ spec = self.TABLE_SPECS.get(table) if not spec: raise ValueError(f"No table spec for {table}") @@ -229,15 +245,19 @@ class ManualIngestTask(BaseTask): pk_col_db = None if pk_col: pk_col_db = next((c for c in columns if c.lower() == pk_col.lower()), pk_col) + pk_index = None + if pk_col_db: + try: + pk_index = next(i for i, c in enumerate(columns_info) if c[0] == pk_col_db) + except Exception: + pk_index = None - placeholders = ", ".join(["%s"] * len(columns)) col_list = ", ".join(f'"{c}"' for c in columns) - sql = f'INSERT INTO {table} ({col_list}) VALUES ({placeholders})' + sql_prefix = f"INSERT INTO {table} ({col_list}) VALUES %s" if pk_col_db: update_cols = [c for c in columns if c != pk_col_db] set_clause = ", ".join(f'"{c}"=EXCLUDED."{c}"' for c in update_cols) - sql += f' ON CONFLICT ("{pk_col_db}") DO UPDATE SET {set_clause}' - sql += " RETURNING (xmax = 0) AS inserted" + sql_prefix += f' ON CONFLICT ("{pk_col_db}") DO UPDATE SET {set_clause}' params = [] now = datetime.now() @@ -288,19 +308,55 @@ class ManualIngestTask(BaseTask): params.append(tuple(row_vals)) if not params: - return 0, 0 + return 0, 0, 0 + + # 先尝试向量化执行(速度快);若失败,再降级逐行并用 SAVEPOINT 跳过异常行。 + try: + with self.db.conn.cursor() as cur: + # 分批提交:降低单次事务/单次 SQL 压力,避免服务端异常中断连接。 + affected = 0 + chunk_size = int(self.config.get("manual.execute_values_page_size", 50) or 50) + chunk_size = max(1, min(chunk_size, 500)) + for i in range(0, len(params), chunk_size): + chunk = params[i : i + chunk_size] + execute_values(cur, sql_prefix, chunk, page_size=len(chunk)) + if cur.rowcount is not None and cur.rowcount > 0: + affected += int(cur.rowcount) + # 这里无法精确拆分 inserted/updated(除非 RETURNING),按“受影响行数≈插入”近似返回。 + return int(affected), 0, 0 + except Exception as exc: + self.logger.warning("批量入库失败,准备降级逐行处理:table=%s, err=%s", table, exc) + try: + self.db.rollback() + except Exception: + pass inserted = 0 updated = 0 + errors = 0 with self.db.conn.cursor() as cur: for row in params: - cur.execute(sql, row) - flag = cur.fetchone()[0] - if flag: + cur.execute("SAVEPOINT sp_manual_ingest_row") + try: + cur.execute(sql_prefix.replace(" VALUES %s", f" VALUES ({', '.join(['%s'] * len(row))})"), row) inserted += 1 - else: - updated += 1 - return inserted, updated + cur.execute("RELEASE SAVEPOINT sp_manual_ingest_row") + except Exception as exc: # noqa: BLE001 + errors += 1 + try: + cur.execute("ROLLBACK TO SAVEPOINT sp_manual_ingest_row") + cur.execute("RELEASE SAVEPOINT sp_manual_ingest_row") + except Exception: + pass + pk_val = None + if pk_index is not None: + try: + pk_val = row[pk_index] + except Exception: + pk_val = None + self.logger.warning("跳过异常行:table=%s pk=%s err=%s", table, pk_val, exc) + + return inserted, updated, errors @staticmethod def _get_value_case_insensitive(record: dict, col: str | None): diff --git a/etl_billiards/tasks/ods_json_archive_task.py b/etl_billiards/tasks/ods_json_archive_task.py new file mode 100644 index 0000000..df490cf --- /dev/null +++ b/etl_billiards/tasks/ods_json_archive_task.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +"""在线抓取 ODS 相关接口并落盘为 JSON(用于后续离线回放/入库)。""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from api.client import APIClient +from models.parsers import TypeParser +from utils.json_store import dump_json, endpoint_to_filename + +from .base_task import BaseTask, TaskContext + + +@dataclass(frozen=True) +class EndpointSpec: + endpoint: str + window_style: str # site | start_end | range | pay | none + data_path: tuple[str, ...] = ("data",) + list_key: str | None = None + + +class OdsJsonArchiveTask(BaseTask): + """ + 抓取一组 ODS 所需接口并落盘为“简化 JSON”: + {"code": 0, "data": [...records...]} + + 说明: + - 该输出格式与 tasks/manual_ingest_task.py 的解析逻辑兼容; + - 默认每页一个文件,避免单文件过大; + - 结算小票(/Order/GetOrderSettleTicketNew)按 orderSettleId 分文件写入。 + """ + + ENDPOINTS: tuple[EndpointSpec, ...] = ( + EndpointSpec("/MemberProfile/GetTenantMemberList", "site", list_key="tenantMemberInfos"), + EndpointSpec("/MemberProfile/GetTenantMemberCardList", "site", list_key="tenantMemberCards"), + EndpointSpec("/MemberProfile/GetMemberCardBalanceChange", "start_end"), + EndpointSpec("/PersonnelManagement/SearchAssistantInfo", "site", list_key="assistantInfos"), + EndpointSpec( + "/AssistantPerformance/GetOrderAssistantDetails", + "start_end", + list_key="orderAssistantDetails", + ), + EndpointSpec( + "/AssistantPerformance/GetAbolitionAssistant", + "start_end", + list_key="abolitionAssistants", + ), + EndpointSpec("/Table/GetSiteTables", "site", list_key="siteTables"), + EndpointSpec( + "/TenantGoodsCategory/QueryPrimarySecondaryCategory", + "site", + list_key="goodsCategoryList", + ), + EndpointSpec("/TenantGoods/QueryTenantGoods", "site", list_key="tenantGoodsList"), + EndpointSpec("/TenantGoods/GetGoodsInventoryList", "site", list_key="orderGoodsList"), + EndpointSpec("/TenantGoods/GetGoodsStockReport", "site"), + EndpointSpec("/TenantGoods/GetGoodsSalesList", "start_end", list_key="orderGoodsLedgers"), + EndpointSpec( + "/PackageCoupon/QueryPackageCouponList", + "site", + list_key="packageCouponList", + ), + EndpointSpec("/Site/GetSiteTableUseDetails", "start_end", list_key="siteTableUseDetailsList"), + EndpointSpec("/Site/GetSiteTableOrderDetails", "start_end", list_key="siteTableUseDetailsList"), + EndpointSpec("/Site/GetTaiFeeAdjustList", "start_end", list_key="taiFeeAdjustInfos"), + EndpointSpec( + "/GoodsStockManage/QueryGoodsOutboundReceipt", + "start_end", + list_key="queryDeliveryRecordsList", + ), + EndpointSpec("/Promotion/GetOfflineCouponConsumePageList", "start_end"), + EndpointSpec("/Order/GetRefundPayLogList", "start_end"), + EndpointSpec("/Site/GetAllOrderSettleList", "range", list_key="settleList"), + EndpointSpec("/Site/GetRechargeSettleList", "range", list_key="settleList"), + EndpointSpec("/PayLog/GetPayLogListPage", "pay"), + ) + + TICKET_ENDPOINT = "/Order/GetOrderSettleTicketNew" + + def get_task_code(self) -> str: + return "ODS_JSON_ARCHIVE" + + def extract(self, context: TaskContext) -> dict: + base_client = getattr(self.api, "base", None) or self.api + if not isinstance(base_client, APIClient): + raise TypeError("ODS_JSON_ARCHIVE 需要 APIClient(在线抓取)") + + output_dir = getattr(self.api, "output_dir", None) + if output_dir: + out = Path(output_dir) + else: + out = Path(self.config.get("pipeline.fetch_root") or self.config["pipeline"]["fetch_root"]) + out.mkdir(parents=True, exist_ok=True) + + write_pretty = bool(self.config.get("io.write_pretty_json", False)) + page_size = int(self.config.get("api.page_size", 200) or 200) + store_id = int(context.store_id) + + total_records = 0 + ticket_ids: set[int] = set() + per_endpoint: list[dict] = [] + + self.logger.info( + "ODS_JSON_ARCHIVE: 开始抓取,窗口[%s ~ %s] 输出目录=%s", + context.window_start, + context.window_end, + out, + ) + + for spec in self.ENDPOINTS: + self.logger.info("ODS_JSON_ARCHIVE: 抓取 endpoint=%s", spec.endpoint) + built_params = self._build_params( + spec.window_style, store_id, context.window_start, context.window_end + ) + # /TenantGoods/GetGoodsInventoryList 要求 siteId 为数组(标量会触发服务端异常,返回畸形状态行 HTTP/1.1 1400) + if spec.endpoint == "/TenantGoods/GetGoodsInventoryList": + built_params["siteId"] = [store_id] + params = self._merge_common_params(built_params) + + base_filename = endpoint_to_filename(spec.endpoint) + stem = Path(base_filename).stem + suffix = Path(base_filename).suffix or ".json" + + endpoint_records = 0 + endpoint_pages = 0 + endpoint_error: str | None = None + + try: + for page_no, records, _, _ in base_client.iter_paginated( + endpoint=spec.endpoint, + params=params, + page_size=page_size, + data_path=spec.data_path, + list_key=spec.list_key, + ): + endpoint_pages += 1 + total_records += len(records) + endpoint_records += len(records) + + if spec.endpoint == "/PayLog/GetPayLogListPage": + for rec in records or []: + relate_id = TypeParser.parse_int( + (rec or {}).get("relateId") + or (rec or {}).get("orderSettleId") + or (rec or {}).get("order_settle_id") + ) + if relate_id: + ticket_ids.add(relate_id) + + out_path = out / f"{stem}__p{int(page_no):04d}{suffix}" + dump_json(out_path, {"code": 0, "data": records}, pretty=write_pretty) + except Exception as exc: # noqa: BLE001 + endpoint_error = f"{type(exc).__name__}: {exc}" + self.logger.error("ODS_JSON_ARCHIVE: 接口抓取失败 endpoint=%s err=%s", spec.endpoint, endpoint_error) + + per_endpoint.append( + { + "endpoint": spec.endpoint, + "file_stem": stem, + "pages": endpoint_pages, + "records": endpoint_records, + "error": endpoint_error, + } + ) + if endpoint_error: + self.logger.warning( + "ODS_JSON_ARCHIVE: endpoint=%s 完成(失败)pages=%s records=%s err=%s", + spec.endpoint, + endpoint_pages, + endpoint_records, + endpoint_error, + ) + else: + self.logger.info( + "ODS_JSON_ARCHIVE: endpoint=%s 完成 pages=%s records=%s", + spec.endpoint, + endpoint_pages, + endpoint_records, + ) + + # Ticket details: per orderSettleId + ticket_ids_sorted = sorted(ticket_ids) + self.logger.info("ODS_JSON_ARCHIVE: 小票候选数=%s", len(ticket_ids_sorted)) + + ticket_file_stem = Path(endpoint_to_filename(self.TICKET_ENDPOINT)).stem + ticket_file_suffix = Path(endpoint_to_filename(self.TICKET_ENDPOINT)).suffix or ".json" + ticket_records = 0 + + for order_settle_id in ticket_ids_sorted: + params = self._merge_common_params({"orderSettleId": int(order_settle_id)}) + try: + records, _ = base_client.get_paginated( + endpoint=self.TICKET_ENDPOINT, + params=params, + page_size=None, + data_path=("data",), + list_key=None, + ) + if not records: + continue + ticket_records += len(records) + out_path = out / f"{ticket_file_stem}__{int(order_settle_id)}{ticket_file_suffix}" + dump_json(out_path, {"code": 0, "data": records}, pretty=write_pretty) + except Exception as exc: # noqa: BLE001 + self.logger.error( + "ODS_JSON_ARCHIVE: 小票抓取失败 orderSettleId=%s err=%s", + order_settle_id, + exc, + ) + continue + + total_records += ticket_records + + manifest = { + "task": self.get_task_code(), + "store_id": store_id, + "window_start": context.window_start.isoformat(), + "window_end": context.window_end.isoformat(), + "page_size": page_size, + "total_records": total_records, + "ticket_ids": len(ticket_ids_sorted), + "ticket_records": ticket_records, + "endpoints": per_endpoint, + } + manifest_path = out / "manifest.json" + dump_json(manifest_path, manifest, pretty=True) + if hasattr(self.api, "last_dump"): + try: + self.api.last_dump = {"file": str(manifest_path), "records": total_records, "pages": None} + except Exception: + pass + + self.logger.info("ODS_JSON_ARCHIVE: 抓取完成,总记录数=%s(含小票=%s)", total_records, ticket_records) + return {"fetched": total_records, "ticket_ids": len(ticket_ids_sorted)} + + def _build_params(self, window_style: str, store_id: int, window_start, window_end) -> dict: + if window_style == "none": + return {} + if window_style == "site": + return {"siteId": store_id} + if window_style == "range": + return { + "siteId": store_id, + "rangeStartTime": TypeParser.format_timestamp(window_start, self.tz), + "rangeEndTime": TypeParser.format_timestamp(window_end, self.tz), + } + if window_style == "pay": + return { + "siteId": store_id, + "StartPayTime": TypeParser.format_timestamp(window_start, self.tz), + "EndPayTime": TypeParser.format_timestamp(window_end, self.tz), + } + # default: startTime/endTime + return { + "siteId": store_id, + "startTime": TypeParser.format_timestamp(window_start, self.tz), + "endTime": TypeParser.format_timestamp(window_end, self.tz), + } diff --git a/etl_billiards/tasks/ods_tasks.py b/etl_billiards/tasks/ods_tasks.py index 6f503bd..1657c6b 100644 --- a/etl_billiards/tasks/ods_tasks.py +++ b/etl_billiards/tasks/ods_tasks.py @@ -2,11 +2,13 @@ """ODS ingestion tasks.""" from __future__ import annotations +import json from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Callable, Dict, Iterable, List, Sequence, Tuple, Type -from loaders.ods import GenericODSLoader +from psycopg2.extras import Json, execute_values + from models.parsers import TypeParser from .base_task import BaseTask @@ -60,70 +62,61 @@ class BaseOdsTask(BaseTask): def get_task_code(self) -> str: return self.SPEC.code - def execute(self) -> dict: + def execute(self, cursor_data: dict | None = None) -> dict: spec = self.SPEC self.logger.info("寮€濮嬫墽琛?%s (ODS)", spec.code) + window_start, window_end, window_minutes = self._resolve_window(cursor_data) + store_id = TypeParser.parse_int(self.config.get("app.store_id")) if not store_id: raise ValueError("app.store_id 鏈厤缃紝鏃犳硶鎵ц ODS 浠诲姟") page_size = self.config.get("api.page_size", 200) - params = self._build_params(spec, store_id) - columns = self._resolve_columns(spec) - if spec.conflict_columns_override: - conflict_columns = list(spec.conflict_columns_override) - else: - conflict_columns = [] - if spec.include_site_column: - conflict_columns.append("site_id") - conflict_columns += [col.column for col in spec.pk_columns] - loader = GenericODSLoader( - self.db, - spec.table_name, - columns, - conflict_columns, + params = self._build_params( + spec, + store_id, + window_start=window_start, + window_end=window_end, ) counts = {"fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "errors": 0} source_file = self._resolve_source_file_hint(spec) try: - global_index = 0 - for page_no, page_records, _, _ in self.api.iter_paginated( + for _, page_records, _, response_payload in self.api.iter_paginated( endpoint=spec.endpoint, params=params, page_size=page_size, data_path=spec.data_path, list_key=spec.list_key, ): - rows: List[dict] = [] - for raw in page_records: - row = self._build_row( - spec=spec, - store_id=store_id, - record=raw, - page_no=page_no if spec.include_page_no else None, - page_size_value=len(page_records) - if spec.include_page_size - else None, - source_file=source_file, - record_index=global_index if spec.include_record_index else None, - ) - if row is None: - counts["skipped"] += 1 - continue - rows.append(row) - global_index += 1 - - inserted, updated, _ = loader.upsert_rows(rows) - counts["inserted"] += inserted - counts["updated"] += updated + inserted, skipped = self._insert_records_schema_aware( + table=spec.table_name, + records=page_records, + response_payload=response_payload, + source_file=source_file, + source_endpoint=spec.endpoint if spec.include_source_endpoint else None, + ) counts["fetched"] += len(page_records) + counts["inserted"] += inserted + counts["skipped"] += skipped self.db.commit() self.logger.info("%s ODS 浠诲姟瀹屾垚: %s", spec.code, counts) - return self._build_result("SUCCESS", counts) + allow_empty_advance = bool(self.config.get("run.allow_empty_result_advance", False)) + status = "SUCCESS" + if counts["fetched"] == 0 and not allow_empty_advance: + status = "PARTIAL" + + result = self._build_result(status, counts) + result["window"] = { + "start": window_start, + "end": window_end, + "minutes": window_minutes, + } + result["request_params"] = params + return result except Exception: self.db.rollback() @@ -131,12 +124,70 @@ class BaseOdsTask(BaseTask): self.logger.error("%s ODS 浠诲姟澶辫触", spec.code, exc_info=True) raise - def _build_params(self, spec: OdsTaskSpec, store_id: int) -> dict: + def _resolve_window(self, cursor_data: dict | None) -> tuple[datetime, datetime, int]: + base_start, base_end, base_minutes = self._get_time_window(cursor_data) + + if self.config.get("run.force_window_override"): + override_start = self.config.get("run.window_override.start") + override_end = self.config.get("run.window_override.end") + if override_start and override_end: + return base_start, base_end, base_minutes + + # 以 ODS 表 MAX(fetched_at) 兜底:避免“窗口游标推进但未实际入库”导致漏数。 + last_fetched = self._get_max_fetched_at(self.SPEC.table_name) + if last_fetched: + overlap_seconds = int(self.config.get("run.overlap_seconds", 120) or 120) + cursor_end = cursor_data.get("last_end") if isinstance(cursor_data, dict) else None + anchor = cursor_end or last_fetched + # 如果 cursor_end 比真实入库时间(last_fetched)更靠后,说明游标被推进但表未跟上:改用 last_fetched 作为起点 + if isinstance(cursor_end, datetime) and cursor_end.tzinfo is None: + cursor_end = cursor_end.replace(tzinfo=self.tz) + if isinstance(cursor_end, datetime) and cursor_end > last_fetched: + anchor = last_fetched + start = anchor - timedelta(seconds=max(0, overlap_seconds)) + if start.tzinfo is None: + start = start.replace(tzinfo=self.tz) + else: + start = start.astimezone(self.tz) + + end = datetime.now(self.tz) + minutes = max(1, int((end - start).total_seconds() // 60)) + return start, end, minutes + + return base_start, base_end, base_minutes + + def _get_max_fetched_at(self, table_name: str) -> datetime | None: + try: + rows = self.db.query(f"SELECT MAX(fetched_at) AS mx FROM {table_name}") + except Exception: + return None + + if not rows or not rows[0].get("mx"): + return None + + mx = rows[0]["mx"] + if not isinstance(mx, datetime): + return None + if mx.tzinfo is None: + return mx.replace(tzinfo=self.tz) + return mx.astimezone(self.tz) + + def _build_params( + self, + spec: OdsTaskSpec, + store_id: int, + *, + window_start: datetime, + window_end: datetime, + ) -> dict: base: dict[str, Any] = {} if spec.include_site_id: - base["siteId"] = store_id + # /TenantGoods/GetGoodsInventoryList 要求 siteId 为数组(标量会触发服务端异常,返回畸形状态行 HTTP/1.1 1400) + if spec.endpoint == "/TenantGoods/GetGoodsInventoryList": + base["siteId"] = [store_id] + else: + base["siteId"] = store_id if spec.requires_window and spec.time_fields: - window_start, window_end, _ = self._get_time_window() start_key, end_key = spec.time_fields base[start_key] = TypeParser.format_timestamp(window_start, self.tz) base[end_key] = TypeParser.format_timestamp(window_end, self.tz) @@ -145,109 +196,226 @@ class BaseOdsTask(BaseTask): params.update(spec.extra_params) return params - def _resolve_columns(self, spec: OdsTaskSpec) -> List[str]: - columns: List[str] = [] - if spec.include_site_column: - columns.append("site_id") - seen = set(columns) - for col_spec in list(spec.pk_columns) + list(spec.extra_columns): - if col_spec.column not in seen: - columns.append(col_spec.column) - seen.add(col_spec.column) + # ------------------------------------------------------------------ schema-aware ingest (ODS doc schema) + def _get_table_columns(self, table: str) -> list[tuple[str, str, str]]: + cache = getattr(self, "_table_columns_cache", {}) + if table in cache: + return cache[table] + if "." in table: + schema, name = table.split(".", 1) + else: + schema, name = "public", table + sql = """ + SELECT column_name, data_type, udt_name + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + ORDER BY ordinal_position + """ + with self.db.conn.cursor() as cur: + cur.execute(sql, (schema, name)) + cols = [(r[0], (r[1] or "").lower(), (r[2] or "").lower()) for r in cur.fetchall()] + cache[table] = cols + self._table_columns_cache = cache + return cols - if spec.include_record_index and "record_index" not in seen: - columns.append("record_index") - seen.add("record_index") + def _get_table_pk_columns(self, table: str) -> list[str]: + cache = getattr(self, "_table_pk_cache", {}) + if table in cache: + return cache[table] + if "." in table: + schema, name = table.split(".", 1) + else: + schema, name = "public", table + sql = """ + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = %s + AND tc.table_name = %s + ORDER BY kcu.ordinal_position + """ + with self.db.conn.cursor() as cur: + cur.execute(sql, (schema, name)) + cols = [r[0] for r in cur.fetchall()] + cache[table] = cols + self._table_pk_cache = cache + return cols - if spec.include_page_no and "page_no" not in seen: - columns.append("page_no") - seen.add("page_no") - - if spec.include_page_size and "page_size" not in seen: - columns.append("page_size") - seen.add("page_size") - - if spec.include_source_file and "source_file" not in seen: - columns.append("source_file") - seen.add("source_file") - - if spec.include_source_endpoint and "source_endpoint" not in seen: - columns.append("source_endpoint") - seen.add("source_endpoint") - - if spec.include_fetched_at and "fetched_at" not in seen: - columns.append("fetched_at") - seen.add("fetched_at") - if "payload" not in seen: - columns.append("payload") - - return columns - - def _build_row( + def _insert_records_schema_aware( self, - spec: OdsTaskSpec, - store_id: int, - record: dict, - page_no: int | None, - page_size_value: int | None, + *, + table: str, + records: list, + response_payload: dict | list | None, source_file: str | None, - record_index: int | None = None, - ) -> dict | None: - row: dict[str, Any] = {} - if spec.include_site_column: - row["site_id"] = store_id + source_endpoint: str | None, + ) -> tuple[int, int]: + """ + 按 DB 表结构动态写入 ODS(只插新数据:ON CONFLICT DO NOTHING)。 + 返回 (inserted, skipped)。 + """ + if not records: + return 0, 0 - for col_spec in spec.pk_columns + spec.extra_columns: - value = self._extract_value(record, col_spec) - if value is None and col_spec.required: - self.logger.warning( - "%s 缂哄皯蹇呭~瀛楁 %s锛屽師濮嬭褰? %s", - spec.code, - col_spec.column, - record, - ) - return None - row[col_spec.column] = value + cols_info = self._get_table_columns(table) + if not cols_info: + raise ValueError(f"Cannot resolve columns for table={table}") - if spec.include_page_no: - row["page_no"] = page_no - if spec.include_page_size: - row["page_size"] = page_size_value - if spec.include_record_index: - row["record_index"] = record_index - if spec.include_source_file: - row["source_file"] = source_file - if spec.include_source_endpoint: - row["source_endpoint"] = spec.endpoint + pk_cols = self._get_table_pk_columns(table) + db_json_cols_lower = { + c[0].lower() for c in cols_info if c[1] in ("json", "jsonb") or c[2] in ("json", "jsonb") + } - if spec.include_fetched_at: - row["fetched_at"] = datetime.now(self.tz) - row["payload"] = record - return row + col_names = [c[0] for c in cols_info] + quoted_cols = ", ".join(f'\"{c}\"' for c in col_names) + sql = f"INSERT INTO {table} ({quoted_cols}) VALUES %s" + if pk_cols: + pk_clause = ", ".join(f'\"{c}\"' for c in pk_cols) + sql += f" ON CONFLICT ({pk_clause}) DO NOTHING" - def _extract_value(self, record: dict, spec: ColumnSpec): - value = None - for key in spec.sources: - value = self._dig(record, key) - if value is not None: - break - if value is None and spec.default is not None: - value = spec.default - if value is not None and spec.transform: - value = spec.transform(value) + now = datetime.now(self.tz) + json_dump = lambda v: json.dumps(v, ensure_ascii=False) # noqa: E731 + + params: list[tuple] = [] + skipped = 0 + + root_site_profile = None + if isinstance(response_payload, dict): + data_part = response_payload.get("data") + if isinstance(data_part, dict): + sp = data_part.get("siteProfile") or data_part.get("site_profile") + if isinstance(sp, dict): + root_site_profile = sp + + for rec in records: + if not isinstance(rec, dict): + skipped += 1 + continue + + merged_rec = self._merge_record_layers(rec) + if table in {"billiards_ods.recharge_settlements", "billiards_ods.settlement_records"}: + site_profile = merged_rec.get("siteProfile") or merged_rec.get("site_profile") or root_site_profile + if isinstance(site_profile, dict): + # 避免写入 None 覆盖原本存在的 camelCase 字段(例如 tenantId/siteId/siteName) + def _fill_missing(target_col: str, candidates: list[Any]): + existing = self._get_value_case_insensitive(merged_rec, target_col) + if existing not in (None, ""): + return + for cand in candidates: + if cand in (None, "", 0): + continue + merged_rec[target_col] = cand + return + + _fill_missing("tenantid", [site_profile.get("tenant_id"), site_profile.get("tenantId")]) + _fill_missing("siteid", [site_profile.get("siteId"), site_profile.get("id")]) + _fill_missing("sitename", [site_profile.get("shop_name"), site_profile.get("siteName")]) + + if pk_cols: + missing_pk = False + for pk in pk_cols: + pk_val = self._get_value_case_insensitive(merged_rec, pk) + if pk_val is None or pk_val == "": + missing_pk = True + break + if missing_pk: + skipped += 1 + continue + + row_vals: list[Any] = [] + for (col_name, data_type, _udt) in cols_info: + col_lower = col_name.lower() + if col_lower == "payload": + row_vals.append(Json(rec, dumps=json_dump)) + continue + if col_lower == "source_file": + row_vals.append(source_file) + continue + if col_lower == "source_endpoint": + row_vals.append(source_endpoint) + continue + if col_lower == "fetched_at": + row_vals.append(now) + continue + + value = self._normalize_scalar(self._get_value_case_insensitive(merged_rec, col_name)) + if col_lower in db_json_cols_lower: + row_vals.append(Json(value, dumps=json_dump) if value is not None else None) + continue + + row_vals.append(self._cast_value(value, data_type)) + + params.append(tuple(row_vals)) + + if not params: + return 0, skipped + + inserted = 0 + chunk_size = int(self.config.get("run.ods_execute_values_page_size", 200) or 200) + chunk_size = max(1, min(chunk_size, 2000)) + with self.db.conn.cursor() as cur: + for i in range(0, len(params), chunk_size): + chunk = params[i : i + chunk_size] + execute_values(cur, sql, chunk, page_size=len(chunk)) + if cur.rowcount is not None and cur.rowcount > 0: + inserted += int(cur.rowcount) + return inserted, skipped + + @staticmethod + def _merge_record_layers(record: dict) -> dict: + merged = record + data_part = merged.get("data") + while isinstance(data_part, dict): + merged = {**data_part, **merged} + data_part = data_part.get("data") + settle_inner = merged.get("settleList") + if isinstance(settle_inner, dict): + merged = {**settle_inner, **merged} + return merged + + @staticmethod + def _get_value_case_insensitive(record: dict | None, col: str | None): + if record is None or col is None: + return None + if col in record: + return record.get(col) + col_lower = col.lower() + for k, v in record.items(): + if isinstance(k, str) and k.lower() == col_lower: + return v + return None + + @staticmethod + def _normalize_scalar(value): + if value == "" or value == "{}" or value == "[]": + return None return value @staticmethod - def _dig(record: Any, path: str | None): - if not path: + def _cast_value(value, data_type: str): + if value is None: return None - current = record - for part in path.split("."): - if isinstance(current, dict): - current = current.get(part) - else: + dt = (data_type or "").lower() + if dt in ("integer", "bigint", "smallint"): + if isinstance(value, bool): + return int(value) + try: + return int(value) + except Exception: return None - return current + if dt in ("numeric", "double precision", "real", "decimal"): + if isinstance(value, bool): + return int(value) + try: + return float(value) + except Exception: + return None + if dt.startswith("timestamp") or dt in ("date", "time", "interval"): + return value if isinstance(value, (str, datetime)) else None + return value def _resolve_source_file_hint(self, spec: OdsTaskSpec) -> str | None: resolver = getattr(self.api, "get_source_hint", None) @@ -319,15 +487,16 @@ ODS_TASK_SPECS: Tuple[OdsTaskSpec, ...] = ( endpoint="/Site/GetAllOrderSettleList", data_path=("data",), list_key="settleList", + time_fields=("rangeStartTime", "rangeEndTime"), pk_columns=(), include_site_column=False, - include_source_endpoint=False, + include_source_endpoint=True, include_page_no=False, include_page_size=False, include_fetched_at=False, include_record_index=True, conflict_columns_override=("source_file", "record_index"), - requires_window=False, + requires_window=True, description="缁撹处璁板綍 ODS锛欸etAllOrderSettleList -> settleList 鍘熷 JSON", ), OdsTaskSpec( @@ -512,6 +681,7 @@ ODS_TASK_SPECS: Tuple[OdsTaskSpec, ...] = ( endpoint="/Site/GetRechargeSettleList", data_path=("data",), list_key="settleList", + time_fields=("rangeStartTime", "rangeEndTime"), pk_columns=(_int_col("recharge_order_id", "settleList.id", "id", required=True),), extra_columns=( _int_col("tenant_id", "settleList.tenantId", "tenantId"), @@ -583,7 +753,7 @@ ODS_TASK_SPECS: Tuple[OdsTaskSpec, ...] = ( include_fetched_at=True, include_record_index=False, conflict_columns_override=None, - requires_window=False, + requires_window=True, description="?????? ODS?GetRechargeSettleList -> data.settleList ????", ), @@ -800,12 +970,6 @@ class OdsSettlementTicketTask(BaseOdsTask): store_id = TypeParser.parse_int(self.config.get("app.store_id")) or 0 counts = {"fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "errors": 0} - loader = GenericODSLoader( - self.db, - spec.table_name, - self._resolve_columns(spec), - list(spec.conflict_columns_override or ("source_file", "record_index")), - ) source_file = self._resolve_source_file_hint(spec) try: @@ -823,39 +987,43 @@ class OdsSettlementTicketTask(BaseOdsTask): context.window_start, context.window_end, ) - return self._build_result("SUCCESS", counts) + result = self._build_result("SUCCESS", counts) + result["window"] = { + "start": context.window_start, + "end": context.window_end, + "minutes": context.window_minutes, + } + result["request_params"] = {"candidates": 0} + return result payloads, skipped = self._fetch_ticket_payloads(candidates) counts["skipped"] += skipped - rows: list[dict] = [] - for idx, payload in enumerate(payloads): - row = self._build_row( - spec=spec, - store_id=store_id, - record=payload, - page_no=None, - page_size_value=None, - source_file=source_file, - record_index=idx if spec.include_record_index else None, - ) - if row is None: - counts["skipped"] += 1 - continue - rows.append(row) - - inserted, updated, _ = loader.upsert_rows(rows) + inserted, skipped2 = self._insert_records_schema_aware( + table=spec.table_name, + records=payloads, + response_payload=None, + source_file=source_file, + source_endpoint=spec.endpoint, + ) counts["inserted"] += inserted - counts["updated"] += updated + counts["skipped"] += skipped2 self.db.commit() self.logger.info( "%s: 灏忕エ鎶撳彇瀹屾垚锛屽€欓€?%s 鎻掑叆=%s 鏇存柊=%s 璺宠繃=%s", spec.code, len(candidates), inserted, - updated, + 0, counts["skipped"], ) - return self._build_result("SUCCESS", counts) + result = self._build_result("SUCCESS", counts) + result["window"] = { + "start": context.window_start, + "end": context.window_end, + "minutes": context.window_minutes, + } + result["request_params"] = {"candidates": len(candidates)} + return result except Exception: counts["errors"] += 1 @@ -1026,4 +1194,3 @@ ODS_TASK_CLASSES: Dict[str, Type[BaseOdsTask]] = { ODS_TASK_CLASSES["ODS_SETTLEMENT_TICKET"] = OdsSettlementTicketTask __all__ = ["ODS_TASK_CLASSES", "ODS_TASK_SPECS", "BaseOdsTask", "ENABLED_ODS_CODES"] - diff --git a/etl_billiards/tests/unit/test_endpoint_routing.py b/etl_billiards/tests/unit/test_endpoint_routing.py new file mode 100644 index 0000000..4a81030 --- /dev/null +++ b/etl_billiards/tests/unit/test_endpoint_routing.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""Unit tests for recent/former endpoint routing.""" + +import sys +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from api.endpoint_routing import plan_calls, recent_boundary + + +TZ = ZoneInfo("Asia/Shanghai") + + +def _now(): + return datetime(2025, 12, 18, 10, 0, 0, tzinfo=TZ) + + +def test_recent_boundary_month_start(): + b = recent_boundary(_now()) + assert b.isoformat() == "2025-09-01T00:00:00+08:00" + + +def test_paylog_routes_to_former_when_old_window(): + params = {"siteId": 1, "StartPayTime": "2025-08-01 00:00:00", "EndPayTime": "2025-08-02 00:00:00"} + calls = plan_calls("/PayLog/GetPayLogListPage", params, now=_now(), tz=TZ) + assert [c.endpoint for c in calls] == ["/PayLog/GetFormerPayLogListPage"] + + +def test_coupon_usage_stays_same_path_even_when_old(): + params = {"siteId": 1, "startTime": "2025-08-01 00:00:00", "endTime": "2025-08-02 00:00:00"} + calls = plan_calls("/Promotion/GetOfflineCouponConsumePageList", params, now=_now(), tz=TZ) + assert [c.endpoint for c in calls] == ["/Promotion/GetOfflineCouponConsumePageList"] + + +def test_goods_outbound_routes_to_queryformer_when_old(): + params = {"siteId": 1, "startTime": "2025-08-01 00:00:00", "endTime": "2025-08-02 00:00:00"} + calls = plan_calls("/GoodsStockManage/QueryGoodsOutboundReceipt", params, now=_now(), tz=TZ) + assert [c.endpoint for c in calls] == ["/GoodsStockManage/QueryFormerGoodsOutboundReceipt"] + + +def test_settlement_records_split_when_crossing_boundary(): + params = {"siteId": 1, "rangeStartTime": "2025-08-15 00:00:00", "rangeEndTime": "2025-09-10 00:00:00"} + calls = plan_calls("/Site/GetAllOrderSettleList", params, now=_now(), tz=TZ) + assert [c.endpoint for c in calls] == ["/Site/GetFormerOrderSettleList", "/Site/GetAllOrderSettleList"] + assert calls[0].params["rangeEndTime"] == "2025-09-01 00:00:00" + assert calls[1].params["rangeStartTime"] == "2025-09-01 00:00:00" + + +@pytest.mark.parametrize( + "endpoint", + [ + "/PayLog/GetFormerPayLogListPage", + "/Site/GetFormerOrderSettleList", + "/GoodsStockManage/QueryFormerGoodsOutboundReceipt", + ], +) +def test_explicit_former_endpoint_not_rerouted(endpoint): + params = {"siteId": 1, "startTime": "2025-08-01 00:00:00", "endTime": "2025-08-02 00:00:00"} + calls = plan_calls(endpoint, params, now=_now(), tz=TZ) + assert [c.endpoint for c in calls] == [endpoint] + diff --git a/etl_billiards/tests/unit/test_ods_tasks.py b/etl_billiards/tests/unit/test_ods_tasks.py index 41064a2..9431a14 100644 --- a/etl_billiards/tests/unit/test_ods_tasks.py +++ b/etl_billiards/tests/unit/test_ods_tasks.py @@ -23,7 +23,7 @@ def _build_config(tmp_path): def test_assistant_accounts_masters_ingest(tmp_path): - """Ensure assistant_accounts_masterS task stores raw payload with record_index dedup keys.""" + """Ensure ODS_ASSISTANT_ACCOUNT stores raw payload with record_index dedup keys.""" config = _build_config(tmp_path) sample = [ { @@ -33,7 +33,7 @@ def test_assistant_accounts_masters_ingest(tmp_path): } ] api = FakeAPIClient({"/PersonnelManagement/SearchAssistantInfo": sample}) - task_cls = ODS_TASK_CLASSES["assistant_accounts_masterS"] + task_cls = ODS_TASK_CLASSES["ODS_ASSISTANT_ACCOUNT"] with get_db_operations() as db_ops: task = task_cls(config, db_ops, api, logging.getLogger("test_assistant_accounts_masters")) @@ -50,7 +50,7 @@ def test_assistant_accounts_masters_ingest(tmp_path): def test_goods_stock_movements_ingest(tmp_path): - """Ensure goods_stock_movements task stores raw payload with record_index dedup keys.""" + """Ensure ODS_INVENTORY_CHANGE stores raw payload with record_index dedup keys.""" config = _build_config(tmp_path) sample = [ { @@ -60,7 +60,7 @@ def test_goods_stock_movements_ingest(tmp_path): } ] api = FakeAPIClient({"/GoodsStockManage/QueryGoodsOutboundReceipt": sample}) - task_cls = ODS_TASK_CLASSES["goods_stock_movements"] + task_cls = ODS_TASK_CLASSES["ODS_INVENTORY_CHANGE"] with get_db_operations() as db_ops: task = task_cls(config, db_ops, api, logging.getLogger("test_goods_stock_movements")) @@ -110,11 +110,11 @@ def test_ods_payment_ingest(tmp_path): def test_ods_settlement_records_ingest(tmp_path): - """Ensure settlement_records task stores settleList raw JSON.""" + """Ensure ODS_SETTLEMENT_RECORDS stores settleList raw JSON.""" config = _build_config(tmp_path) - sample = [{"data": {"settleList": [{"id": 701, "orderTradeNo": 8001}]}}] + sample = [{"id": 701, "orderTradeNo": 8001}] api = FakeAPIClient({"/Site/GetAllOrderSettleList": sample}) - task_cls = ODS_TASK_CLASSES["settlement_records"] + task_cls = ODS_TASK_CLASSES["ODS_SETTLEMENT_RECORDS"] with get_db_operations() as db_ops: task = task_cls(config, db_ops, api, logging.getLogger("test_settlement_records")) diff --git a/etl_billiards/utils/logging_utils.py b/etl_billiards/utils/logging_utils.py new file mode 100644 index 0000000..2c60674 --- /dev/null +++ b/etl_billiards/utils/logging_utils.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import logging +import sys +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from typing import Iterator, TextIO + + +class TeeStream: + def __init__(self, *streams: TextIO) -> None: + self._streams = streams + + def write(self, data: str) -> int: + for stream in self._streams: + stream.write(data) + return len(data) + + def flush(self) -> None: + for stream in self._streams: + stream.flush() + + def isatty(self) -> bool: + return False + + def fileno(self) -> int: + return self._streams[0].fileno() + + +def build_log_path(log_dir: Path, prefix: str, tag: str = "") -> Path: + suffix = f"_{tag}" if tag else "" + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return log_dir / f"{prefix}{suffix}_{stamp}.log" + + +@contextmanager +def configure_logging( + name: str, + log_file: Path | None, + *, + level: str = "INFO", + console: bool = True, + tee_std: bool = True, +) -> Iterator[logging.Logger]: + logger = logging.getLogger(name) + logger.handlers.clear() + logger.setLevel(getattr(logging, level.upper(), logging.INFO)) + logger.propagate = False + + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s: %(message)s", + "%Y-%m-%d %H:%M:%S", + ) + + original_stdout = sys.stdout + original_stderr = sys.stderr + log_fp: TextIO | None = None + + try: + if log_file: + log_file.parent.mkdir(parents=True, exist_ok=True) + log_fp = open(log_file, "a", encoding="utf-8", buffering=1) + if tee_std: + if console: + sys.stdout = TeeStream(original_stdout, log_fp) + sys.stderr = TeeStream(original_stderr, log_fp) + else: + sys.stdout = log_fp + sys.stderr = log_fp + file_handler = logging.StreamHandler(log_fp) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + if console: + console_handler = logging.StreamHandler(original_stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + yield logger + finally: + for handler in list(logger.handlers): + handler.flush() + handler.close() + logger.removeHandler(handler) + if log_fp: + log_fp.flush() + log_fp.close() + sys.stdout = original_stdout + sys.stderr = original_stderr diff --git a/README_FULL.md b/tmp/README_FULL.md similarity index 84% rename from README_FULL.md rename to tmp/README_FULL.md index 3909de9..5140e3e 100644 --- a/README_FULL.md +++ b/tmp/README_FULL.md @@ -56,6 +56,49 @@ python -m etl_billiards.cli.main --tasks DWD_QUALITY_CHECK --pipeline-flow INGES --- + + + +## 正式环境(在线抓取 → 更新 ODS → 更新 DWD) +核心入口 CLI:`python -m etl_billiards.cli.main` + +### 必备配置(建议通过环境变量或 `.env`) +- 数据库:`PG_DSN`、`STORE_ID` +- 在线抓取:`API_TOKEN`(可选 `API_BASE`、`API_TIMEOUT`、`API_PAGE_SIZE`、`API_RETRY_MAX`) +- 输出目录(可选):`EXPORT_ROOT`、`LOG_ROOT`、`FETCH_ROOT`/`JSON_FETCH_ROOT` + +### 推荐定时方式 A(两段定时,更清晰) +1) **更新 ODS(在线抓取 + 入库,FULL)** +```bash +python -m etl_billiards.cli.main \ + --pipeline-flow FULL \ + --tasks PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER \ + --pg-dsn "$PG_DSN" --store-id "$STORE_ID" \ + --api-token "$API_TOKEN" +``` +2) **ODS → DWD(将新增/变更同步到 DWD)** +```bash +python -m etl_billiards.cli.main \ + --pipeline-flow INGEST_ONLY \ + --tasks DWD_LOAD_FROM_ODS \ + --pg-dsn "$PG_DSN" --store-id "$STORE_ID" +``` + +### 推荐定时方式 B(一条命令串起来) +同一条命令先跑在线抓取/入库任务,再跑 DWD 装载任务: +```bash +python -m etl_billiards.cli.main \ + --pipeline-flow FULL \ + --tasks PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER,DWD_LOAD_FROM_ODS \ + --pg-dsn "$PG_DSN" --store-id "$STORE_ID" \ + --api-token "$API_TOKEN" +``` + +### pipeline-flow 说明 +- `FULL`:在线抓取落盘 + 本地清洗入库(ODS 任务会走抓取;`DWD_LOAD_FROM_ODS` 仅走入库阶段) +- `FETCH_ONLY`:仅在线抓取落盘,不入库 +- `INGEST_ONLY`:仅从本地 JSON 回放入库(适合离线回放/补跑) + ## 4. 目录结构与关键文件 - 根目录:`etl_billiards/` 主代码;`requirements.txt` 依赖;`run_etl.sh/.bat` 启动脚本;`.env/.env.example` 配置;`tmp/` 草稿/调试归档。 - `config/`:`defaults.py` 默认值,`env_parser.py` 解析 .env,`settings.py` AppConfig 统一加载。 @@ -214,3 +257,20 @@ python scripts/test_db_connection.py --dsn postgresql://user:pwd@host:5432/db -- - DSN/路径:确认 `.env` 中 `PG_DSN`、`INGEST_SOURCE_DIR` 与本地一致。 - 新增任务:在 `tasks/` 实现并注册到 `task_registry.py`,必要时同步更新 DDL 与映射。 - 权限/运行:检查网络、账号权限;脚本需执行权限(如 `chmod +x run_etl.sh`)。 + + + +## 16.temp +原来在 task_merged.py 里配置的 14 个任务中,有 11 个目前还没有在新项目里实现,对应的 loader / task 类也不存在。 + + + +原脚本里“导出请求/响应 JSON 到本地目录、生成 manifest.json / ingest_report.json 并支持 offline 模式”的那一块逻辑,在新代码里还没有真正落地,只保留了配置字段和数据库字段,但没有实际写文件和离线装载的实现。 + + +丰富Pytest,进行分模块.分任务测试 + + +质量检查目前没有被“接入主流程”,内容也待完善,入库等问题? + + diff --git a/开发笔记/记录.md b/开发笔记/记录.md new file mode 100644 index 0000000..5998a4c --- /dev/null +++ b/开发笔记/记录.md @@ -0,0 +1,4 @@ + + + +API遵循 RESTful API 规范 \ No newline at end of file