From 679edd1f5859e410b362e9f3b6aac24e8bafba1e Mon Sep 17 00:00:00 2001 From: BXY0414 <1079823339@qq.com> Date: Fri, 13 Mar 2026 13:10:20 +0800 Subject: [PATCH] refactor: consolidate bitable pipeline and add legacy reconstructed backups --- .DS_Store | Bin 0 -> 6148 bytes .vscode/settings.json | 4 + .../project_bitable_common.cpython-312.pyc | Bin 0 -> 48616 bytes generate_project_gmv_csv.py | 46 + generate_project_refund_csv.py | 53 + generate_project_sales_csv.py | 53 + project.zip | Bin 0 -> 11974 bytes project_bitable_common.py | 919 ++++++++++++++++++ run_project_bitable_pipeline.py | 74 ++ 店铺映射表.xlsx | Bin 0 -> 10893 bytes 10 files changed, 1149 insertions(+) create mode 100644 .DS_Store create mode 100644 .vscode/settings.json create mode 100644 __pycache__/project_bitable_common.cpython-312.pyc create mode 100644 generate_project_gmv_csv.py create mode 100644 generate_project_refund_csv.py create mode 100644 generate_project_sales_csv.py create mode 100644 project.zip create mode 100644 project_bitable_common.py create mode 100644 run_project_bitable_pipeline.py create mode 100644 店铺映射表.xlsx diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cc5d92e5bb180a1158c2134a636247d60c0b4384 GIT binary patch literal 6148 zcmeHKO-lnY5S{3P#bW7E5p_=<3;u((=)tpiD_!07n|{%)qMr6BcvbAh`V;(T-u0qy zCIR=$B2q*p1IbIXGcS{u&88tDRX^KbBdQWn35C&_MO9$@oJ-E8Oiu-Qj#{v3EcAufNsmhC!)3@HNEc#JfwTFS64e}T0P{sBfh0q+=Ydin-;AKZs@hI#Fe zIO=-qbdE(5L&?;aR`0EPr_rXPB^bE!t< z-C}6-U4mm=gPy@yBc8yN4h8B^Wv&=Xhoj#$exAWtqYfu!E+5K_tjrBX$>`vB#hsL= z(biT0t3X3!AJ2tiWr``+yHt8V~yy6**^jzgRQIrKdQhxaQFgX literal 0 HcmV?d00001 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4b5a294 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda" +} \ No newline at end of file diff --git a/__pycache__/project_bitable_common.cpython-312.pyc b/__pycache__/project_bitable_common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..acefc01dfe7e48d74505c8ccfc1c7aa65226836b GIT binary patch literal 48616 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 If~niZ-#vYmUH||9 literal 0 HcmV?d00001 diff --git a/generate_project_gmv_csv.py b/generate_project_gmv_csv.py new file mode 100644 index 0000000..f4bdb17 --- /dev/null +++ b/generate_project_gmv_csv.py @@ -0,0 +1,46 @@ +import argparse + +from project_bitable_common import ( + DEFAULT_END_DATE, + DEFAULT_START_DATE, + DEFAULT_STORE_FIELD_NAME, + get_base_url, + load_dotenv, + log_step, + run_gmv_sync, + validate_date, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="从飞书多维表格读取销售额和退款额,计算 GMV 并写回。") + parser.add_argument("--start-date", default=DEFAULT_START_DATE, help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=DEFAULT_END_DATE, help="结束日期,格式 YYYY-MM-DD") + parser.add_argument("--base-url", default=get_base_url(), help="飞书多维表格地址") + parser.add_argument("--store-field-name", default=DEFAULT_STORE_FIELD_NAME, help="飞书中的店铺字段名") + parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口") + parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过") + return parser.parse_args() + + +def main() -> None: + load_dotenv() + args = parse_args() + start_date = validate_date(args.start_date) + end_date = validate_date(args.end_date) + if end_date < start_date: + raise ValueError("end-date 不能早于 start-date") + log_step("开始执行 GMV 同步") + run_gmv_sync( + start_date=start_date, + end_date=end_date, + base_url=args.base_url, + store_field_name=args.store_field_name, + dry_run=args.dry_run, + skip_missing_tables=args.skip_missing_tables, + ) + log_step("GMV 同步完成") + + +if __name__ == "__main__": + main() diff --git a/generate_project_refund_csv.py b/generate_project_refund_csv.py new file mode 100644 index 0000000..38baa3f --- /dev/null +++ b/generate_project_refund_csv.py @@ -0,0 +1,53 @@ +import argparse +from pathlib import Path + +from project_bitable_common import ( + DEFAULT_END_DATE, + DEFAULT_START_DATE, + DEFAULT_STORE_FIELD_NAME, + get_base_url, + get_mapping_file, + load_dotenv, + log_step, + run_metric_sync, + validate_date, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="从 PostgreSQL 拉取退款额并写入飞书多维表格。") + parser.add_argument("--start-date", default=DEFAULT_START_DATE, help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=DEFAULT_END_DATE, help="结束日期,格式 YYYY-MM-DD") + parser.add_argument("--base-url", default=get_base_url(), help="飞书多维表格地址") + parser.add_argument("--mapping-file", default=str(get_mapping_file()), help="店铺映射表 xlsx") + parser.add_argument("--store-field-name", default=DEFAULT_STORE_FIELD_NAME, help="飞书中的店铺字段名") + parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径") + parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口") + parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过") + return parser.parse_args() + + +def main() -> None: + load_dotenv() + args = parse_args() + start_date = validate_date(args.start_date) + end_date = validate_date(args.end_date) + if end_date < start_date: + raise ValueError("end-date 不能早于 start-date") + log_step("开始执行退款额同步") + run_metric_sync( + metric_name="refund", + start_date=start_date, + end_date=end_date, + base_url=args.base_url, + mapping_file=Path(args.mapping_file), + store_field_name=args.store_field_name, + table_map_file=args.table_map_file, + dry_run=args.dry_run, + skip_missing_tables=args.skip_missing_tables, + ) + log_step("退款额同步完成") + + +if __name__ == "__main__": + main() diff --git a/generate_project_sales_csv.py b/generate_project_sales_csv.py new file mode 100644 index 0000000..3a652b0 --- /dev/null +++ b/generate_project_sales_csv.py @@ -0,0 +1,53 @@ +import argparse +from pathlib import Path + +from project_bitable_common import ( + DEFAULT_END_DATE, + DEFAULT_START_DATE, + DEFAULT_STORE_FIELD_NAME, + get_base_url, + get_mapping_file, + load_dotenv, + log_step, + run_metric_sync, + validate_date, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="从 PostgreSQL 拉取销售额并写入飞书多维表格。") + parser.add_argument("--start-date", default=DEFAULT_START_DATE, help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=DEFAULT_END_DATE, help="结束日期,格式 YYYY-MM-DD") + parser.add_argument("--base-url", default=get_base_url(), help="飞书多维表格地址") + parser.add_argument("--mapping-file", default=str(get_mapping_file()), help="店铺映射表 xlsx") + parser.add_argument("--store-field-name", default=DEFAULT_STORE_FIELD_NAME, help="飞书中的店铺字段名") + parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径") + parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口") + parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过") + return parser.parse_args() + + +def main() -> None: + load_dotenv() + args = parse_args() + start_date = validate_date(args.start_date) + end_date = validate_date(args.end_date) + if end_date < start_date: + raise ValueError("end-date 不能早于 start-date") + log_step("开始执行销售额同步") + run_metric_sync( + metric_name="sales", + start_date=start_date, + end_date=end_date, + base_url=args.base_url, + mapping_file=Path(args.mapping_file), + store_field_name=args.store_field_name, + table_map_file=args.table_map_file, + dry_run=args.dry_run, + skip_missing_tables=args.skip_missing_tables, + ) + log_step("销售额同步完成") + + +if __name__ == "__main__": + main() diff --git a/project.zip b/project.zip new file mode 100644 index 0000000000000000000000000000000000000000..3d3505e4d19f89ff00b1be0bc0e13b64a10b7523 GIT binary patch literal 11974 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 AkN^Mx literal 0 HcmV?d00001 diff --git a/project_bitable_common.py b/project_bitable_common.py new file mode 100644 index 0000000..aca42cc --- /dev/null +++ b/project_bitable_common.py @@ -0,0 +1,919 @@ +import json +import os +import re +from collections import defaultdict +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import psycopg2 +import requests +from openpyxl import load_workbook + + +BASE_DIR = Path(__file__).resolve().parent +ENV_FILE = BASE_DIR / ".env" +DEFAULT_MAPPING_FILE = BASE_DIR / "店铺映射表.xlsx" +DEFAULT_START_DATE = "2026-03-01" +DEFAULT_END_DATE = "2026-03-01" +DEFAULT_STORE_FIELD_NAME = "业务店铺名称" +STORE_FIELD_CANDIDATES = ["业务店铺名称", "店铺名称", "门店名称", "店铺", "名称"] +SUPPORTED_PROJECTS = ["大眼睛", "羽心堂", "MMMJ", "EM", "春回盏", "BROKEN ZOO"] + + +@dataclass(frozen=True) +class MetricConfig: + name: str + description: str + sql_template: str + field_suffix: str + + +METRIC_CONFIGS = { + "sales": MetricConfig( + name="sales", + description="销售额", + field_suffix="总销售额", + sql_template=""" +SET TIMEZONE = 'Asia/Shanghai'; + +WITH base_data AS ( + SELECT + f.支付时间::DATE AS 统计日期, + f.店铺主键, + s.店铺名称 AS 原始店铺名称, + p.品牌名称, + f.业务模式, + f.实付分摊金额 AS 指标金额 + FROM analytics_layer.fact_sales f + LEFT JOIN analytics_layer.dim_shop s + ON f.店铺主键 = s.店铺主键 + LEFT JOIN analytics_layer.dim_product p + ON f.商品主键 = p.商品主键 + AND f.统一规格编码 = p.统一规格编码 + WHERE f.支付时间 >= %(start_ts)s + AND f.支付时间 < %(end_ts_exclusive)s +), +brand_split AS ( + SELECT + 统计日期, + 店铺主键, + 原始店铺名称, + 业务模式, + CASE + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '羽心堂' + THEN 原始店铺名称 || '_羽心堂' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '春回盏' + THEN 原始店铺名称 || '_春回盏' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'Mama Mujer' + THEN 原始店铺名称 || '_Mama Mujer' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'ERBA MIGINA' + THEN 原始店铺名称 || '_ERBA MIGINA' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'BROKEN ZOO' + THEN 原始店铺名称 || '_BROKEN ZOO' + WHEN 店铺主键 IN (1,28,29) + THEN 原始店铺名称 || '_其他' + WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE '羽心堂' + THEN 原始店铺名称 || '_羽心堂' + WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE 'Mama Mujer' + THEN 原始店铺名称 || '_Mama Mujer' + WHEN 店铺主键 IN (3,9) + THEN 原始店铺名称 || '_其他' + WHEN 店铺主键 IS NULL OR 原始店铺名称 IS NULL + THEN '分销/SEEDING' + ELSE 原始店铺名称 + END AS 店铺名称, + 指标金额 + FROM base_data +), +shop_brand_detail AS ( + SELECT + 统计日期, + 店铺主键, + 店铺名称, + SUM(指标金额) AS 指标值 + FROM brand_split + GROUP BY 1,2,3 +) +SELECT + 统计日期, + 店铺名称, + 指标值 +FROM shop_brand_detail +WHERE NOT ( + 店铺主键 IN (1,9,28,29) + AND 店铺名称 LIKE '%%_其他' +) + +UNION ALL + +SELECT + 统计日期, + 原始店铺名称 AS 店铺名称, + SUM(指标金额) +FROM base_data +WHERE 店铺主键 IN (1,28,29,3) +GROUP BY 1,2 + +UNION ALL + +SELECT + 统计日期, + CASE + WHEN 业务模式 IN ('三方计算','三方分销') + THEN 原始店铺名称 || '_三方' + ELSE 原始店铺名称 || '_自营' + END AS 店铺名称, + SUM(指标金额) +FROM base_data +WHERE 店铺主键 = 1 +GROUP BY 1,2 + +ORDER BY 统计日期 DESC, 指标值 DESC; +""", + ), + "refund": MetricConfig( + name="refund", + description="退款额", + field_suffix="退款额", + sql_template=""" +SET TIMEZONE = 'Asia/Shanghai'; + +WITH base_data AS ( + SELECT + f.完结时间::DATE AS 统计日期, + f.店铺主键, + s.店铺名称 AS 原始店铺名称, + p.品牌名称, + f.退款金额 AS 指标金额 + FROM analytics_layer.fact_returns f + LEFT JOIN analytics_layer.dim_shop s + ON f.店铺主键 = s.店铺主键 + LEFT JOIN analytics_layer.dim_product p + ON f.商品主键 = p.商品主键 + AND f.统一规格编码 = p.统一规格编码 + WHERE f.完结时间 >= %(start_ts)s + AND f.完结时间 < %(end_ts_exclusive)s +), +brand_split AS ( + SELECT + 统计日期, + 店铺主键, + 原始店铺名称, + CASE + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '羽心堂' + THEN 原始店铺名称 || '_羽心堂' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE '春回盏' + THEN 原始店铺名称 || '_春回盏' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'Mama Mujer' + THEN 原始店铺名称 || '_Mama Mujer' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'ERBA MIGINA' + THEN 原始店铺名称 || '_ERBA MIGINA' + WHEN 店铺主键 IN (1,28,29) AND 品牌名称 LIKE 'BROKEN ZOO' + THEN 原始店铺名称 || '_BROKEN ZOO' + WHEN 店铺主键 IN (1,28,29) + THEN 原始店铺名称 || '_其他' + WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE '羽心堂' + THEN 原始店铺名称 || '_羽心堂' + WHEN 店铺主键 IN (3,9) AND 品牌名称 LIKE 'Mama Mujer' + THEN 原始店铺名称 || '_Mama Mujer' + WHEN 店铺主键 IN (3,9) + THEN 原始店铺名称 || '_其他' + WHEN 店铺主键 IS NULL OR 原始店铺名称 IS NULL + THEN '分销/SEEDING' + ELSE 原始店铺名称 + END AS 店铺名称, + 指标金额 + FROM base_data +), +shop_brand_detail AS ( + SELECT + 统计日期, + 店铺主键, + 店铺名称, + SUM(指标金额) AS 指标值 + FROM brand_split + GROUP BY 1,2,3 +) +SELECT + 统计日期, + 店铺名称, + 指标值 +FROM shop_brand_detail +WHERE NOT ( + 店铺主键 IN (1,9,28,29) + AND 店铺名称 LIKE '%%_其他' +) + +UNION ALL + +SELECT + 统计日期, + 原始店铺名称 AS 店铺名称, + SUM(指标金额) +FROM base_data +WHERE 店铺主键 IN (1,28,29,3) +GROUP BY 1,2 + +ORDER BY 统计日期 DESC, 指标值 DESC; +""", + ), +} + + +@dataclass(frozen=True) +class MetricRow: + stat_date: date + bi_store_name: str + amount: Decimal + + +@dataclass(frozen=True) +class TableInfo: + table_id: str + name: str + + +@dataclass(frozen=True) +class SyncResult: + sql_only_stores: list[str] + feishu_only_stores: list[str] + + +def log_step(message: str) -> None: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{now}] {message}", flush=True) + + +def preview_list(values: list[Any], limit: int = 20) -> str: + if len(values) <= limit: + return str(values) + return f"{values[:limit]} ... 共{len(values)}项" + + +def load_dotenv(env_file: Path = ENV_FILE) -> None: + if not env_file.exists(): + log_step(f"未找到 .env 文件,跳过加载: {env_file}") + return + log_step(f"加载环境变量: {env_file}") + for raw_line in env_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +def get_base_url() -> str: + base_url = os.getenv("FEISHU_BASE_URL") + if not base_url: + raise ValueError("缺少环境变量 FEISHU_BASE_URL") + return base_url + + +def get_mapping_file() -> Path: + return Path(os.getenv("PROJECT_MAPPING_FILE", str(DEFAULT_MAPPING_FILE))) + + +def resolve_store_field_name(fields: list[dict[str, Any]], preferred_field_name: str) -> str: + field_names = [str(item.get("field_name") or "") for item in fields] + if preferred_field_name in field_names: + return preferred_field_name + for candidate in STORE_FIELD_CANDIDATES: + if candidate in field_names: + return candidate + raise ValueError(f"未找到店铺名称字段: preferred={preferred_field_name}, 可用字段={field_names}") + + +def validate_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +def normalize_text(value: str) -> str: + return re.sub(r"[\s_\-()()]+", "", value or "").lower() + + +def classify_project(store_name: str) -> str: + name = store_name or "" + prioritized_rules = [ + ("MMMJ", ["Mama", "mmmj", "MMMJ"]), + ("羽心堂", ["羽心堂"]), + ("EM", ["EM", "ERBA MIGINA"]), + ("春回盏", ["春回盏"]), + ("BROKEN ZOO", ["BROKEN ZOO"]), + ] + for project, keywords in prioritized_rules: + if any(keyword in name for keyword in keywords): + return project + if "大眼睛" in name or "大西瓜籽妈妈" in name: + return "大眼睛" + return "" + + +def get_project_table_aliases(project: str) -> list[str]: + aliases = { + "大眼睛": ["大眼睛", "大眼睛买买买"], + "羽心堂": ["羽心堂"], + "MMMJ": ["MMMJ", "Mama Mujer", "MAMA MUJER"], + "EM": ["EM", "ERBA MIGINA"], + "春回盏": ["春回盏"], + "BROKEN ZOO": ["BROKEN ZOO", "BROKENZOO", "葆福颂"], + } + return aliases.get(project, [project]) + + +def load_store_mapping(path: Path) -> dict[str, str]: + log_step(f"加载店铺映射表: {path}") + wb = load_workbook(path, data_only=True) + ws = wb.active + mapping: dict[str, str] = {} + for business_name, bi_name in ws.iter_rows(min_row=2, values_only=True): + if not business_name or not bi_name: + continue + mapping[str(bi_name).strip()] = str(business_name).strip() + log_step(f"店铺映射加载完成: {len(mapping)} 条") + return mapping + + +def resolve_business_store_name(bi_store_name: str, store_mapping: dict[str, str]) -> str: + return store_mapping.get(bi_store_name, "") + + +def get_db_connection() -> psycopg2.extensions.connection: + required = [ + "ANALYTICS_DB_HOST", + "ANALYTICS_DB_PORT", + "ANALYTICS_DB_USER", + "ANALYTICS_DB_PASSWORD", + "ANALYTICS_DB_NAME", + ] + missing = [key for key in required if not os.getenv(key)] + if missing: + raise ValueError(f"缺少数据库环境变量: {', '.join(missing)}") + log_step( + f"连接 PostgreSQL: host={os.environ['ANALYTICS_DB_HOST']} port={os.environ['ANALYTICS_DB_PORT']} db={os.environ['ANALYTICS_DB_NAME']}" + ) + return psycopg2.connect( + host=os.environ["ANALYTICS_DB_HOST"], + port=os.environ["ANALYTICS_DB_PORT"], + user=os.environ["ANALYTICS_DB_USER"], + password=os.environ["ANALYTICS_DB_PASSWORD"], + dbname=os.environ["ANALYTICS_DB_NAME"], + ) + + +def fetch_metric_rows(start_date: date, end_date: date, metric_config: MetricConfig) -> list[MetricRow]: + start_ts = f"{start_date.isoformat()} 00:00:00" + end_ts_exclusive = f"{(end_date + timedelta(days=1)).isoformat()} 00:00:00" + log_step(f"开始查询{metric_config.description}: {start_ts} -> {end_ts_exclusive} (右开区间)") + with get_db_connection() as conn: + with conn.cursor() as cur: + cur.execute(metric_config.sql_template, {"start_ts": start_ts, "end_ts_exclusive": end_ts_exclusive}) + rows = cur.fetchall() + log_step(f"{metric_config.description}查询完成: {len(rows)} 行") + return [ + MetricRow(stat_date=row[0], bi_store_name=(row[1] or "").strip(), amount=row[2] or Decimal("0")) + for row in rows + ] + + +def build_project_metrics( + rows: list[MetricRow], + store_mapping: dict[str, str], + metric_config: MetricConfig, +) -> dict[str, dict[tuple[int, int], dict[date, dict[str, Decimal]]]]: + log_step(f"开始按项目/月/日期聚合{metric_config.description}") + grouped: dict[str, dict[tuple[int, int], dict[date, dict[str, Decimal]]]] = defaultdict( + lambda: defaultdict(lambda: defaultdict(dict)) + ) + for row in rows: + business_store_name = resolve_business_store_name(row.bi_store_name, store_mapping) + if not business_store_name: + continue + project = classify_project(business_store_name) + if not project: + continue + month_key = (row.stat_date.year, row.stat_date.month) + current_amount = grouped[project][month_key][row.stat_date].get(business_store_name, Decimal("0")) + grouped[project][month_key][row.stat_date][business_store_name] = current_amount + row.amount + project_count = len(grouped) + month_count = sum(len(months) for months in grouped.values()) + log_step(f"{metric_config.description}聚合完成: 项目={project_count},项目月份组合={month_count}") + return grouped + + +def build_target_project_months( + start_date: date, + end_date: date, + grouped_metrics: dict[str, dict[tuple[int, int], dict[date, dict[str, Decimal]]]], +) -> dict[str, dict[tuple[int, int], dict[date, dict[str, Decimal]]]]: + result = { + project: {month_key: dict(date_map) for month_key, date_map in month_dict.items()} + for project, month_dict in grouped_metrics.items() + } + current = start_date + while current <= end_date: + month_key = (current.year, current.month) + for project in SUPPORTED_PROJECTS: + project_months = result.setdefault(project, {}) + metrics_by_date = project_months.setdefault(month_key, {}) + metrics_by_date.setdefault(current, {}) + current += timedelta(days=1) + return result + + +def get_feishu_token() -> str: + app_id = os.getenv("FEISHU_APP_ID") + app_secret = os.getenv("FEISHU_APP_SECRET") + if not app_id or not app_secret: + raise ValueError("缺少飞书环境变量 FEISHU_APP_ID / FEISHU_APP_SECRET") + log_step("开始获取飞书 tenant_access_token") + resp = requests.post( + "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + json={"app_id": app_id, "app_secret": app_secret}, + timeout=30, + ) + resp.raise_for_status() + payload = resp.json() + if payload.get("code") != 0: + raise RuntimeError(f"获取飞书 token 失败: {payload}") + log_step("飞书 tenant_access_token 获取成功") + return payload["tenant_access_token"] + + +class FeishuBitableClient: + def __init__(self, base_url: str) -> None: + self.base_url = base_url + self.app_token = self.extract_app_token(base_url) + log_step(f"解析飞书 Base URL 成功: app_token={self.app_token}") + self.token = get_feishu_token() + self._fields_cache: dict[str, list[dict[str, Any]]] = {} + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json; charset=utf-8", + } + ) + + @staticmethod + def extract_app_token(base_url: str) -> str: + path = urlparse(base_url).path.strip("/") + match = re.search(r"base/([^/]+)", path) + if not match: + raise ValueError(f"无法从链接中解析 app_token: {base_url}") + return match.group(1) + + def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + url = f"https://open.feishu.cn{path}" + params = kwargs.get("params") + if params: + log_step(f"请求飞书接口: {method} {path} params={params}") + else: + log_step(f"请求飞书接口: {method} {path}") + resp = self.session.request(method, url, timeout=60, **kwargs) + try: + payload = resp.json() + except ValueError: + resp.raise_for_status() + raise RuntimeError(f"飞书接口返回了非 JSON 响应: status={resp.status_code}, body={resp.text[:500]}") + if resp.status_code >= 400: + error = payload.get("error") or {} + raise RuntimeError( + f"飞书接口 HTTP {resp.status_code} {path}: code={payload.get('code')} msg={payload.get('msg')} " + f"log_id={error.get('log_id', '')} payload={payload}" + ) + if payload.get("code") != 0: + error = payload.get("error") or {} + raise RuntimeError( + f"飞书接口失败 {path}: code={payload.get('code')} msg={payload.get('msg')} " + f"log_id={error.get('log_id', '')} payload={payload}" + ) + return payload.get("data") or {} + + def list_tables(self) -> list[TableInfo]: + log_step("开始获取多维表格数据表列表") + items: list[TableInfo] = [] + page_token = "" + while True: + params: dict[str, Any] = {} + if page_token: + params["page_token"] = page_token + data = self.request("GET", f"/open-apis/bitable/v1/apps/{self.app_token}/tables", params=params) + for item in data.get("items") or []: + items.append(TableInfo(table_id=item["table_id"], name=item["name"])) + if not data.get("has_more", False): + break + page_token = data.get("page_token") or "" + if not page_token: + break + log_step(f"飞书返回的数据表名称: {preview_list([item.name for item in items])}") + log_step(f"数据表列表获取完成: {len(items)} 个") + return items + + def list_fields(self, table_id: str) -> list[dict[str, Any]]: + if table_id in self._fields_cache: + log_step(f"命中字段缓存: table_id={table_id}, 字段数={len(self._fields_cache[table_id])}") + return self._fields_cache[table_id] + log_step(f"开始获取字段列表: table_id={table_id}") + items: list[dict[str, Any]] = [] + page_token = "" + seen_tokens: set[str] = set() + page_index = 0 + while True: + page_index += 1 + params = {"page_size": 100} + if page_token: + params["page_token"] = page_token + if page_token in seen_tokens: + raise RuntimeError(f"字段列表分页出现重复 page_token,已中断: table_id={table_id}, page_token={page_token}") + seen_tokens.add(page_token) + log_step(f"获取字段列表分页: table_id={table_id}, page={page_index}, page_token={page_token or ''}") + data = self.request( + "GET", + f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields", + params=params, + ) + items.extend(data.get("items") or []) + has_more = data.get("has_more", False) + next_page_token = data.get("page_token") or "" + log_step( + f"字段列表分页完成: table_id={table_id}, page={page_index}, 累计字段数={len(items)}, " + f"has_more={has_more}, next_page_token={next_page_token or ''}" + ) + if not has_more: + break + if not next_page_token: + raise RuntimeError(f"字段列表分页返回 has_more=True 但 page_token 为空: table_id={table_id}, 已获取字段数={len(items)}") + page_token = next_page_token + if page_index >= 100: + raise RuntimeError(f"字段列表分页超过 100 页,已中断: table_id={table_id}") + self._fields_cache[table_id] = items + log_step(f"飞书返回的字段名称: table_id={table_id}, fields={preview_list([item.get('field_name', '') for item in items])}") + log_step(f"字段列表获取完成: table_id={table_id}, 字段数={len(items)}") + return items + + def ensure_number_field(self, table_id: str, field_name: str) -> None: + log_step(f"检查字段是否存在: table_id={table_id}, field={field_name}") + fields = self.list_fields(table_id) + if any(item.get("field_name") == field_name for item in fields): + log_step(f"字段已存在,跳过创建: table_id={table_id}, field={field_name}") + return + log_step(f"开始创建数字字段: table_id={table_id}, field={field_name}") + self.request( + "POST", + f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields", + json={"field_name": field_name, "type": 2}, + ) + self._fields_cache.pop(table_id, None) + log_step(f"字段创建完成: table_id={table_id}, field={field_name}") + + def list_records(self, table_id: str) -> list[dict[str, Any]]: + log_step(f"开始获取记录列表: table_id={table_id}") + items: list[dict[str, Any]] = [] + page_token = "" + while True: + params = {"page_size": 500} + if page_token: + params["page_token"] = page_token + data = self.request( + "GET", + f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records", + params=params, + ) + items.extend(data.get("items") or []) + page_token = data.get("page_token") or "" + if not page_token: + break + log_step(f"记录列表获取完成: table_id={table_id}, 记录数={len(items)}") + return items + + def update_record(self, table_id: str, record_id: str, fields: dict[str, Any]) -> None: + log_step(f"更新记录: table_id={table_id}, record_id={record_id}, fields={list(fields.keys())}") + self.request( + "PUT", + f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/{record_id}", + json={"fields": fields}, + ) + + +def load_table_name_overrides(path_text: str) -> dict[str, str]: + if not path_text: + return {} + path = Path(path_text) + if not path.exists(): + raise FileNotFoundError(f"未找到表名映射文件: {path}") + payload = json.loads(path.read_text(encoding="utf-8")) + return {str(key): str(value) for key, value in payload.items()} + + +def find_target_table( + tables: list[TableInfo], + project: str, + year: int, + month: int, + overrides: dict[str, str] | None = None, +) -> TableInfo: + log_step(f"开始匹配目标表: 项目={project}, 月份={year:04d}-{month:02d}") + overrides = overrides or {} + override_key = f"{project}:{year:04d}-{month:02d}" + if override_key in overrides: + target_name = overrides[override_key] + for table in tables: + if table.name == target_name: + log_step(f"命中表名映射: {override_key} -> {table.name}") + return table + raise ValueError(f"表名映射命中了 {target_name},但飞书中不存在该表") + normalized_projects = [normalize_text(alias) for alias in get_project_table_aliases(project)] + month_tokens = { + str(month), + f"{month}月", + f"{month:02d}月", + f"{year:04d}{month:02d}", + f"{year:04d}年{month:02d}月", + f"{year:04d}-{month:02d}", + f"{year:04d}_{month:02d}", + f"{year:04d}.{month:02d}", + } + log_step(f"项目表名别名: 项目={project}, aliases={get_project_table_aliases(project)}") + candidates = [] + project_related_tables = [] + for table in tables: + normalized_name = normalize_text(table.name) + matched_project = any(alias in normalized_name for alias in normalized_projects) + if matched_project: + project_related_tables.append(table.name) + if not matched_project: + continue + if any(normalize_text(token) in normalized_name for token in month_tokens): + candidates.append(table) + if len(candidates) == 1: + log_step(f"匹配到目标表: 项目={project}, 月份={year:04d}-{month:02d}, 表={candidates[0].name}") + return candidates[0] + if not candidates: + all_table_names = [table.name for table in tables] + raise ValueError( + f"未找到匹配表: 项目={project}, 月份={year:04d}-{month:02d}, 项目相关表={project_related_tables or '[]'}, 全部表={all_table_names}" + ) + raise ValueError( + f"匹配到多个表,请提供 table_map_file 指定: 项目={project}, 月份={year:04d}-{month:02d}, 表={', '.join(t.name for t in candidates)}" + ) + + +def decimal_to_number(value: Decimal) -> float: + return float(value.quantize(Decimal("0.01"))) + + +def get_metric_field_name(stat_date: date, metric_config: MetricConfig) -> str: + return stat_date.strftime(f"%m-%d{metric_config.field_suffix}") + + +def parse_number(value: Any) -> float | None: + if value in (None, ""): + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return round(float(value), 2) + if isinstance(value, str): + text = value.strip().replace(",", "") + if not text: + return None + try: + return round(float(text), 2) + except ValueError: + return None + return None + + +def iter_dates(start_date: date, end_date: date) -> list[date]: + dates = [] + current = start_date + while current <= end_date: + dates.append(current) + current = date.fromordinal(current.toordinal() + 1) + return dates + + +def sync_metric_table_for_month( + client: FeishuBitableClient, + table: TableInfo, + store_field_name: str, + metrics_by_date: dict[date, dict[str, Decimal]], + dry_run: bool, + metric_config: MetricConfig, +) -> SyncResult: + log_step(f"开始同步数据表: {table.name}") + table_fields = client.list_fields(table.table_id) + store_field_name = resolve_store_field_name(table_fields, store_field_name) + log_step(f"识别到店铺字段: 表={table.name}, field={store_field_name}") + records = client.list_records(table.table_id) + record_by_store: dict[str, dict[str, Any]] = {} + preview_store_fields: list[str] = [] + for item in records: + fields = item.get("fields", {}) + store_name = fields.get(store_field_name) + if isinstance(store_name, list): + store_name = store_name[0] if store_name else "" + if not store_name: + continue + normalized_store_name = str(store_name).strip() + normalized_store_key = normalize_text(normalized_store_name) + preview_store_fields.append(normalized_store_name) + record_by_store[normalized_store_key] = item + log_step(f"飞书返回的记录店铺名称预览: table_id={table.table_id}, stores={preview_list(preview_store_fields)}") + + feishu_store_names = set(record_by_store) + sql_store_names: set[str] = set() + sql_store_names_raw: set[str] = set() + for store_metrics in metrics_by_date.values(): + sql_store_names_raw.update(store_metrics.keys()) + sql_store_names.update(normalize_text(name) for name in store_metrics.keys()) + all_store_names = feishu_store_names | sql_store_names + + sql_name_map = {normalize_text(name): name for name in sql_store_names_raw} + feishu_name_map = {normalize_text(name): name for name in preview_store_fields} + sql_only_stores = sorted(sql_name_map[name] for name in (sql_store_names - feishu_store_names)) + feishu_only_stores = sorted(feishu_name_map[name] for name in (feishu_store_names - sql_store_names)) + + log_step(f"SQL 聚合出的店铺名称: 表={table.name}, stores={preview_list(sorted(sql_store_names_raw))}") + log_step(f"待处理店铺数: 表={table.name}, 店铺数={len(all_store_names)}, 日期数={len(metrics_by_date)}") + log_step( + f"店铺匹配情况: 表={table.name}, SQL有但飞书无={preview_list(sql_only_stores)}, " + f"飞书有但SQL无={preview_list(feishu_only_stores)}" + ) + + for stat_date in sorted(metrics_by_date): + field_name = get_metric_field_name(stat_date, metric_config) + if dry_run: + print(f"[DRY-RUN] 表 {table.name} 将新增字段: {field_name}") + else: + client.ensure_number_field(table.table_id, field_name) + for store_key in sorted(all_store_names): + store_name = sql_name_map.get(store_key) or feishu_name_map.get(store_key) or store_key + amount = None + for raw_name, raw_amount in metrics_by_date[stat_date].items(): + if normalize_text(raw_name) == store_key: + amount = raw_amount + break + field_value = decimal_to_number(amount) if amount is not None else None + existing = record_by_store.get(store_key) + if dry_run: + print( + f"[DRY-RUN] 表={table.name} 店铺={store_name} 字段={field_name} 值={field_value if field_value is not None else ''}" + ) + continue + if existing: + client.update_record(table.table_id, existing["record_id"], {field_name: field_value}) + continue + log_step(f"跳过新增记录: 表={table.name}, 店铺={store_name}, 字段={field_name}, 原因=飞书中不存在该店铺行") + log_step(f"数据表同步完成: {table.name}") + return SyncResult(sql_only_stores=sql_only_stores, feishu_only_stores=feishu_only_stores) + + +def run_metric_sync( + metric_name: str, + start_date: date, + end_date: date, + base_url: str, + mapping_file: Path, + store_field_name: str, + table_map_file: str, + dry_run: bool, + skip_missing_tables: bool, +) -> None: + metric_config = METRIC_CONFIGS[metric_name] + mapping = load_store_mapping(mapping_file) + metric_rows = fetch_metric_rows(start_date, end_date, metric_config) + grouped_metrics = build_project_metrics(metric_rows, mapping, metric_config) + grouped_metrics = build_target_project_months(start_date, end_date, grouped_metrics) + table_overrides = load_table_name_overrides(table_map_file) + if table_overrides: + log_step(f"加载表名映射完成: {len(table_overrides)} 条") + client = FeishuBitableClient(base_url) + tables = client.list_tables() + unmatched_summary: list[tuple[str, str, str, SyncResult]] = [] + for project in sorted(grouped_metrics): + for (year, month), metrics_by_date in sorted(grouped_metrics[project].items()): + try: + table = find_target_table(tables, project, year, month, table_overrides) + except ValueError as exc: + if not skip_missing_tables: + raise + log_step(f"跳过: {exc}") + continue + log_step(f"同步项目={project} 月份={year:04d}-{month:02d} -> 表={table.name}") + sync_result = sync_metric_table_for_month( + client=client, + table=table, + store_field_name=store_field_name, + metrics_by_date=metrics_by_date, + dry_run=dry_run, + metric_config=metric_config, + ) + unmatched_summary.append((project, f"{year:04d}-{month:02d}", table.name, sync_result)) + log_step("未匹配店铺汇总开始") + for project, month, table_name, result in unmatched_summary: + log_step( + f"未匹配店铺汇总: 项目={project}, 月份={month}, 表={table_name}, " + f"SQL有但飞书无={result.sql_only_stores}, 飞书有但SQL无={result.feishu_only_stores}" + ) + + +def get_sales_field_name(stat_date: date) -> str: + return get_metric_field_name(stat_date, METRIC_CONFIGS["sales"]) + + +def get_refund_field_name(stat_date: date) -> str: + return get_metric_field_name(stat_date, METRIC_CONFIGS["refund"]) + + +def get_gmv_field_name(stat_date: date) -> str: + return stat_date.strftime("%m-%d GMV") + + +def sync_gmv_table_for_day( + client: FeishuBitableClient, + table: TableInfo, + stat_date: date, + preferred_store_field_name: str, + dry_run: bool, +) -> None: + log_step(f"开始计算 GMV: 表={table.name}, 日期={stat_date.isoformat()}") + fields = client.list_fields(table.table_id) + store_field_name = resolve_store_field_name(fields, preferred_store_field_name) + sales_field_name = get_sales_field_name(stat_date) + refund_field_name = get_refund_field_name(stat_date) + gmv_field_name = get_gmv_field_name(stat_date) + field_names = [str(item.get("field_name") or "") for item in fields] + log_step( + f"GMV 字段检查: 表={table.name}, 店铺字段={store_field_name}, 销售字段={sales_field_name in field_names}, " + f"退款字段={refund_field_name in field_names}, GMV字段={gmv_field_name in field_names}" + ) + if sales_field_name not in field_names or refund_field_name not in field_names: + log_step(f"跳过 GMV 计算: 表={table.name}, 原因=缺少销售额或退款额字段") + return + if not dry_run: + client.ensure_number_field(table.table_id, gmv_field_name) + records = client.list_records(table.table_id) + preview_stores = [] + updated_count = 0 + skipped_empty_count = 0 + for item in records: + record_id = item["record_id"] + record_fields = item.get("fields", {}) + store_name = record_fields.get(store_field_name) + if isinstance(store_name, list): + store_name = store_name[0] if store_name else "" + store_name = str(store_name or "").strip() + if store_name: + preview_stores.append(store_name) + sales_value = parse_number(record_fields.get(sales_field_name)) + refund_value = parse_number(record_fields.get(refund_field_name)) + if sales_value is None and refund_value is None: + skipped_empty_count += 1 + continue + gmv_value = round((sales_value or 0.0) - (refund_value or 0.0), 2) + if dry_run: + print( + f"[DRY-RUN] 表={table.name} 店铺={store_name} 字段={gmv_field_name} " + f"销售额={sales_value if sales_value is not None else ''} " + f"退款额={refund_value if refund_value is not None else ''} GMV={gmv_value}" + ) + else: + client.update_record(table.table_id, record_id, {gmv_field_name: gmv_value}) + updated_count += 1 + log_step(f"飞书记录店铺预览: 表={table.name}, stores={preview_list(preview_stores)}") + log_step(f"GMV 计算完成: 表={table.name}, 更新记录数={updated_count}, 双空跳过={skipped_empty_count}") + + +def run_gmv_sync( + start_date: date, + end_date: date, + base_url: str, + store_field_name: str, + dry_run: bool, + skip_missing_tables: bool, +) -> None: + client = FeishuBitableClient(base_url) + tables = client.list_tables() + for stat_date in iter_dates(start_date, end_date): + for project in SUPPORTED_PROJECTS: + try: + table = find_target_table(tables, project, stat_date.year, stat_date.month) + except ValueError as exc: + if not skip_missing_tables: + raise + log_step(f"跳过: {exc}") + continue + sync_gmv_table_for_day( + client=client, + table=table, + stat_date=stat_date, + preferred_store_field_name=store_field_name, + dry_run=dry_run, + ) + diff --git a/run_project_bitable_pipeline.py b/run_project_bitable_pipeline.py new file mode 100644 index 0000000..4008f22 --- /dev/null +++ b/run_project_bitable_pipeline.py @@ -0,0 +1,74 @@ +import argparse +from pathlib import Path + +from project_bitable_common import ( + DEFAULT_END_DATE, + DEFAULT_START_DATE, + DEFAULT_STORE_FIELD_NAME, + get_base_url, + get_mapping_file, + load_dotenv, + log_step, + run_gmv_sync, + run_metric_sync, + validate_date, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="按 销售 -> 退款 -> GMV 顺序执行飞书多维表格同步。") + parser.add_argument("--start-date", default=DEFAULT_START_DATE, help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=DEFAULT_END_DATE, help="结束日期,格式 YYYY-MM-DD") + parser.add_argument("--base-url", default=get_base_url(), help="飞书多维表格地址") + parser.add_argument("--mapping-file", default=str(get_mapping_file()), help="店铺映射表 xlsx") + parser.add_argument("--store-field-name", default=DEFAULT_STORE_FIELD_NAME, help="飞书中的店铺字段名") + parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径") + parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口") + parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过") + return parser.parse_args() + + +def main() -> None: + load_dotenv() + args = parse_args() + start_date = validate_date(args.start_date) + end_date = validate_date(args.end_date) + if end_date < start_date: + raise ValueError("end-date 不能早于 start-date") + + log_step("流水线开始: 销售 -> 退款 -> GMV") + run_metric_sync( + metric_name="sales", + start_date=start_date, + end_date=end_date, + base_url=args.base_url, + mapping_file=Path(args.mapping_file), + store_field_name=args.store_field_name, + table_map_file=args.table_map_file, + dry_run=args.dry_run, + skip_missing_tables=args.skip_missing_tables, + ) + run_metric_sync( + metric_name="refund", + start_date=start_date, + end_date=end_date, + base_url=args.base_url, + mapping_file=Path(args.mapping_file), + store_field_name=args.store_field_name, + table_map_file=args.table_map_file, + dry_run=args.dry_run, + skip_missing_tables=args.skip_missing_tables, + ) + run_gmv_sync( + start_date=start_date, + end_date=end_date, + base_url=args.base_url, + store_field_name=args.store_field_name, + dry_run=args.dry_run, + skip_missing_tables=args.skip_missing_tables, + ) + log_step("流水线完成") + + +if __name__ == "__main__": + main() diff --git a/店铺映射表.xlsx b/店铺映射表.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5af1e527972536cc25bdc087843c68474db3b0d2 GIT binary patch literal 10893 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 n_n!Y*SM^N$%WB{LgZA$#D