Compare commits

..
11 Commits
12 changed files with 644 additions and 110 deletions
Vendored
BIN
View File
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
# PostgreSQL analytics database (required)
ANALYTICS_DB_HOST=postgres.example.internal
ANALYTICS_DB_PORT=5432
ANALYTICS_DB_USER=your_database_user
ANALYTICS_DB_PASSWORD=your_database_password
ANALYTICS_DB_NAME=your_database_name
# Feishu application and Bitable (required)
FEISHU_APP_ID=cli_your_app_id
FEISHU_APP_SECRET=your_feishu_app_secret
FEISHU_BASE_URL=https://example.feishu.cn/base/your_app_token
# Sync settings
PROJECT_MAPPING_FILE=店铺映射表.xlsx
FEISHU_STORE_FIELD_NAME=店铺名称
SYNC_START_DATE=2026-04-01
# Feishu notification (optional; configure one or both as needed)
FEISHU_BOT_WEBHOOK=
FEISHU_CHAT_ID=
FEISHU_REPORT_IMAGE_PATH=
# Prefect (required only for Prefect deployment/worker mode)
PREFECT_API_URL=http://host.docker.internal:4200/api
PREFECT_SYNC_STATE_FILE=
# Logging (optional)
LOG_DIR=
LOG_FILE_NAME=gmv_sync.log
LOG_LEVEL=INFO
LOG_MAX_BYTES=10485760
LOG_BACKUP_COUNT=7
+2
View File
@@ -1,6 +1,8 @@
__pycache__/* __pycache__/*
*.pyc *.pyc
.vscode/ .vscode/
# Local credentials and machine-specific configuration
.env .env
created/ created/
创建编组.zip 创建编组.zip
+233
View File
@@ -0,0 +1,233 @@
# GMV Analytics
按项目、店铺和日期统计销售金额、退款金额及 GMV,并同步到飞书多维表格。支持本地手动执行、历史日期补数,以及通过 Docker + Prefect 定时运行。
## 项目介绍
数据来自 PostgreSQL,通过 `店铺映射表.xlsx` 将 BI 店铺名称转换为业务店铺名称,再按项目和月份匹配飞书目标表。
- 支持项目:大眼睛、羽心堂、MMMJ、EM、春回盏、BROKEN ZOO。
- 计算口径:`GMV = 销售金额 - 退款金额`,具体取数 SQL 位于 `project_bitable_common.py`。
- 本地流水线逐日查询销售额和退款额,计算 GMV,写入对应项目的月份表。
- 当前流水线使用“日期”“指标”和各店铺数值列,指标包括“销售金额”“退款金额”“GMV”“汇总GMV”。汇总逻辑读取表内当月、不晚于处理日期的 GMV 记录进行累加。
- 可选择同步完成后向飞书群发送通知。
## 环境准备
需要 Python 3.12 或以上版本、可连接的 PostgreSQL 数据库,以及具有目标多维表格访问和写入权限的飞书应用。
以下命令均在项目根目录执行。已有 Conda / Python 环境时,先激活该环境。
使用 uv 安装依赖并进入虚拟环境:
```bash
uv sync
source .venv/bin/activate
```
也可以在已有 Python 环境中安装项目声明的依赖:
```bash
python - <<'PY'
import subprocess
import sys
import tomllib
from pathlib import Path
config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
subprocess.check_call([sys.executable, "-m", "pip", "install", *config["project"]["dependencies"]])
PY
```
## 配置 .env
在项目根目录创建或编辑 `.env`。下面均为占位值,请替换为实际配置:
```dotenv
ANALYTICS_DB_HOST=your-db-host
ANALYTICS_DB_PORT=5432
ANALYTICS_DB_NAME=your-db-name
ANALYTICS_DB_USER=your-db-user
ANALYTICS_DB_PASSWORD=your-db-password
FEISHU_APP_ID=your-app-id
FEISHU_APP_SECRET=your-app-secret
FEISHU_BASE_URL=https://your-tenant.feishu.cn/base/your-base-token
SYNC_START_DATE=2026-09-01
```
脚本自动加载项目根目录的 `.env`,已存在的进程环境变量优先于 `.env`。当前加载器按 `KEY=value` 读取,值不要额外加引号,也不要在值后追加行内注释。
`.env` 已加入 `.gitignore`,用于保存本地配置。
可选配置:
| 变量 | 用途与默认行为 |
| --- | --- |
| `PROJECT_MAPPING_FILE` | 店铺映射文件路径;不设置时使用脚本目录下的 `店铺映射表.xlsx` |
| `SYNC_START_DATE` | 本地流水线默认开始日期;未设置时为 `2026-04-01` |
| `FEISHU_CHAT_ID` | 应用机器人通知的目标群;配置后优先使用此方式 |
| `FEISHU_BOT_WEBHOOK` | 未设置群 ID 时使用的群机器人 Webhook |
| `FEISHU_REPORT_IMAGE_PATH` | 应用机器人通知使用的本地预览图路径 |
| `FEISHU_STORE_FIELD_NAME` | 独立脚本使用的店铺字段名,默认 `店铺名称` |
| `PREFECT_API_URL` | Prefect Server API 地址,本地流水线不需要 |
| `LOG_DIR` | 日志目录,默认项目下的 `logs` |
| `LOG_LEVEL` | 日志级别,默认 `INFO` |
映射文件读取第一个活动工作表,跳过第一行表头。三列及以上时,使用第 2 列作为业务店铺名称、第 3 列作为 BI 店铺名称;只有两列时,依次为业务店铺名称、BI 店铺名称。
## 本地执行
本地入口为 `run_project_bitable_pipeline.py`,可直接使用 Python 运行,无需启动 Docker 或 Prefect Server。未激活 uv 虚拟环境时,也可以将以下命令中的 `python` 替换为 `uv run python`。
### 指定一天
将开始日期和结束日期设为同一天:
```bash
python run_project_bitable_pipeline.py \
--start-date 2026-09-07 \
--end-date 2026-09-07 \
--no-notify \
--skip-missing-tables
```
### 指定日期范围
例如同步 2026 年 9 月 1 日至 9 月 7 日,包含首尾两天:
```bash
python run_project_bitable_pipeline.py \
--start-date 2026-09-01 \
--end-date 2026-09-07 \
--no-notify \
--skip-missing-tables
```
`--no-notify` 表示只同步数据,不发群通知。需要通知时,配置通知渠道并去掉该参数。
`--skip-missing-tables` 表示找不到对应表时记录日志并继续处理其他项目和日期,不会自动创建月份表。匹配到多个表、映射指定表不存在等配置错误仍会中断运行,避免写入错误的表。
### 预览执行
```bash
python run_project_bitable_pipeline.py \
--start-date 2026-09-01 \
--end-date 2026-09-07 \
--dry-run \
--no-notify \
--skip-missing-tables
```
`--dry-run` 会查询数据库、获取飞书访问令牌和表列表,并输出计划处理的表、日期和指标,不写入飞书、不发通知。因此仍需有效的数据库和飞书配置。
### 常用参数
| 参数 | 说明 |
| --- | --- |
| `--start-date YYYY-MM-DD` | 开始日期,默认读取 `SYNC_START_DATE` |
| `--end-date YYYY-MM-DD` | 结束日期,默认运行机器本地日期的昨天 |
| `--no-notify` | 跳过飞书群通知,默认未开启 |
| `--skip-missing-tables` | 本地流水线跳过缺失月份表,默认未开启;Prefect 默认开启,并在完成后推送缺失表提醒 |
| `--dry-run` | 预览,不执行飞书写入和通知 |
| `--mapping-file PATH` | 指定店铺映射 Excel 文件,覆盖环境变量配置 |
| `--table-map-file PATH` | 指定项目月份到飞书表名的 JSON 映射 |
| `--base-url URL` | 指定飞书多维表格地址;仍需在环境中配置 `FEISHU_BASE_URL`,当前参数解析会读取其默认值 |
日期范围的结束日期不能早于开始日期。本地流水线未传日期时会从默认开始日期一直同步到昨天;补数时建议明确传入两个日期。
完整参数可在配置好 `.env` 后查看:
```bash
python run_project_bitable_pipeline.py --help
```
## 常见问题
### 映射表路径不存在
如果出现 `FileNotFoundError`,且路径仍指向其他机器上的 `/Users/idss/...`,请修改 `.env` 中的 `PROJECT_MAPPING_FILE`,或删除该配置以使用项目自带文件。
也可以在命令中添加以下参数,直接覆盖旧路径:
```bash
--mapping-file ./店铺映射表.xlsx
```
### 找不到对应月份的飞书表
例如“羽心堂”只有 3—8 月表,执行 9 月数据时会报 `未找到匹配表`。临时跳过可添加 `--skip-missing-tables`;补齐月份表后,再运行对应日期范围。
### 表名不规范或匹配到多个表
创建 `table_map.json`,键格式为 `项目:YYYY-MM`,值为飞书中实际存在的完整表名:
```json
{
"大眼睛:2026-09": "大眼睛买买买-9月",
"羽心堂:2026-09": "羽心堂-9月"
}
```
执行时添加 `--table-map-file ./table_map.json`。自动匹配支持项目别名和月份文本,跨年份存在同名月份表时应显式指定映射。
## Prefect 执行与定时调度
手动触发一次 Prefect 数据同步:
```bash
python prefect_gmv_scheduler.py \
--mode sync \
--start-date 2026-09-01 \
--end-date 2026-09-07 \
--skip-missing-tables
```
| 模式 | 行为 |
| --- | --- |
| `--mode sync` | 只同步数据 |
| `--mode notify` | 只发送通知 |
| `--mode all` | 先同步再通知,默认模式 |
Prefect 同步未显式指定日期时,默认处理截至昨天的最近 5 天,并以 `SYNC_START_DATE` 为最早边界。缺失月份表会自动跳过,其余表仍会写入;同步结束后,缺失的“项目 + 月份”会汇总为一条飞书提醒。通知未指定日期时,优先读取上次成功同步保存的日期范围。
`prefect.yaml` 定义了北京时间每天 08:00 同步、09:00 通知两条调度。部署前需准备可访问的 Prefect Server,并配置 `PREFECT_API_URL`。
```bash
docker compose up -d --build
docker exec -it prefect-gmv-worker sh -lc 'cd /app && prefect deploy --all'
```
Compose 启动的是等待调度任务的 worker。当前 Prefect deployment 默认跳过缺失月份表,并在同步结束后推送一条汇总提醒。修改 `prefect.yaml` 后,执行 `prefect deploy --all` 注册新配置。
更多部署说明见项目中的 `DOCKER_PREFECT_USAGE.md`;其中示例的本机目录需要替换为实际项目目录。
## 文件说明与日志
| 文件 | 用途 |
| --- | --- |
| `run_project_bitable_pipeline.py` | 本地完整流水线入口,使用日期 / 指标行结构 |
| `project_bitable_common.py` | SQL、店铺映射、表匹配、飞书 API、同步与通知逻辑 |
| `prefect_gmv_scheduler.py` | Prefect flow、日期窗口和执行模式 |
| `generate_project_sales_csv.py` | 独立销售额同步脚本 |
| `generate_project_refund_csv.py` | 独立退款额同步脚本 |
| `generate_project_gmv_csv.py` | 从飞书销售额和退款额字段计算并回写 GMV |
| `店铺映射表.xlsx` | BI 店铺与业务店铺名称映射 |
| `logging_config.py` | 控制台与轮转文件日志 |
| `prefect.yaml` | 定时任务与 work pool 配置 |
| `docker-compose.yaml` / `Dockerfile` | 容器运行与构建配置 |
三个 `generate_project_*_csv.py` 文件虽带有 `csv` 后缀,实际执行飞书同步;它们使用以店铺为记录、日期指标为字段的结构,与当前完整流水线的表结构不同。日常运行使用完整流水线入口。
默认日志写入 `logs/gmv_sync.log`,单个文件达到 10 MiB 后轮转,保留 7 个备份。
```bash
tail -f logs/gmv_sync.log
```
Docker worker 日志:
```bash
docker logs -f prefect-gmv-worker
```
Binary file not shown.
+4 -4
View File
@@ -7,14 +7,14 @@ pull: []
deployments: deployments:
- name: gmv-daily-data-sync-8am - name: gmv-daily-data-sync-8am
version: "1.0.0" version: "1.0.1"
description: "每天上午 8 点执行 GMV 数据同步并写入飞书多维表格。" description: "每天上午 8 点执行 GMV 数据同步并写入飞书多维表格。"
entrypoint: prefect_gmv_scheduler.py:gmv_daily_data_sync_flow entrypoint: prefect_gmv_scheduler.py:gmv_daily_data_sync_flow
parameters: parameters:
start_date: null start_date: null
end_date: null end_date: null
dry_run: false dry_run: false
skip_missing_tables: false skip_missing_tables: true
table_map_file: "" table_map_file: ""
tags: tags:
- gmv - gmv
@@ -29,14 +29,14 @@ deployments:
name: gmv-analytics-pool name: gmv-analytics-pool
- name: gmv-daily-notify-9am - name: gmv-daily-notify-9am
version: "1.0.0" version: "1.0.1"
description: "每天上午 9 点推送 GMV 数据同步结果到飞书群。" description: "每天上午 9 点推送 GMV 数据同步结果到飞书群。"
entrypoint: prefect_gmv_scheduler.py:gmv_daily_notify_flow entrypoint: prefect_gmv_scheduler.py:gmv_daily_notify_flow
parameters: parameters:
start_date: null start_date: null
end_date: null end_date: null
dry_run: false dry_run: false
skip_missing_tables: false skip_missing_tables: true
table_map_file: "" table_map_file: ""
tags: tags:
- gmv - gmv
+18 -4
View File
@@ -39,6 +39,7 @@ from project_bitable_common import (
load_dotenv, load_dotenv,
log_step, log_step,
run_daily_sync_for_date, run_daily_sync_for_date,
send_missing_table_notification,
send_sync_notification, send_sync_notification,
validate_date, validate_date,
) )
@@ -72,7 +73,7 @@ def load_sync_config(
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
dry_run: bool = False, dry_run: bool = False,
skip_missing_tables: bool = False, skip_missing_tables: bool = True,
table_map_file: str = "", table_map_file: str = "",
) -> SyncConfig: ) -> SyncConfig:
load_dotenv() load_dotenv()
@@ -174,7 +175,7 @@ def gmv_daily_data_sync_flow(
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
dry_run: bool = False, dry_run: bool = False,
skip_missing_tables: bool = False, skip_missing_tables: bool = True,
table_map_file: str = "", table_map_file: str = "",
) -> None: ) -> None:
config = load_sync_config( config = load_sync_config(
@@ -205,6 +206,14 @@ def gmv_daily_data_sync_flow(
run_daily_sync_for_date(context, stat_date) run_daily_sync_for_date(context, stat_date)
if not config.dry_run: if not config.dry_run:
mark_sync_window_completed(sync_window) mark_sync_window_completed(sync_window)
if context.missing_tables:
log_step(f"Prefect 数据同步发现缺失表: {len(context.missing_tables)} 个")
send_missing_table_notification(
context,
context.missing_tables,
sync_window.start_date,
sync_window.end_date,
)
log_step("Prefect 数据同步完成") log_step("Prefect 数据同步完成")
@@ -213,7 +222,7 @@ def gmv_daily_notify_flow(
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
dry_run: bool = False, dry_run: bool = False,
skip_missing_tables: bool = False, skip_missing_tables: bool = True,
table_map_file: str = "", table_map_file: str = "",
) -> None: ) -> None:
config = load_sync_config( config = load_sync_config(
@@ -235,7 +244,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--end-date", default="", help="结束日期,格式 YYYY-MM-DD,默认昨天") parser.add_argument("--end-date", default="", help="结束日期,格式 YYYY-MM-DD,默认昨天")
parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径") parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径")
parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口") parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口")
parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过") parser.add_argument(
"--skip-missing-tables",
action=argparse.BooleanOptionalAction,
default=True,
help="找不到对应月份的飞书表时跳过并在同步结束后推送提醒(默认开启)",
)
parser.add_argument("--serve", action="store_true", help="启动 Prefect deployments,按 8 点同步、9 点通知 定时执行") parser.add_argument("--serve", action="store_true", help="启动 Prefect deployments,按 8 点同步、9 点通知 定时执行")
parser.add_argument( parser.add_argument(
"--mode", "--mode",
+259 -40
View File
@@ -1,8 +1,9 @@
import json import json
import os import os
import re import re
import ssl
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
@@ -12,6 +13,8 @@ from urllib.parse import urlparse
import psycopg2 import psycopg2
import requests import requests
from openpyxl import load_workbook from openpyxl import load_workbook
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from logging_config import get_logger, setup_logging from logging_config import get_logger, setup_logging
@@ -23,6 +26,51 @@ DEFAULT_START_DATE = "2026-04-01"
DEFAULT_STORE_FIELD_NAME = "店铺名称" DEFAULT_STORE_FIELD_NAME = "店铺名称"
STORE_FIELD_CANDIDATES = ["业务店铺名称", "店铺名称", "门店名称", "店铺", "名称"] STORE_FIELD_CANDIDATES = ["业务店铺名称", "店铺名称", "门店名称", "店铺", "名称"]
SUPPORTED_PROJECTS = ["大眼睛", "羽心堂", "MMMJ", "EM", "春回盏", "BROKEN ZOO"] SUPPORTED_PROJECTS = ["大眼睛", "羽心堂", "MMMJ", "EM", "春回盏", "BROKEN ZOO"]
FEISHU_HTTP_RETRY_COUNT = 3
class TLS12HTTPAdapter(HTTPAdapter):
"""Requests adapter that keeps the TLS policy consistent for Feishu calls."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._ssl_context = ssl.create_default_context()
# TLS 1.3 handshakes are reset by the current server-side network path.
# Keep certificate verification enabled while using the compatible TLS 1.2.
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
super().__init__(*args, **kwargs)
def init_poolmanager(self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any) -> None:
pool_kwargs["ssl_context"] = self._ssl_context
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any:
proxy_kwargs["ssl_context"] = self._ssl_context
return super().proxy_manager_for(proxy, **proxy_kwargs)
def create_feishu_session(*, retry_post: bool = False) -> requests.Session:
"""Create a verified TLS 1.2 session for Feishu APIs with bounded retries.
Only the token endpoint opts into POST retries because obtaining a token is
idempotent. Data-writing POST requests retain their original at-most-once
behaviour, while all calls still use TLS 1.2.
"""
retry = Retry(
total=FEISHU_HTTP_RETRY_COUNT,
connect=FEISHU_HTTP_RETRY_COUNT,
read=FEISHU_HTTP_RETRY_COUNT,
status=0,
other=0,
backoff_factor=1,
allowed_methods=None if retry_post else frozenset({"GET", "HEAD", "OPTIONS"}),
raise_on_status=False,
)
session = requests.Session()
adapter = TLS12HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
return session
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -46,6 +94,7 @@ WITH base_data AS (
f.店铺主键, f.店铺主键,
s.店铺名称 AS 原始店铺名称, s.店铺名称 AS 原始店铺名称,
p.品牌名称, p.品牌名称,
p.商品名称,
f.业务模式, f.业务模式,
f.实付分摊金额 f.实付分摊金额
FROM analytics_layer.fact_sales f FROM analytics_layer.fact_sales f
@@ -64,23 +113,30 @@ brand_split AS (
原始店铺名称, 原始店铺名称,
业务模式, 业务模式,
CASE CASE
WHEN COALESCE(原始店铺名称, '') LIKE '%%羽心堂分销%%'
THEN '羽心堂分销'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '羽心堂' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE '羽心堂'
THEN 原始店铺名称 || '_羽心堂' THEN 原始店铺名称 || '_羽心堂'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '春回盏' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE '春回盏'
THEN 原始店铺名称 || '_春回盏' THEN 原始店铺名称 || '_春回盏'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'Mama Mujer' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE 'Mama Mujer'
THEN 原始店铺名称 || '_Mama Mujer' THEN 原始店铺名称 || '_Mama Mujer'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'ERBA MIGINA' WHEN 店铺主键 IN (1,28,29)
AND (
COALESCE(品牌名称, '') LIKE 'ERBA MIGINA'
OR COALESCE(商品名称, '') ILIKE '%%ERBA MIGINA%%'
OR COALESCE(商品名称, '') ~* '(^|[^A-Za-z])EM([^A-Za-z]|$)'
)
THEN 原始店铺名称 || '_ERBA MIGINA' THEN 原始店铺名称 || '_ERBA MIGINA'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'BROKEN ZOO' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE 'BROKEN ZOO'
THEN 原始店铺名称 || '_BROKEN ZOO' THEN 原始店铺名称 || '_BROKEN ZOO'
WHEN 店铺主键 IN (1,28,29) WHEN 店铺主键 IN (1,28,29)
THEN 原始店铺名称 || '_其他' THEN 原始店铺名称 || '_其他'
WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE '羽心堂' WHEN 店铺主键 IN (3,9) AND COALESCE(品牌名称, '') LIKE '羽心堂'
THEN 原始店铺名称 || '_羽心堂' THEN 原始店铺名称 || '_羽心堂'
WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE 'Mama Mujer' WHEN 店铺主键 IN (3,9) AND COALESCE(品牌名称, '') LIKE 'Mama Mujer'
THEN 原始店铺名称 || '_Mama Mujer' THEN 原始店铺名称 || '_Mama Mujer'
WHEN 店铺主键 IN (3,9) WHEN 店铺主键 IN (3,9)
THEN 原始店铺名称 || '_其他' THEN 原始店铺名称 || '_其他'
@@ -103,8 +159,8 @@ shop_brand_detail AS (
SUM(实付分摊金额) AS 总销售额 SUM(实付分摊金额) AS 总销售额
FROM brand_split FROM brand_split
GROUP BY 1,2,3 GROUP BY 1,2,3
) ),
union_data AS (
SELECT SELECT
统计日期, 统计日期,
店铺名称, 店铺名称,
@@ -117,18 +173,16 @@ WHERE NOT (
UNION ALL UNION ALL
-- 店铺合计(使用 dim_shop 店铺名)
SELECT SELECT
统计日期, 统计日期,
原始店铺名称 AS 店铺名称, 原始店铺名称 AS 店铺名称,
SUM(实付分摊金额) SUM(实付分摊金额) AS 总销售额
FROM base_data FROM base_data
WHERE 店铺主键 IN (1,28,29,3) WHERE 店铺主键 IN (1,28,29,3)
GROUP BY 1,2 GROUP BY 1,2
UNION ALL UNION ALL
-- 权益金(充值购物金单独统计)
SELECT SELECT
统计日期, 统计日期,
原始店铺名称 || '_权益金' AS 店铺名称, 原始店铺名称 || '_权益金' AS 店铺名称,
@@ -137,6 +191,14 @@ FROM base_data
WHERE 店铺主键 IN (5,11,14,22,26) WHERE 店铺主键 IN (5,11,14,22,26)
AND 业务模式 = '充值购物金' AND 业务模式 = '充值购物金'
GROUP BY 1,2 GROUP BY 1,2
)
SELECT
统计日期,
店铺名称,
SUM(总销售额) AS 总销售额
FROM union_data
GROUP BY 1,2
ORDER BY 统计日期 DESC, 总销售额 DESC; ORDER BY 统计日期 DESC, 总销售额 DESC;
""", """,
@@ -147,13 +209,53 @@ ORDER BY 统计日期 DESC, 总销售额 DESC;
field_suffix="退款额", field_suffix="退款额",
sql_template=""" sql_template="""
SET TIMEZONE = 'Asia/Shanghai'; SET TIMEZONE = 'Asia/Shanghai';
WITH base_data AS ( WITH sales_base AS (
SELECT
fs.*,
TRIM(fs."平台订单号") AS "原平台订单号",
split_part(TRIM(fs."平台订单号"), '-', 1) AS "主订单号"
FROM analytics_layer.fact_sales fs
WHERE fs."订单状态" IN ('已关闭', '已取消', '交易关闭')
AND NULLIF(TRIM(fs."平台订单号"), '') IS NOT NULL
AND fs."支付时间" IS NOT NULL
AND fs."支付时间" >= %(start_ts)s
AND fs."支付时间" < %(end_ts_exclusive)s
),
missing_order_keys AS (
SELECT DISTINCT
sb."主订单号"
FROM sales_base sb
WHERE NOT EXISTS (
SELECT 1
FROM analytics_layer.fact_returns fr
WHERE split_part(TRIM(fr."平台订单号"), '-', 1) = sb."主订单号"
AND NULLIF(TRIM(fr."平台订单号"), '') IS NOT NULL
)
),
missing_return_sales AS (
SELECT
sb."支付时间"::DATE AS 统计日期,
sb."店铺主键",
SUM(sb."实付分摊金额") AS 补充退款金额
FROM sales_base sb
JOIN missing_order_keys mok
ON sb."主订单号" = mok."主订单号"
WHERE sb."店铺主键" NOT IN (5,11,14,22,26)
GROUP BY
sb."支付时间"::DATE,
sb."店铺主键"
),
base_data AS (
SELECT SELECT
f.完结时间::DATE AS 统计日期, f.完结时间::DATE AS 统计日期,
f.店铺主键, f.店铺主键,
f.平台订单号, f.平台订单号,
s.店铺名称 AS 原始店铺名称, s.店铺名称 AS 原始店铺名称,
p.品牌名称, p.品牌名称,
p.商品名称,
f.退款金额 f.退款金额
FROM analytics_layer.fact_returns f FROM analytics_layer.fact_returns f
LEFT JOIN analytics_layer.dim_shop s LEFT JOIN analytics_layer.dim_shop s
@@ -165,10 +267,25 @@ WITH base_data AS (
AND f.完结时间 < %(end_ts_exclusive)s AND f.完结时间 < %(end_ts_exclusive)s
), ),
recharge_orders AS ( sales_order_flag AS (
SELECT DISTINCT 平台订单号, 店铺主键 SELECT
FROM analytics_layer.fact_sales fs.平台订单号,
WHERE 业务模式 = '充值购物金' fs.店铺主键,
TRUE AS 订单存在于销售表,
BOOL_OR(fs.业务模式 = '充值购物金') AS 是否充值购物金订单
FROM analytics_layer.fact_sales fs
INNER JOIN (
SELECT DISTINCT
平台订单号,
店铺主键
FROM base_data
WHERE 店铺主键 IN (5,11,14,22,26)
) r
ON fs.平台订单号 = r.平台订单号
AND fs.店铺主键 = r.店铺主键
GROUP BY
fs.平台订单号,
fs.店铺主键
), ),
brand_split AS ( brand_split AS (
@@ -178,22 +295,27 @@ brand_split AS (
原始店铺名称, 原始店铺名称,
CASE CASE
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '羽心堂' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE '羽心堂'
THEN 原始店铺名称 || '_羽心堂' THEN 原始店铺名称 || '_羽心堂'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '春回盏' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE '春回盏'
THEN 原始店铺名称 || '_春回盏' THEN 原始店铺名称 || '_春回盏'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'Mama Mujer' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE 'Mama Mujer'
THEN 原始店铺名称 || '_Mama Mujer' THEN 原始店铺名称 || '_Mama Mujer'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'ERBA MIGINA' WHEN 店铺主键 IN (1,28,29)
AND (
COALESCE(品牌名称, '') LIKE 'ERBA MIGINA'
OR COALESCE(商品名称, '') ILIKE '%%ERBA MIGINA%%'
OR COALESCE(商品名称, '') ~* '(^|[^A-Za-z])EM([^A-Za-z]|$)'
)
THEN 原始店铺名称 || '_ERBA MIGINA' THEN 原始店铺名称 || '_ERBA MIGINA'
WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'BROKEN ZOO' WHEN 店铺主键 IN (1,28,29) AND COALESCE(品牌名称, '') LIKE 'BROKEN ZOO'
THEN 原始店铺名称 || '_BROKEN ZOO' THEN 原始店铺名称 || '_BROKEN ZOO'
WHEN 店铺主键 IN (1,28,29) WHEN 店铺主键 IN (1,28,29)
THEN 原始店铺名称 || '_其他' THEN 原始店铺名称 || '_其他'
WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE '羽心堂' WHEN 店铺主键 IN (3,9) AND COALESCE(品牌名称, '') LIKE '羽心堂'
THEN 原始店铺名称 || '_羽心堂' THEN 原始店铺名称 || '_羽心堂'
WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE 'Mama Mujer' WHEN 店铺主键 IN (3,9) AND COALESCE(品牌名称, '') LIKE 'Mama Mujer'
THEN 原始店铺名称 || '_Mama Mujer' THEN 原始店铺名称 || '_Mama Mujer'
WHEN 店铺主键 IN (3,9) WHEN 店铺主键 IN (3,9)
THEN 原始店铺名称 || '_其他' THEN 原始店铺名称 || '_其他'
@@ -215,8 +337,9 @@ shop_brand_detail AS (
SUM(退款金额) AS 总退款金额 SUM(退款金额) AS 总退款金额
FROM brand_split FROM brand_split
GROUP BY 1,2,3 GROUP BY 1,2,3
) ),
union_data AS (
SELECT SELECT
统计日期, 统计日期,
店铺名称, 店铺名称,
@@ -229,7 +352,6 @@ WHERE NOT (
UNION ALL UNION ALL
-- 店铺合计
SELECT SELECT
统计日期, 统计日期,
原始店铺名称 AS 店铺名称, 原始店铺名称 AS 店铺名称,
@@ -240,18 +362,42 @@ GROUP BY 1,2
UNION ALL UNION ALL
-- 权益金退款
SELECT SELECT
r.统计日期, r.统计日期,
r.原始店铺名称 || '_权益金' AS 店铺名称, r.原始店铺名称 || '_权益金' AS 店铺名称,
SUM(r.退款金额) AS 总退款金额 SUM(r.退款金额) AS 总退款金额
FROM base_data r FROM base_data r
JOIN recharge_orders ro LEFT JOIN sales_order_flag sof
ON r.平台订单号 = ro.平台订单号 ON r.平台订单号 = sof.平台订单号
AND r.店铺主键 = ro.店铺主键 AND r.店铺主键 = sof.店铺主键
WHERE r.店铺主键 IN (5,11,14,22,26) WHERE r.店铺主键 IN (5,11,14,22,26)
AND (
sof.订单存在于销售表 IS NULL
OR sof.是否充值购物金订单 = TRUE
)
GROUP BY 1,2 GROUP BY 1,2
UNION ALL
SELECT
mrs.统计日期,
COALESCE(s.店铺名称, '未知店铺') AS 店铺名称,
SUM(mrs.补充退款金额) AS 总退款金额
FROM missing_return_sales mrs
LEFT JOIN analytics_layer.dim_shop s
ON mrs.店铺主键 = s.店铺主键
GROUP BY 1,2
)
SELECT
统计日期,
店铺名称,
SUM(总退款金额) AS 总退款金额
FROM union_data
GROUP BY
统计日期,
店铺名称
ORDER BY 统计日期 DESC, 总退款金额 DESC; ORDER BY 统计日期 DESC, 总退款金额 DESC;
""", """,
), ),
@@ -282,6 +428,17 @@ class MetricSyncArtifacts:
created_fields: set[str] created_fields: set[str]
@dataclass(frozen=True, order=True)
class MissingTable:
project: str
year: int
month: int
class TargetTableNotFoundError(ValueError):
"""Raised when a project's monthly Feishu table does not exist."""
@dataclass(frozen=True) @dataclass(frozen=True)
class DailySyncConfig: class DailySyncConfig:
base_url: str base_url: str
@@ -302,6 +459,7 @@ class DailySyncContext:
mapping: dict[str, str] mapping: dict[str, str]
table_overrides: dict[str, str] table_overrides: dict[str, str]
config: DailySyncConfig config: DailySyncConfig
missing_tables: set[MissingTable] = field(default_factory=set)
def _log_to_prefect(level_name: str, message: str) -> None: def _log_to_prefect(level_name: str, message: str) -> None:
@@ -430,7 +588,7 @@ def classify_project(store_name: str) -> str:
def get_project_table_aliases(project: str) -> list[str]: def get_project_table_aliases(project: str) -> list[str]:
aliases = { aliases = {
"大眼睛": ["大眼睛", "大眼睛买买买"], "大眼睛": ["大眼睛买买买"],
"羽心堂": ["羽心堂"], "羽心堂": ["羽心堂"],
"MMMJ": ["MMMJ", "Mama Mujer", "MAMA MUJER"], "MMMJ": ["MMMJ", "Mama Mujer", "MAMA MUJER"],
"EM": ["EM", "ERBA MIGINA"], "EM": ["EM", "ERBA MIGINA"],
@@ -569,8 +727,9 @@ def get_feishu_token() -> str:
app_secret = os.getenv("FEISHU_APP_SECRET") app_secret = os.getenv("FEISHU_APP_SECRET")
if not app_id or not app_secret: if not app_id or not app_secret:
raise ValueError("缺少飞书环境变量 FEISHU_APP_ID / FEISHU_APP_SECRET") raise ValueError("缺少飞书环境变量 FEISHU_APP_ID / FEISHU_APP_SECRET")
log_step("开始获取飞书 tenant_access_token") log_step(f"开始获取飞书 tenant_access_token(TLS 1.2,最多重试 {FEISHU_HTTP_RETRY_COUNT} 次)")
resp = requests.post( with create_feishu_session(retry_post=True) as session:
resp = session.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret}, json={"app_id": app_id, "app_secret": app_secret},
timeout=30, timeout=30,
@@ -592,7 +751,7 @@ class FeishuBitableClient:
self._fields_cache: dict[str, list[dict[str, Any]]] = {} self._fields_cache: dict[str, list[dict[str, Any]]] = {}
self._records_cache: dict[str, list[dict[str, Any]]] = {} self._records_cache: dict[str, list[dict[str, Any]]] = {}
self._tables_cache: list[TableInfo] | None = None self._tables_cache: list[TableInfo] | None = None
self.session = requests.Session() self.session = create_feishu_session()
self.session.headers.update( self.session.headers.update(
{ {
"Authorization": f"Bearer {self.token}", "Authorization": f"Bearer {self.token}",
@@ -840,6 +999,7 @@ def send_feishu_sync_card(
base_url: str, base_url: str,
start_date: date, start_date: date,
end_date: date, end_date: date,
extra_content: str = "",
) -> None: ) -> None:
if not webhook_url: if not webhook_url:
log_step("未配置飞书群机器人 webhook,跳过群消息推送") log_step("未配置飞书群机器人 webhook,跳过群消息推送")
@@ -860,6 +1020,7 @@ def send_feishu_sync_card(
"content": ( "content": (
f"**同步范围**\n{start_date.isoformat()} 至 {end_date.isoformat()}\n\n" f"**同步范围**\n{start_date.isoformat()} 至 {end_date.isoformat()}\n\n"
f"**多维表格**\n[点击打开多维表格]({base_url})" f"**多维表格**\n[点击打开多维表格]({base_url})"
+ (f"\n\n{extra_content}" if extra_content else "")
), ),
}, },
} }
@@ -867,7 +1028,8 @@ def send_feishu_sync_card(
}, },
} }
log_step("开始推送飞书群卡片消息") log_step("开始推送飞书群卡片消息")
resp = requests.post(webhook_url, json=card, timeout=30) with create_feishu_session() as session:
resp = session.post(webhook_url, json=card, timeout=30)
resp.raise_for_status() resp.raise_for_status()
payload = resp.json() payload = resp.json()
if payload.get("code") not in (0, None): if payload.get("code") not in (0, None):
@@ -883,11 +1045,18 @@ def upload_feishu_image(image_path: str, token: str | None = None) -> str:
raise FileNotFoundError(f"未找到预览图文件: {path}") raise FileNotFoundError(f"未找到预览图文件: {path}")
token = token or get_feishu_token() token = token or get_feishu_token()
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
with create_feishu_session() as session:
with path.open("rb") as image_file: with path.open("rb") as image_file:
files = {"image": (path.name, image_file, "application/octet-stream")} files = {"image": (path.name, image_file, "application/octet-stream")}
data = {"image_type": "message"} data = {"image_type": "message"}
log_step(f"开始上传飞书消息图片: {path}") log_step(f"开始上传飞书消息图片: {path}")
resp = requests.post("https://open.feishu.cn/open-apis/im/v1/images", headers=headers, data=data, files=files, timeout=60) resp = session.post(
"https://open.feishu.cn/open-apis/im/v1/images",
headers=headers,
data=data,
files=files,
timeout=60,
)
resp.raise_for_status() resp.raise_for_status()
payload = resp.json() payload = resp.json()
if payload.get("code") != 0: if payload.get("code") != 0:
@@ -907,6 +1076,7 @@ def send_feishu_app_card(
end_date: date, end_date: date,
image_path: str = "", image_path: str = "",
token: str | None = None, token: str | None = None,
extra_content: str = "",
) -> None: ) -> None:
if not chat_id: if not chat_id:
log_step("未配置 FEISHU_CHAT_ID,跳过应用机器人卡片推送") log_step("未配置 FEISHU_CHAT_ID,跳过应用机器人卡片推送")
@@ -921,6 +1091,7 @@ def send_feishu_app_card(
"content": ( "content": (
f"**同步范围**\n{start_date.isoformat()} 至 {end_date.isoformat()}\n\n" f"**同步范围**\n{start_date.isoformat()} 至 {end_date.isoformat()}\n\n"
f"**多维表格**\n[{title}]({base_url})" f"**多维表格**\n[{title}]({base_url})"
+ (f"\n\n{extra_content}" if extra_content else "")
), ),
}, },
} }
@@ -957,7 +1128,8 @@ def send_feishu_app_card(
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
} }
log_step(f"开始通过应用机器人发送群卡片: chat_id={chat_id}") log_step(f"开始通过应用机器人发送群卡片: chat_id={chat_id}")
resp = requests.post( with create_feishu_session() as session:
resp = session.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id",
headers=headers, headers=headers,
json=message, json=message,
@@ -1020,7 +1192,7 @@ def find_target_table(
return candidates[0] return candidates[0]
if not candidates: if not candidates:
all_table_names = [table.name for table in tables] all_table_names = [table.name for table in tables]
raise ValueError( raise TargetTableNotFoundError(
f"未找到匹配表: 项目={project}, 月份={year:04d}-{month:02d}, 项目相关表={project_related_tables or '[]'}, 全部表={all_table_names}" f"未找到匹配表: 项目={project}, 月份={year:04d}-{month:02d}, 项目相关表={project_related_tables or '[]'}, 全部表={all_table_names}"
) )
raise ValueError( raise ValueError(
@@ -1617,8 +1789,15 @@ def run_daily_sync_for_date(context: DailySyncContext, stat_date: date) -> None:
} }
try: try:
table = find_target_table(context.tables, project, stat_date.year, stat_date.month, context.table_overrides) table = find_target_table(context.tables, project, stat_date.year, stat_date.month, context.table_overrides)
except ValueError as exc: except TargetTableNotFoundError as exc:
if context.config.skip_missing_tables: if context.config.skip_missing_tables:
missing_table = MissingTable(project=project, year=stat_date.year, month=stat_date.month)
if missing_table not in context.missing_tables:
context.missing_tables.add(missing_table)
log_step(
"记录缺失表: "
f"项目={project}, 月份={stat_date.year:04d}-{stat_date.month:02d}"
)
log_step(f"跳过: {exc}") log_step(f"跳过: {exc}")
continue continue
raise raise
@@ -1717,3 +1896,43 @@ def send_sync_notification(
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
) )
def send_missing_table_notification(
context: DailySyncContext,
missing_tables: set[MissingTable],
start_date: date,
end_date: date,
) -> None:
"""Notify Feishu once after a sync run about tables skipped because they do not exist."""
if context.config.dry_run or not missing_tables:
return
table_lines = "\n".join(
f"- {item.project}:{item.year:04d}-{item.month:02d}" for item in sorted(missing_tables)
)
extra_content = (
"**以下月份表不存在,已跳过写入**\n"
f"{table_lines}\n\n"
"请创建对应月份表后重新执行补数。"
)
title = "GMV 数据同步:缺失表提醒"
if context.config.chat_id:
send_feishu_app_card(
chat_id=context.config.chat_id,
title=title,
base_url=context.config.base_url,
start_date=start_date,
end_date=end_date,
token=context.client.token,
extra_content=extra_content,
)
return
send_feishu_sync_card(
webhook_url=context.config.group_webhook,
title=title,
base_url=context.config.base_url,
start_date=start_date,
end_date=end_date,
extra_content=extra_content,
)
+1
View File
@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"httpx>=0.25.0", "httpx>=0.25.0",
"importlib-metadata>=8.0.0",
"openpyxl>=3.1.2", "openpyxl>=3.1.2",
"pandas", "pandas",
"prefect==3.6.8", "prefect==3.6.8",
+4
View File
@@ -34,6 +34,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径") parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径")
parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口") parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口")
parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过") parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过")
parser.add_argument("--no-notify", action="store_true", help="只写入多维表格,不推送飞书群通知")
return parser.parse_args() return parser.parse_args()
@@ -61,6 +62,9 @@ def main() -> None:
) )
for stat_date in iter_dates(start_date, end_date): for stat_date in iter_dates(start_date, end_date):
run_daily_sync_for_date(context, stat_date) run_daily_sync_for_date(context, stat_date)
if args.no_notify:
log_step("已跳过飞书群通知")
else:
send_sync_notification(context, start_date, end_date) send_sync_notification(context, start_date, end_date)
log_step("流水线完成") log_step("流水线完成")
+29
View File
@@ -0,0 +1,29 @@
import ssl
from project_bitable_common import FEISHU_HTTP_RETRY_COUNT, TLS12HTTPAdapter, create_feishu_session
def test_feishu_session_forces_tls_12() -> None:
session = create_feishu_session()
try:
adapter = session.get_adapter("https://open.feishu.cn")
assert isinstance(adapter, TLS12HTTPAdapter)
context = adapter._ssl_context
assert context.minimum_version == ssl.TLSVersion.TLSv1_2
assert context.maximum_version == ssl.TLSVersion.TLSv1_2
finally:
session.close()
def test_token_session_can_retry_post_but_regular_session_cannot() -> None:
regular_session = create_feishu_session()
token_session = create_feishu_session(retry_post=True)
try:
regular_retry = regular_session.get_adapter("https://open.feishu.cn").max_retries
token_retry = token_session.get_adapter("https://open.feishu.cn").max_retries
assert regular_retry.total == FEISHU_HTTP_RETRY_COUNT
assert regular_retry.allowed_methods == frozenset({"GET", "HEAD", "OPTIONS"})
assert token_retry.allowed_methods is None
finally:
regular_session.close()
token_session.close()
Binary file not shown.