From fff650441256199dd7ee3e92e75aabaee5cd2a87 Mon Sep 17 00:00:00 2001 From: BXY0414 <1079823339@qq.com> Date: Fri, 13 Mar 2026 18:19:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=BA=94=E7=94=A8=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E6=8E=A8=E9=80=81=E5=88=B0=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E7=BE=A4=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + generate_project_gmv_csv.py | 12 +- generate_project_refund_csv.py | 12 +- generate_project_sales_csv.py | 12 +- project_bitable_common.py | 192 +++++++++++++++++++++++++++++++- run_project_bitable_pipeline.py | 104 +++++++++++------ 店铺映射表.xlsx | Bin 10893 -> 11281 bytes 7 files changed, 276 insertions(+), 60 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1367188 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.vscode/ +.env diff --git a/generate_project_gmv_csv.py b/generate_project_gmv_csv.py index f4bdb17..8700314 100644 --- a/generate_project_gmv_csv.py +++ b/generate_project_gmv_csv.py @@ -1,10 +1,10 @@ import argparse from project_bitable_common import ( - DEFAULT_END_DATE, - DEFAULT_START_DATE, - DEFAULT_STORE_FIELD_NAME, + get_default_end_date, + get_default_start_date, get_base_url, + get_store_field_name, load_dotenv, log_step, run_gmv_sync, @@ -14,10 +14,10 @@ from project_bitable_common import ( 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("--start-date", default=get_default_start_date(), help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=get_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("--store-field-name", default=get_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() diff --git a/generate_project_refund_csv.py b/generate_project_refund_csv.py index 38baa3f..5ed63f8 100644 --- a/generate_project_refund_csv.py +++ b/generate_project_refund_csv.py @@ -2,11 +2,11 @@ import argparse from pathlib import Path from project_bitable_common import ( - DEFAULT_END_DATE, - DEFAULT_START_DATE, - DEFAULT_STORE_FIELD_NAME, + get_default_end_date, + get_default_start_date, get_base_url, get_mapping_file, + get_store_field_name, load_dotenv, log_step, run_metric_sync, @@ -16,11 +16,11 @@ from project_bitable_common import ( 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("--start-date", default=get_default_start_date(), help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=get_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("--store-field-name", default=get_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="找不到对应月份的飞书表时跳过") diff --git a/generate_project_sales_csv.py b/generate_project_sales_csv.py index 3a652b0..3d6771e 100644 --- a/generate_project_sales_csv.py +++ b/generate_project_sales_csv.py @@ -2,11 +2,11 @@ import argparse from pathlib import Path from project_bitable_common import ( - DEFAULT_END_DATE, - DEFAULT_START_DATE, - DEFAULT_STORE_FIELD_NAME, + get_default_end_date, + get_default_start_date, get_base_url, get_mapping_file, + get_store_field_name, load_dotenv, log_step, run_metric_sync, @@ -16,11 +16,11 @@ from project_bitable_common import ( 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("--start-date", default=get_default_start_date(), help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=get_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("--store-field-name", default=get_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="找不到对应月份的飞书表时跳过") diff --git a/project_bitable_common.py b/project_bitable_common.py index aca42cc..9f12c57 100644 --- a/project_bitable_common.py +++ b/project_bitable_common.py @@ -17,9 +17,8 @@ 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 = "业务店铺名称" +DEFAULT_START_DATE = "2026-02-25" +DEFAULT_STORE_FIELD_NAME = "店铺名称" STORE_FIELD_CANDIDATES = ["业务店铺名称", "店铺名称", "门店名称", "店铺", "名称"] SUPPORTED_PROJECTS = ["大眼睛", "羽心堂", "MMMJ", "EM", "春回盏", "BROKEN ZOO"] @@ -278,6 +277,42 @@ def get_mapping_file() -> Path: return Path(os.getenv("PROJECT_MAPPING_FILE", str(DEFAULT_MAPPING_FILE))) +def get_store_field_name() -> str: + return os.getenv("FEISHU_STORE_FIELD_NAME", DEFAULT_STORE_FIELD_NAME) + + +def get_default_start_date() -> str: + return os.getenv("SYNC_START_DATE", DEFAULT_START_DATE) + + +def get_default_end_date() -> str: + return (date.today() - timedelta(days=1)).isoformat() + + +def get_group_webhook_url() -> str: + candidates = [ + "FEISHU_BOT_WEBHOOK", + "FEISHU_ROBOT_WEBHOOK", + "FEISHU_WEBHOOK", + "LARK_BOT_WEBHOOK", + "LARK_ROBOT_WEBHOOK", + "GROUP_WEBHOOK", + ] + for key in candidates: + value = os.getenv(key) + if value: + return value + return "" + + +def get_feishu_chat_id() -> str: + return os.getenv("FEISHU_CHAT_ID", "") + + +def get_feishu_report_image_path() -> str: + return os.getenv("FEISHU_REPORT_IMAGE_PATH", "") + + 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: @@ -330,7 +365,15 @@ def load_store_mapping(path: Path) -> dict[str, str]: 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): + for row in ws.iter_rows(min_row=2, values_only=True): + if not row: + continue + if len(row) >= 3: + _, business_name, bi_name = row[:3] + elif len(row) >= 2: + business_name, bi_name = row[:2] + else: + continue if not business_name or not bi_name: continue mapping[str(bi_name).strip()] = str(business_name).strip() @@ -610,6 +653,146 @@ def load_table_name_overrides(path_text: str) -> dict[str, str]: return {str(key): str(value) for key, value in payload.items()} +def send_feishu_sync_card( + webhook_url: str, + title: str, + base_url: str, + start_date: date, + end_date: date, +) -> None: + if not webhook_url: + log_step("未配置飞书群机器人 webhook,跳过群消息推送") + return + card = { + "msg_type": "interactive", + "card": { + "config": {"wide_screen_mode": True}, + "header": { + "template": "blue", + "title": {"tag": "plain_text", "content": title}, + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": ( + f"**同步范围**\n{start_date.isoformat()} 至 {end_date.isoformat()}\n\n" + f"**多维表格**\n[点击打开多维表格]({base_url})" + ), + }, + } + ], + }, + } + log_step("开始推送飞书群卡片消息") + resp = requests.post(webhook_url, json=card, timeout=30) + resp.raise_for_status() + payload = resp.json() + if payload.get("code") not in (0, None): + raise RuntimeError(f"飞书群消息推送失败: {payload}") + log_step("飞书群卡片消息推送完成") + + +def upload_feishu_image(image_path: str) -> str: + if not image_path: + return "" + path = Path(image_path) + if not path.exists(): + raise FileNotFoundError(f"未找到预览图文件: {path}") + token = get_feishu_token() + headers = {"Authorization": f"Bearer {token}"} + with path.open("rb") as image_file: + files = {"image": (path.name, image_file, "application/octet-stream")} + data = {"image_type": "message"} + 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.raise_for_status() + payload = resp.json() + if payload.get("code") != 0: + raise RuntimeError(f"飞书图片上传失败: {payload}") + image_key = ((payload.get("data") or {}).get("image_key")) or "" + if not image_key: + raise RuntimeError(f"飞书图片上传成功但未返回 image_key: {payload}") + log_step(f"飞书消息图片上传完成: image_key={image_key}") + return image_key + + +def send_feishu_app_card( + chat_id: str, + title: str, + base_url: str, + start_date: date, + end_date: date, + image_path: str = "", +) -> None: + if not chat_id: + log_step("未配置 FEISHU_CHAT_ID,跳过应用机器人卡片推送") + return + token = get_feishu_token() + image_key = upload_feishu_image(image_path) if image_path else "" + elements: list[dict[str, Any]] = [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": ( + f"**同步范围**\n{start_date.isoformat()} 至 {end_date.isoformat()}\n\n" + f"**多维表格**\n[{title}]({base_url})" + ), + }, + } + ] + if image_key: + elements.append({"tag": "img", "img_key": image_key, "alt": {"tag": "plain_text", "content": "报表预览图"}}) + elements.append( + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "打开仪表盘"}, + "type": "primary", + "url": base_url, + } + ], + } + ) + message = { + "receive_id": chat_id, + "msg_type": "interactive", + "content": json.dumps( + { + "config": {"wide_screen_mode": True}, + "header": {"template": "blue", "title": {"tag": "plain_text", "content": title}}, + "elements": elements, + }, + ensure_ascii=False, + ), + } + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + } + log_step(f"开始通过应用机器人发送群卡片: chat_id={chat_id}") + resp = requests.post( + "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", + headers=headers, + json=message, + timeout=60, + ) + 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: + raise RuntimeError(f"应用机器人发送群卡片失败: status={resp.status_code}, payload={payload}") + if payload.get("code") != 0: + raise RuntimeError(f"应用机器人发送群卡片失败: {payload}") + log_step("应用机器人群卡片发送完成") + + def find_target_table( tables: list[TableInfo], project: str, @@ -916,4 +1099,3 @@ def run_gmv_sync( 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 index 4008f22..e486900 100644 --- a/run_project_bitable_pipeline.py +++ b/run_project_bitable_pipeline.py @@ -2,26 +2,35 @@ import argparse from pathlib import Path from project_bitable_common import ( - DEFAULT_END_DATE, - DEFAULT_START_DATE, - DEFAULT_STORE_FIELD_NAME, + get_default_end_date, + get_default_start_date, get_base_url, + get_feishu_chat_id, + get_feishu_report_image_path, + get_group_webhook_url, get_mapping_file, + get_store_field_name, + iter_dates, load_dotenv, log_step, run_gmv_sync, run_metric_sync, + send_feishu_app_card, + send_feishu_sync_card, 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("--start-date", default=get_default_start_date(), help="开始日期,格式 YYYY-MM-DD") + parser.add_argument("--end-date", default=get_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("--store-field-name", default=get_store_field_name(), help="飞书中的店铺字段名") + parser.add_argument("--chat-id", default=get_feishu_chat_id(), help="飞书群 chat_id") + parser.add_argument("--report-image-path", default=get_feishu_report_image_path(), help="飞书卡片预览图本地路径") + parser.add_argument("--group-webhook", default=get_group_webhook_url(), help="飞书群机器人 webhook") 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="找不到对应月份的飞书表时跳过") @@ -36,37 +45,58 @@ def main() -> None: 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("流水线开始: 按天执行 销售 -> 退款 -> GMV") + for stat_date in iter_dates(start_date, end_date): + log_step(f"开始处理日期: {stat_date.isoformat()}") + run_metric_sync( + metric_name="sales", + start_date=stat_date, + end_date=stat_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=stat_date, + end_date=stat_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=stat_date, + end_date=stat_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(f"日期处理完成: {stat_date.isoformat()}") + if not args.dry_run: + if args.chat_id: + send_feishu_app_card( + chat_id=args.chat_id, + title="GMV数据同步", + base_url=args.base_url, + start_date=start_date, + end_date=end_date, + image_path=args.report_image_path, + ) + else: + send_feishu_sync_card( + webhook_url=args.group_webhook, + title="GMV数据同步", + base_url=args.base_url, + start_date=start_date, + end_date=end_date, + ) log_step("流水线完成") diff --git a/店铺映射表.xlsx b/店铺映射表.xlsx index 5af1e527972536cc25bdc087843c68474db3b0d2..b30ff3eccf3215ee99c382757741ef3d4d8d140d 100644 GIT binary patch delta 6349 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