This commit is contained in:
2026-03-24 16:33:14 +08:00
parent a9c7b7ead5
commit aae76442ec
7 changed files with 499 additions and 131 deletions
+189 -37
View File
@@ -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`,由容器自己维持调度。
Binary file not shown.
+34
View File
@@ -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"
}
}
}
+50
View File
@@ -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
+126 -21
View File
@@ -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 数据同步开始: {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)
except Exception:
notify_feishu.with_options(name="notify-feishu-failed")(config, config.start_date, config.end_date)
raise
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__":
+97 -70
View File
@@ -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()
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),
}
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 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,
Binary file not shown.