From aae76442ecd48f576cc6336723e124ee2979bf14 Mon Sep 17 00:00:00 2001 From: BXY0414 <1079823339@qq.com> Date: Tue, 24 Mar 2026 16:33:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DOCKER_PREFECT_USAGE.md | 226 +++++++++++++++--- .../project_bitable_common.cpython-312.pyc | Bin 75862 -> 79965 bytes field_group_state.json | 34 +++ prefect.yaml | 50 ++++ prefect_gmv_scheduler.py | 151 ++++++++++-- project_bitable_common.py | 169 +++++++------ 店铺映射表.xlsx | Bin 11281 -> 11422 bytes 7 files changed, 499 insertions(+), 131 deletions(-) create mode 100644 field_group_state.json create mode 100644 prefect.yaml diff --git a/DOCKER_PREFECT_USAGE.md b/DOCKER_PREFECT_USAGE.md index 5dfe212..c484472 100644 --- a/DOCKER_PREFECT_USAGE.md +++ b/DOCKER_PREFECT_USAGE.md @@ -1,40 +1,60 @@ -# Docker + Prefect +# Docker + Prefect 使用说明 -## 1. 构建镜像 +当前方案包含两条定时任务: + +- `08:00` 执行数据统计并写入飞书多维表格 +- `09:00` 推送结果到飞书群 + +调度配置写在 [prefect.yaml](/Users/idss/YXT_Project/gmv-analytics/prefect.yaml) 中。 + +## 1. 创建 Docker 镜像 + +在项目目录执行: ```bash cd /Users/idss/YXT_Project/gmv-analytics docker build -t gmv-analytics-prefect:latest . ``` -## 2. 单次执行 +镜像内容来自: -```bash -docker run --rm \ - --env-file /Users/idss/YXT_Project/gmv-analytics/.env \ - -e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \ - gmv-analytics-prefect:latest \ - python prefect_gmv_scheduler.py -``` +- [Dockerfile](/Users/idss/YXT_Project/gmv-analytics/Dockerfile) +- [docker-entrypoint.sh](/Users/idss/YXT_Project/gmv-analytics/docker-entrypoint.sh) -## 3. 启动每天 09:00 的 Prefect 调度 +容器启动后会: -这会连接 `.env` 中已经配置的 `PREFECT_API_URL`,并在 Prefect 中启动一个 deployment。 - -```bash -docker run --rm \ - --env-file /Users/idss/YXT_Project/gmv-analytics/.env \ - -e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \ - gmv-analytics-prefect:latest -``` - -容器默认执行: +- 加载 `.env` +- 自动把 `PROJECT_MAPPING_FILE` 修正为容器内的 `/app/店铺映射表.xlsx` +- 默认执行: ```bash python prefect_gmv_scheduler.py --serve ``` -也可以直接用 Compose: +## 2. 启动 Docker 容器 + +### 方式 A:直接用 `docker run` + +```bash +docker run -d \ + --name gmv-prefect-scheduler \ + --env-file /Users/idss/YXT_Project/gmv-analytics/.env \ + -e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \ + gmv-analytics-prefect:latest +``` + +停止容器: + +```bash +docker stop gmv-prefect-scheduler +docker rm gmv-prefect-scheduler +``` + +### 方式 B:用 `docker compose` + +项目里已经有 [docker-compose.yaml](/Users/idss/YXT_Project/gmv-analytics/docker-compose.yaml)。 + +启动: ```bash cd /Users/idss/YXT_Project/gmv-analytics @@ -47,40 +67,172 @@ docker compose up -d --build docker compose down ``` -## 4. 如果你是用 Prefect worker 跑容器 +## 3. 注册 Prefect 调度 -先保证你的 Prefect worker 已经连到同一个 `PREFECT_API_URL`。 +推荐方式是使用 [prefect.yaml](/Users/idss/YXT_Project/gmv-analytics/prefect.yaml) 注册 deployment: -推荐命令示例: +```bash +cd /Users/idss/YXT_Project/gmv-analytics +prefect deploy --all +``` + +这会向 `.env` 中的 `PREFECT_API_URL` 注册两个 deployment: + +- `gmv-daily-data-sync-8am` +- `gmv-daily-notify-9am` + +前提条件: + +- `.env` 中已经配置好 `PREFECT_API_URL` +- Prefect API 服务可访问 +- 已经有可用的 work pool + 当前 [prefect.yaml](/Users/idss/YXT_Project/gmv-analytics/prefect.yaml) 默认使用: + `default-agent-pool` + +如果还没有 worker,可以在宿主机启动一个: ```bash prefect worker start --pool default-agent-pool --type process ``` -如果你后面要改成真正的 Docker work pool,再在 Prefect UI 中把 deployment 的基础设施切到 Docker,并指定镜像: +## 4. 自动执行是怎么发生的 -```text -gmv-analytics-prefect:latest +如果你走的是 `prefect deploy --all` 方案: + +1. `prefect.yaml` 注册两个 deployment +2. Prefect Server/Cloud 按 cron 触发: + - `08:00`:`gmv-daily-data-sync-8am` + - `09:00`:`gmv-daily-notify-9am` +3. Prefect worker 拉起执行 +4. 代码入口在 [prefect_gmv_scheduler.py](/Users/idss/YXT_Project/gmv-analytics/prefect_gmv_scheduler.py) + +如果你走的是容器内 `--serve` 方案: + +1. 容器常驻运行 `python prefect_gmv_scheduler.py --serve` +2. 代码里会本地注册并维持两个定时调度 +3. 到时间后容器内部直接执行 flow + +建议: + +- 正式环境优先用 `prefect.yaml + prefect deploy --all` +- `--serve` 更适合本地或过渡期 + +## 5. 单次手动执行 + +只跑数据同步: + +```bash +python /Users/idss/YXT_Project/gmv-analytics/prefect_gmv_scheduler.py --mode sync ``` -## 5. 常用覆盖参数 +只跑飞书通知: -单次 dry-run: +```bash +python /Users/idss/YXT_Project/gmv-analytics/prefect_gmv_scheduler.py --mode notify +``` + +先同步再通知: + +```bash +python /Users/idss/YXT_Project/gmv-analytics/prefect_gmv_scheduler.py --mode all +``` + +如果要在 Docker 里手动执行: ```bash docker run --rm \ --env-file /Users/idss/YXT_Project/gmv-analytics/.env \ -e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \ gmv-analytics-prefect:latest \ - python prefect_gmv_scheduler.py --dry-run + python prefect_gmv_scheduler.py --mode sync ``` -指定时间范围: +## 6. Docker 中的日志 + +现在日志分成两层: + +### 6.1 容器标准输出日志 + +这是你最先看到的日志来源。 + +如果用 `docker run`: ```bash -docker run --rm \ - --env-file /Users/idss/YXT_Project/gmv-analytics/.env \ - -e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \ - gmv-analytics-prefect:latest \ - python prefect_gmv_scheduler.py --start-date 2026-03-01 --end-date 2026-03-12 +docker logs -f gmv-prefect-scheduler ``` + +如果用 `docker compose`: + +```bash +cd /Users/idss/YXT_Project/gmv-analytics +docker compose logs -f +``` + +这些日志包含: + +- Prefect flow 启动/完成 +- 每天数据同步流程 +- 飞书接口日志 +- 错误堆栈 + +### 6.2 容器内本地文件日志 + +项目已经接了统一日志模块 [logging_config.py](/Users/idss/YXT_Project/gmv-analytics/logging_config.py),所以日志还会写入容器内文件: + +- 默认目录:`/app/logs` +- 默认文件:`/app/logs/gmv_sync.log` + +进入容器查看: + +```bash +docker exec -it gmv-prefect-scheduler sh +tail -f /app/logs/gmv_sync.log +``` + +如果是 compose: + +```bash +docker exec -it gmv-prefect-scheduler sh +tail -f /app/logs/gmv_sync.log +``` + +### 6.3 日志级别 + +默认是 `INFO`。 +如果要看更细的调试日志,在 `.env` 里加: + +```bash +LOG_LEVEL=DEBUG +``` + +可选日志配置还有: + +```bash +LOG_DIR=/app/logs +LOG_FILE_NAME=gmv_sync.log +LOG_MAX_BYTES=10485760 +LOG_BACKUP_COUNT=7 +``` + +## 7. 现在最推荐的执行方式 + +如果你要的是“长期稳定自动执行”,建议这样: + +1. 构建镜像 +2. 准备好 `.env` +3. 启动 Prefect API +4. 启动 worker +5. 执行: + +```bash +cd /Users/idss/YXT_Project/gmv-analytics +prefect deploy --all +``` + +如果你只是希望一个容器自己在里面定时跑,也可以直接: + +```bash +docker compose up -d --build +``` + +这时它会直接跑 `--serve`,由容器自己维持调度。 diff --git a/__pycache__/project_bitable_common.cpython-312.pyc b/__pycache__/project_bitable_common.cpython-312.pyc index 95f58ec26ff960d0a8f5c7c70134398df9db3837..695cb010eaf50eea4fefb0f567bd90a6b16f5757 100644 GIT binary patch delta 18156 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 diff --git a/field_group_state.json b/field_group_state.json new file mode 100644 index 0000000..c34d3f1 --- /dev/null +++ b/field_group_state.json @@ -0,0 +1,34 @@ +{ + "XpA0biRh4arbSTsEAcuc7U1Znhh": { + "tblIdcsE2RP8b0V4": { + "汇总金额": "fgkKA4sTDeMhBDQ", + "GMV编组": "fgOuepoxtRQ9OxX", + "销售额/退款额": "fgScyMddEHsCkKp" + }, + "tblqQ5MtqfVDgBx7": { + "汇总金额": "fgkRBorMX90vTeA", + "GMV编组": "fg76wQNHbh2m7E2", + "销售额/退款额": "fg6vKipfag0BSxi" + }, + "tbljWa2kxC1GAFLO": { + "汇总金额": "fgF9Nj9fizqmrnK", + "GMV编组": "fg4HnQJQhxdwcyv", + "销售额/退款额": "fgoSAxK2ABO8C7e" + }, + "tblFnLz4YNVDKHhx": { + "汇总金额": "fghdR8NiF0ZMMi6", + "GMV编组": "fgR3uN6iTbJngoS", + "销售额/退款额": "fgdc1C0NkN5MwFr" + }, + "tblCo2dD4TWFebBv": { + "汇总金额": "fgLvnxp0hEpz6M4", + "GMV编组": "fgItrizttYa2tUO", + "销售额/退款额": "fg3GucwB5LnbJtj" + }, + "tblBuzDGVzRPlrlU": { + "汇总金额": "fgoFVymkm5pgKjQ", + "GMV编组": "fgwTPpWsXRy5q1b", + "销售额/退款额": "fg7sYpOSjx4DpAY" + } + } +} \ No newline at end of file diff --git a/prefect.yaml b/prefect.yaml new file mode 100644 index 0000000..3e67d78 --- /dev/null +++ b/prefect.yaml @@ -0,0 +1,50 @@ +name: gmv-analytics +prefect-version: "3.0.0" + +build: [] +push: [] +pull: [] + +deployments: + - name: gmv-daily-data-sync-8am + version: "1.0.0" + description: "每天上午 8 点执行 GMV 数据同步并写入飞书多维表格。" + entrypoint: prefect_gmv_scheduler.py:gmv_daily_data_sync_flow + parameters: + start_date: null + end_date: null + dry_run: false + skip_missing_tables: false + table_map_file: "" + tags: + - gmv + - feishu + - bitable + - sync + schedules: + - cron: "0 16 50 * *" + timezone: "Asia/Shanghai" + active: true + work_pool: + name: default-agent-pool + + - name: gmv-daily-notify-9am + version: "1.0.0" + description: "每天上午 9 点推送 GMV 数据同步结果到飞书群。" + entrypoint: prefect_gmv_scheduler.py:gmv_daily_notify_flow + parameters: + start_date: null + end_date: null + dry_run: false + skip_missing_tables: false + table_map_file: "" + tags: + - gmv + - feishu + - notify + schedules: + - cron: "0 17 30 * *" + timezone: "Asia/Shanghai" + active: true + work_pool: + name: default-agent-pool diff --git a/prefect_gmv_scheduler.py b/prefect_gmv_scheduler.py index ac8505e..b7ed1b0 100644 --- a/prefect_gmv_scheduler.py +++ b/prefect_gmv_scheduler.py @@ -1,4 +1,5 @@ import argparse +import json import os from dataclasses import dataclass from datetime import date @@ -42,6 +43,9 @@ from project_bitable_common import ( validate_date, ) +BASE_DIR = Path(__file__).resolve().parent +DEFAULT_PREFECT_SYNC_STATE_FILE = BASE_DIR / "prefect_sync_state.json" + @dataclass(frozen=True) class SyncConfig: @@ -58,6 +62,12 @@ class SyncConfig: skip_missing_tables: bool +@dataclass(frozen=True) +class SyncWindow: + start_date: date + end_date: date + + def load_sync_config( start_date: str | None = None, end_date: str | None = None, @@ -81,6 +91,68 @@ def load_sync_config( ) +def get_prefect_sync_state_file() -> Path: + return Path(os.getenv("PREFECT_SYNC_STATE_FILE", str(DEFAULT_PREFECT_SYNC_STATE_FILE))) + + +def load_prefect_sync_state() -> dict[str, str]: + path = get_prefect_sync_state_file() + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + log_step(f"读取 Prefect 同步状态失败,已忽略: path={path}, error={exc}") + return {} + return {str(key): str(value) for key, value in payload.items() if value is not None} + + +def save_prefect_sync_state(state: dict[str, str]) -> None: + path = get_prefect_sync_state_file() + path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + +def resolve_sync_window( + config: SyncConfig, + explicit_start_date: str | None, + explicit_end_date: str | None, + mode: str, +) -> SyncWindow: + if explicit_start_date or explicit_end_date: + start_date_value = validate_date(explicit_start_date or config.start_date) + end_date_value = validate_date(explicit_end_date or config.end_date) + if end_date_value < start_date_value: + raise ValueError("end-date 不能早于 start-date") + return SyncWindow(start_date=start_date_value, end_date=end_date_value) + + state = load_prefect_sync_state() + configured_start_date = validate_date(config.start_date) + yesterday = validate_date(config.end_date) + + if mode == "notify": + last_sync_start = state.get("last_sync_start_date") + last_sync_end = state.get("last_sync_end_date") + if last_sync_start and last_sync_end: + start_date_value = validate_date(last_sync_start) + end_date_value = validate_date(last_sync_end) + if end_date_value < start_date_value: + raise ValueError("prefect_sync_state.json 中的日期范围无效") + return SyncWindow(start_date=start_date_value, end_date=end_date_value) + return SyncWindow(start_date=configured_start_date, end_date=yesterday) + + if state.get("bootstrap_completed") == "true": + return SyncWindow(start_date=yesterday, end_date=yesterday) + return SyncWindow(start_date=configured_start_date, end_date=yesterday) + + +def mark_sync_window_completed(window: SyncWindow) -> None: + state = load_prefect_sync_state() + state["bootstrap_completed"] = "true" + state["last_sync_start_date"] = window.start_date.isoformat() + state["last_sync_end_date"] = window.end_date.isoformat() + save_prefect_sync_state(state) + + @task(name="notify-feishu") def notify_feishu(context: SyncConfig, start_date_text: str, end_date_text: str) -> None: context_obj = build_daily_sync_context( @@ -99,8 +171,8 @@ def notify_feishu(context: SyncConfig, start_date_text: str, end_date_text: str) send_sync_notification(context_obj, validate_date(start_date_text), validate_date(end_date_text)) -@flow(name="gmv-daily-sync") -def gmv_daily_sync_flow( +@flow(name="gmv-daily-data-sync") +def gmv_daily_data_sync_flow( start_date: str | None = None, end_date: str | None = None, dry_run: bool = False, @@ -114,11 +186,7 @@ def gmv_daily_sync_flow( skip_missing_tables=skip_missing_tables, table_map_file=table_map_file, ) - - start_date_value = validate_date(config.start_date) - end_date_value = validate_date(config.end_date) - if end_date_value < start_date_value: - raise ValueError("end-date 不能早于 start-date") + sync_window = resolve_sync_window(config, start_date, end_date, mode="sync") context = build_daily_sync_context( DailySyncConfig( base_url=config.base_url, @@ -133,27 +201,50 @@ def gmv_daily_sync_flow( ) ) - log_step(f"Prefect 调度开始: {config.start_date} -> {config.end_date}") - try: - for stat_date in iter_dates(start_date_value, end_date_value): - log_step(f"Prefect 开始处理日期: {stat_date.isoformat()}") - run_daily_sync_for_date(context, stat_date) - except Exception: - notify_feishu.with_options(name="notify-feishu-failed")(config, config.start_date, config.end_date) - raise + log_step(f"Prefect 数据同步开始: {sync_window.start_date.isoformat()} -> {sync_window.end_date.isoformat()}") + for stat_date in iter_dates(sync_window.start_date, sync_window.end_date): + log_step(f"Prefect 开始处理日期: {stat_date.isoformat()}") + run_daily_sync_for_date(context, stat_date) + if not config.dry_run: + mark_sync_window_completed(sync_window) + log_step("Prefect 数据同步完成") - notify_feishu(config, config.start_date, config.end_date) - log_step("Prefect 调度完成") + +@flow(name="gmv-daily-notify") +def gmv_daily_notify_flow( + start_date: str | None = None, + end_date: str | None = None, + dry_run: bool = False, + skip_missing_tables: bool = False, + table_map_file: str = "", +) -> None: + config = load_sync_config( + start_date=start_date, + end_date=end_date, + dry_run=dry_run, + skip_missing_tables=skip_missing_tables, + table_map_file=table_map_file, + ) + sync_window = resolve_sync_window(config, start_date, end_date, mode="notify") + log_step(f"Prefect 飞书通知开始: {sync_window.start_date.isoformat()} -> {sync_window.end_date.isoformat()}") + notify_feishu(config, sync_window.start_date.isoformat(), sync_window.end_date.isoformat()) + log_step("Prefect 飞书通知完成") def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Prefect 调度:每天上午 9 点执行 GMV 数据同步。") + parser = argparse.ArgumentParser(description="Prefect 调度:8点执行数据同步,9点执行飞书通知。") parser.add_argument("--start-date", default="", help="开始日期,格式 YYYY-MM-DD,默认读取 .env 中的 SYNC_START_DATE") parser.add_argument("--end-date", default="", help="结束日期,格式 YYYY-MM-DD,默认昨天") 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="找不到对应月份的飞书表时跳过") - parser.add_argument("--serve", action="store_true", help="启动 Prefect deployment,并按每天上午 9 点定时执行") + parser.add_argument("--serve", action="store_true", help="启动 Prefect deployments,按 8 点同步、9 点通知 定时执行") + parser.add_argument( + "--mode", + choices=("sync", "notify", "all"), + default="all", + help="单次执行模式:sync=只跑数据同步,notify=只发飞书通知,all=先同步再通知", + ) return parser.parse_args() @@ -167,15 +258,29 @@ def main() -> None: "table_map_file": args.table_map_file, } if args.serve: - gmv_daily_sync_flow.serve( - name="gmv-daily-sync-9am", - cron="0 9 * * *", + gmv_daily_data_sync_flow.serve( + name="gmv-daily-data-sync-8am", + cron="0 8 * * *", timezone="Asia/Shanghai", parameters=flow_kwargs, tags=["gmv", "feishu", "bitable"], ) + gmv_daily_notify_flow.serve( + name="gmv-daily-notify-9am", + cron="0 9 * * *", + timezone="Asia/Shanghai", + parameters=flow_kwargs, + tags=["gmv", "feishu", "notify"], + ) return - gmv_daily_sync_flow(**flow_kwargs) + if args.mode == "sync": + gmv_daily_data_sync_flow(**flow_kwargs) + return + if args.mode == "notify": + gmv_daily_notify_flow(**flow_kwargs) + return + gmv_daily_data_sync_flow(**flow_kwargs) + gmv_daily_notify_flow(**flow_kwargs) if __name__ == "__main__": diff --git a/project_bitable_common.py b/project_bitable_common.py index 51a52b1..a97592a 100644 --- a/project_bitable_common.py +++ b/project_bitable_common.py @@ -124,7 +124,7 @@ UNION ALL SELECT 统计日期, CASE - WHEN 业务模式 IN ('三方计算','三方分销') + WHEN 业务模式 IN ('三方结算','三方分销') THEN 原始店铺名称 || '_三方' ELSE 原始店铺名称 || '_自营' END AS 店铺名称, @@ -664,44 +664,24 @@ class FeishuBitableClient: self.request( "POST", f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields", - json={"field_name": field_name, "type": 2}, + json={"field_name": field_name, "type": 2, "property": {"formatter": "0.00"}}, ) self._fields_cache.pop(table_id, None) log_step(f"字段创建完成: table_id={table_id}, field={field_name}") return True - def try_list_field_groups(self, table_id: str) -> list[dict[str, Any]] | None: - path = f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/field_groups" - url = f"https://open.feishu.cn{path}" - log_debug(f"尝试读取字段编组: table_id={table_id}") - resp = self.session.request("GET", url, timeout=60) - try: - payload = resp.json() - except ValueError: - log_debug(f"字段编组接口返回非 JSON,跳过读取: table_id={table_id}, status={resp.status_code}") - return None - if resp.status_code == 404: - log_debug(f"字段编组读取接口不可用: table_id={table_id}, payload={payload}") - return None - if resp.status_code >= 400 or payload.get("code") != 0: - log_debug(f"字段编组读取失败,跳过比对: table_id={table_id}, payload={payload}") - return None - data = payload.get("data") or {} - items = data.get("items") or data.get("field_groups") or [] - log_debug(f"字段编组读取成功: table_id={table_id}, 编组数={len(items)}") - return items - - def create_field_groups(self, table_id: str, field_groups: list[dict[str, Any]]) -> None: + def create_field_groups(self, table_id: str, field_groups: list[dict[str, Any]]) -> dict[str, Any]: log_step( f"开始创建字段编组: table_id={table_id}, " f"groups={[(group.get('name') or '', len(group.get('children') or [])) for group in field_groups]}" ) - self.request( + data = self.request( "POST", f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/field_groups", json={"field_groups": field_groups}, ) log_step(f"字段编组创建请求完成: table_id={table_id}") + return data def list_records(self, table_id: str) -> list[dict[str, Any]]: if table_id in self._records_cache: @@ -1000,6 +980,7 @@ def build_field_group_payload( store_field_name: str, allowed_field_names: set[str] | None = None, ) -> list[dict[str, Any]]: + summary_fields: list[dict[str, str]] = [] gmv_fields: list[dict[str, str]] = [] other_fields: list[dict[str, str]] = [] for field in fields: @@ -1012,11 +993,16 @@ def build_field_group_payload( if allowed_field_names is not None and field_name not in allowed_field_names: continue child = {"type": "field", "id": field_id} + if field_name == get_summary_gmv_field_name(): + summary_fields.append(child) + continue if "gmv" in field_name.lower(): gmv_fields.append(child) else: other_fields.append(child) groups: list[dict[str, Any]] = [] + if summary_fields: + groups.append({"name": "汇总金额", "children": summary_fields}) if gmv_fields: groups.append({"name": "GMV编组", "children": gmv_fields}) if other_fields: @@ -1024,22 +1010,13 @@ def build_field_group_payload( return groups -def normalize_field_groups(field_groups: list[dict[str, Any]]) -> dict[str, set[str]]: - normalized: dict[str, set[str]] = {} - for group in field_groups: - name = str(group.get("name") or "").strip() - if not name: - continue - normalized[name] = { - str(child.get("id") or "").strip() - for child in (group.get("children") or []) - if str(child.get("id") or "").strip() - } - return normalized - - -def get_field_group_order(field_groups: list[dict[str, Any]]) -> list[str]: - return [str(group.get("name") or "").strip() for group in field_groups if str(group.get("name") or "").strip()] +def get_incremental_field_names_for_day(stat_date: date) -> set[str]: + return { + get_summary_gmv_field_name(), + get_gmv_field_name(stat_date), + get_sales_field_name(stat_date), + get_refund_field_name(stat_date), + } def ensure_field_groups_for_table( @@ -1057,21 +1034,6 @@ def ensure_field_groups_for_table( return desired_summary = {group["name"]: len(group.get("children") or []) for group in desired_groups} log_step(f"目标字段编组: 表={table.name}, summary={desired_summary}") - existing_groups = client.try_list_field_groups(table.table_id) - if existing_groups is not None: - existing_normalized = normalize_field_groups(existing_groups) - desired_normalized = normalize_field_groups(desired_groups) - existing_order = get_field_group_order(existing_groups) - desired_order = get_field_group_order(desired_groups) - if existing_normalized == desired_normalized and existing_order == desired_order: - log_step(f"字段编组已存在且一致,跳过创建: 表={table.name}") - return - log_debug( - f"字段编组存在差异: 表={table.name}, " - f"existing={ {name: len(ids) for name, ids in existing_normalized.items()} }, " - f"desired={ {name: len(ids) for name, ids in desired_normalized.items()} }, " - f"existing_order={existing_order}, desired_order={desired_order}" - ) if dry_run: print(f"[DRY-RUN] 表={table.name} 将创建/更新字段编组: {desired_summary}") return @@ -1087,6 +1049,12 @@ def ensure_field_groups_for_table( "原因=应用缺少 base:field_group:create 权限,请在飞书开放平台开通后重试" ) return + if "1254117" in error_text or "The name of field group is duplicated" in error_text: + log_step( + f"跳过字段编组创建: 表={table.name}, " + "原因=字段编组已存在同名分组,按幂等场景处理" + ) + return if "1254122" in error_text or "Child belongs to multiple field groups" in error_text: log_step(f"检测到字段已被其他编组占用,尝试逐个创建编组: 表={table.name}") for group in desired_groups: @@ -1143,6 +1111,31 @@ def sync_field_groups_for_range( log_step("字段编组同步完成") +def rebuild_field_groups_for_range_with_context( + context: DailySyncContext, + start_date: date, + end_date: date, +) -> None: + log_step(f"开始重建字段编组: {start_date.isoformat()} -> {end_date.isoformat()}") + for (year, month), _ in sorted(build_group_dates_by_month(start_date, end_date).items()): + for project in SUPPORTED_PROJECTS: + try: + table = find_target_table(context.tables, project, year, month, context.table_overrides) + except ValueError as exc: + if context.config.skip_missing_tables: + log_step(f"跳过字段编组重建: {exc}") + continue + raise + ensure_field_groups_for_table( + client=context.client, + table=table, + store_field_name=context.config.store_field_name, + allowed_field_names=None, + dry_run=context.config.dry_run, + ) + log_step("字段编组重建完成") + + def sync_field_groups_for_day_with_context( context: DailySyncContext, stat_date: date, @@ -1155,11 +1148,7 @@ def sync_field_groups_for_day_with_context( if allowed_field_names_override is not None: allowed_field_names = set(allowed_field_names_override) elif not force_full_rebuild and stat_date.day != 1: - allowed_field_names = { - get_gmv_field_name(stat_date), - get_sales_field_name(stat_date), - get_refund_field_name(stat_date), - } + allowed_field_names = get_incremental_field_names_for_day(stat_date) log_step(f"增量编组目标字段: 日期={stat_date.isoformat()}, fields={sorted(allowed_field_names)}") for project in SUPPORTED_PROJECTS: try: @@ -1355,19 +1344,24 @@ def get_gmv_field_name(stat_date: date) -> str: return stat_date.strftime("%m-%d GMV") +def get_summary_gmv_field_name() -> str: + return "汇总GMV" + + def sync_gmv_table_for_day( client: FeishuBitableClient, table: TableInfo, stat_date: date, preferred_store_field_name: str, dry_run: bool, -) -> bool: +) -> set[str]: 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) + summary_gmv_field_name = get_summary_gmv_field_name() field_names = [str(item.get("field_name") or "") for item in fields] log_debug( f"GMV 字段检查: 表={table.name}, 店铺字段={store_field_name}, 销售字段={sales_field_name in field_names}, " @@ -1375,10 +1369,20 @@ def sync_gmv_table_for_day( ) if sales_field_name not in field_names or refund_field_name not in field_names: log_step(f"跳过 GMV 计算: 表={table.name}, 原因=缺少销售额或退款额字段") - return False - created_field = False + return set() + created_fields: set[str] = set() if not dry_run: - created_field = client.ensure_number_field(table.table_id, gmv_field_name) + if client.ensure_number_field(table.table_id, gmv_field_name): + created_fields.add(gmv_field_name) + if client.ensure_number_field(table.table_id, summary_gmv_field_name): + created_fields.add(summary_gmv_field_name) + fields = client.list_fields(table.table_id) + field_names = [str(item.get("field_name") or "") for item in fields] + month_gmv_field_names = sorted( + field_name + for field_name in field_names + if field_name.endswith(" GMV") and field_name != summary_gmv_field_name + ) records = client.list_records(table.table_id) preview_stores = [] updated_count = 0 @@ -1398,18 +1402,27 @@ def sync_gmv_table_for_day( skipped_empty_count += 1 continue gmv_value = round((sales_value or 0.0) - (refund_value or 0.0), 2) + summary_gmv_value = round( + sum(parse_number(record_fields.get(name)) or 0.0 for name in month_gmv_field_names if name != gmv_field_name) + gmv_value, + 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}" + f"退款额={refund_value if refund_value is not None else ''} GMV={gmv_value} " + f"{summary_gmv_field_name}={summary_gmv_value}" ) else: - client.update_record(table.table_id, record_id, {gmv_field_name: gmv_value}) + client.update_record( + table.table_id, + record_id, + {gmv_field_name: gmv_value, summary_gmv_field_name: summary_gmv_value}, + ) updated_count += 1 log_debug(f"飞书记录店铺预览: 表={table.name}, stores={preview_list(preview_stores)}") log_step(f"GMV 计算完成: 表={table.name}, 更新记录数={updated_count}, 双空跳过={skipped_empty_count}") - return created_field + return created_fields def run_gmv_sync_with_context( @@ -1434,8 +1447,7 @@ def run_gmv_sync_with_context( preferred_store_field_name=context.config.store_field_name, dry_run=context.config.dry_run, ) - if created: - created_fields.add(get_gmv_field_name(stat_date)) + created_fields.update(created) return MetricSyncArtifacts(created_fields=created_fields) @@ -1460,6 +1472,17 @@ def run_gmv_sync( run_gmv_sync_with_context(context, start_date, end_date) +def run_daily_data_sync_for_date(context: DailySyncContext, stat_date: date) -> None: + log_step(f"开始处理日期: {stat_date.isoformat()}") + created_fields: set[str] = set() + for metric_name in ("sales", "refund"): + artifacts = run_metric_sync_with_context(context, metric_name, stat_date, stat_date) + created_fields.update(artifacts.created_fields) + gmv_artifacts = run_gmv_sync_with_context(context, stat_date, stat_date) + created_fields.update(gmv_artifacts.created_fields) + log_step(f"日期处理完成: {stat_date.isoformat()}") + + def run_daily_sync_for_date(context: DailySyncContext, stat_date: date) -> None: log_step(f"开始处理日期: {stat_date.isoformat()}") created_fields: set[str] = set() @@ -1471,9 +1494,13 @@ def run_daily_sync_for_date(context: DailySyncContext, stat_date: date) -> None: should_sync_field_groups = stat_date.day == 1 or bool(created_fields) if should_sync_field_groups: + if stat_date.day == 1: + sync_reason = "每月1号完整编组" + else: + sync_reason = f"新增字段={sorted(created_fields)}" log_step( f"触发字段编组同步: 日期={stat_date.isoformat()}, " - f"原因={'每月1号完整编组' if stat_date.day == 1 else f'新增字段={sorted(created_fields)}'}" + f"原因={sync_reason}" ) sync_field_groups_for_day_with_context( context=context, diff --git a/店铺映射表.xlsx b/店铺映射表.xlsx index b30ff3eccf3215ee99c382757741ef3d4d8d140d..46b3dcf37527f789e7b1edc94cf1ddbbe957d57c 100644 GIT binary patch delta 3261 zcmb7HXH=70vredyA}s_62nkX`4|tg}@Sb{<@a4D19Vn>#b|ko~?>f7{3XVUB)1GH3t5=HOc0N-X#k6 zt0@81-C5al=SYQ{g`$DHGnww2>q=wkTG7yG`v=$-+0Y7kX)!Y5YC{wlcNPoAcMxhYUG^~BCA|6ybYN^?043!R_+2zqiSDKH|8!> zdDA`fDHfA{81P=WKq0kKs-h;vjdqslG6N5!w0y#QE6Fmdhmgl5Ubxi5t}dqk+J90d zyE3iDKgps(AyM~5TgN+xOp0{F8a~IuVqNEtT8D^&2>E6qV|+Ji?mBIaA}RyOFf9ym z>Hk4Y!~X)u+9&pbl7b$wT35q>ahjEK{~}9@t(cOe5tNV`#u8G z0Cr{8z%NXAB8*!m-5xP+np?c@EO5GY(jJ(N{uW!H&ROlzszJHBJ9pA900h-^lCc1r z1LRKhhnd(6`x1Y!d61W+(8_Y&u`OQSHQc}= zpdw@0-lR(e{(7m*hu3pVCHpH+N2Qg)U|3jvL)61}#>)x{)|0LUT#ssFMBT z^f4b(>kgMmybr7B=5=n0g!4|Jr2FQTx=HK)&kn=br)Rg1F*&g>ion6w8|}a0UBx~Z zH1?wj?4pHqK-(KA$+-UU`y(W6)~T0*Duu}`zGrf+{~$QrJp5#mR`kBK?dlwA^>gu% z_gL)2l~n1`qy1C(bT&oAOY`vxNv<(;hxX8>;jI7g6Qg+p!T=y3Qgo{et~0A}S6ofp zegHZ{IPrvD8@kf)L8$q;d2QL=@S?$iPGD@{R9LkwP?Fj!(!F58@J@?jbYLDBdr*7) z;QiFi)gAw&HiaT)9T_zZzin8fa+5&=Ea=IC4MQu6EtCqn|1x&@V>{{cF5QWZJNb^U z>~f-F2Ddbo)OpC;9f)ADh@+F)kd482kQ7tKeezvji56KaqI8mE0V@7V+am!GTKrEQ z%>Ap`z%AayH%_*kI#~=LU1vvKxP`|(tkSEB?P5gNM|5ub*=O8SLDs6eeNdSL@$+3H z^q}wh48cx97OAD)>%-cAdO zSFC0XbPpnyDTZ`-nfjPBF-KUYT<(kf_XzYT;6R_(BX13l&Cf~}s7cr4o)3}BJpTmp zRG03nbpn1%XU&M3cE^9rRU>PhYGO#=C6w(%Uc2dbx5iN?0Zz=VTB@5fiaF$Gu8Q5a zH|pfA6FeQ_S8YGXCjaq|x|b5oH@E3VxAP8(%G+_tuwV>UHodN?nv%k*niT1uC6AUZcM1K}-u@KveTo+?a@RjCvWr_K-be@su9c96n zSHcIZ6AF)gq<|YjhBpwGtQYA^TW=Q2jBdMLiA39F;OTgdw10iM(p^olN{uuI0H1b@({2jAaI&^+_S!zgdNg6k6p;J<=&G5g!aRW+0siuESC_Df zkX7#R!t*8-TxeZ8r6eUxfg7mIcLc+zoXNs!j{t*vbJnLN*YG7;UrMkBd;6+~`IQC@aQj zUy*-8f2;bz3F0fIs;>C2ta>r%(TX9*%l{+%Ul7dS5QWigQOs{hrNe4a)O+9QO;JpM z5&L#azuf--`dk^nZ5z@8k?)l_hfYIi1V(hi_TG2z5U9=lr!bc;z zuttqs4Xd*WpN0~9m}dD8tn1?Jo{$UOPoBq2xYb14mNXen1+l4VH1;Zt%LWOiQ%~l; zP2xIaiHT5cCQ-rj3i-$^2z4I;PDoP95m)B|fMXLzRzeun=@^vr(`%ZMIIqWeN1!)G7-8YQ$|Ww-@Sv!pJ%J& zdj_)beK1^D{*Fywj~TD*J0*Ke2&dG-=;>BYM*=_Kio6}4x;5=ZWhHA&%a_ii|vsPe?C$o?Ek z$(W&)->Ape7cG4$uqFjo-B7s==E&2sAIy`WIX|wlQW{x$&a3)cDM{~Y$T$0Qz~u1O zcHSO@g=Xi4u`+iqgi&TA;W)bz5y(0oftFr2IrUlTEq$Pw)U?kXO5DT}cqBW*_gr+Z zw+aJyb4bCW?X!L~p?oe}jUyMmRzf06YUATc@9QJJiwen9-N)Q0 zHK8L*ch) zq6?ZYsfpIrt+JHHMJ+`?Ivm;{9}0EJ$j(*VSii((+!_-H^G}bqBL>6+qU9Qo%WE|5 z%U^;%P`WMT_~JAcxo@d2qS$<4zMB=EbWw06IJFc*oVWSW=V9C8?EVe_|I1a&x*VpZaIS!@|aOMy4|cIh)D3{$F7u zpA&U1@zLh$b80EbSFCdI14+94GE765Hp52bJXU;{tzYsJM$KzVll~4?`@;)ex0;8> z&o1I)ld|#jGfU1c8Rr)g`<7En7k2n9&uOtK=W^bF8}gVY5B7=5FpE(Zg=<;Of`XyR zLAqa?4Ewj>f;L>aNV1Gm)tOJtN66=v9%{oy%popLJ~ly**+1qW06+Zd6u;~GVGMju zyTWjMdMd!^aiH4Tx4QO~1%_pXi3R;MgiuGAw3lH47yR(gmacpa(02Q&k0Qd@cK6Pi zwK97cC4DSYw;ef{cA;=Uah|LrA+6&MQBn#D^$bSVfOybG`o&{YwR(11;ZxnqHMDbq z_InEDsjqz9wKmd$H0HUA4^GK+J$AcS#P5qx&13lba-v9sr delta 3091 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 Z1E^r6#s7ca{NM5k;2H+awJHCb{Tr7g%>DoX