调整
This commit is contained in:
+189
-37
@@ -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
|
```bash
|
||||||
cd /Users/idss/YXT_Project/gmv-analytics
|
cd /Users/idss/YXT_Project/gmv-analytics
|
||||||
docker build -t gmv-analytics-prefect:latest .
|
docker build -t gmv-analytics-prefect:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. 单次执行
|
镜像内容来自:
|
||||||
|
|
||||||
```bash
|
- [Dockerfile](/Users/idss/YXT_Project/gmv-analytics/Dockerfile)
|
||||||
docker run --rm \
|
- [docker-entrypoint.sh](/Users/idss/YXT_Project/gmv-analytics/docker-entrypoint.sh)
|
||||||
--env-file /Users/idss/YXT_Project/gmv-analytics/.env \
|
|
||||||
-e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \
|
|
||||||
gmv-analytics-prefect:latest \
|
|
||||||
python prefect_gmv_scheduler.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 启动每天 09:00 的 Prefect 调度
|
容器启动后会:
|
||||||
|
|
||||||
这会连接 `.env` 中已经配置的 `PREFECT_API_URL`,并在 Prefect 中启动一个 deployment。
|
- 加载 `.env`
|
||||||
|
- 自动把 `PROJECT_MAPPING_FILE` 修正为容器内的 `/app/店铺映射表.xlsx`
|
||||||
```bash
|
- 默认执行:
|
||||||
docker run --rm \
|
|
||||||
--env-file /Users/idss/YXT_Project/gmv-analytics/.env \
|
|
||||||
-e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \
|
|
||||||
gmv-analytics-prefect:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
容器默认执行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python prefect_gmv_scheduler.py --serve
|
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
|
```bash
|
||||||
cd /Users/idss/YXT_Project/gmv-analytics
|
cd /Users/idss/YXT_Project/gmv-analytics
|
||||||
@@ -47,40 +67,172 @@ docker compose up -d --build
|
|||||||
docker compose down
|
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
|
```bash
|
||||||
prefect worker start --pool default-agent-pool --type process
|
prefect worker start --pool default-agent-pool --type process
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你后面要改成真正的 Docker work pool,再在 Prefect UI 中把 deployment 的基础设施切到 Docker,并指定镜像:
|
## 4. 自动执行是怎么发生的
|
||||||
|
|
||||||
```text
|
如果你走的是 `prefect deploy --all` 方案:
|
||||||
gmv-analytics-prefect:latest
|
|
||||||
|
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
|
```bash
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--env-file /Users/idss/YXT_Project/gmv-analytics/.env \
|
--env-file /Users/idss/YXT_Project/gmv-analytics/.env \
|
||||||
-e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \
|
-e PROJECT_MAPPING_FILE=/app/店铺映射表.xlsx \
|
||||||
gmv-analytics-prefect:latest \
|
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
|
```bash
|
||||||
docker run --rm \
|
docker logs -f gmv-prefect-scheduler
|
||||||
--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 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.
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -42,6 +43,9 @@ from project_bitable_common import (
|
|||||||
validate_date,
|
validate_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_PREFECT_SYNC_STATE_FILE = BASE_DIR / "prefect_sync_state.json"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SyncConfig:
|
class SyncConfig:
|
||||||
@@ -58,6 +62,12 @@ class SyncConfig:
|
|||||||
skip_missing_tables: bool
|
skip_missing_tables: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SyncWindow:
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
|
||||||
|
|
||||||
def load_sync_config(
|
def load_sync_config(
|
||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_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")
|
@task(name="notify-feishu")
|
||||||
def notify_feishu(context: SyncConfig, start_date_text: str, end_date_text: str) -> None:
|
def notify_feishu(context: SyncConfig, start_date_text: str, end_date_text: str) -> None:
|
||||||
context_obj = build_daily_sync_context(
|
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))
|
send_sync_notification(context_obj, validate_date(start_date_text), validate_date(end_date_text))
|
||||||
|
|
||||||
|
|
||||||
@flow(name="gmv-daily-sync")
|
@flow(name="gmv-daily-data-sync")
|
||||||
def gmv_daily_sync_flow(
|
def gmv_daily_data_sync_flow(
|
||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
@@ -114,11 +186,7 @@ def gmv_daily_sync_flow(
|
|||||||
skip_missing_tables=skip_missing_tables,
|
skip_missing_tables=skip_missing_tables,
|
||||||
table_map_file=table_map_file,
|
table_map_file=table_map_file,
|
||||||
)
|
)
|
||||||
|
sync_window = resolve_sync_window(config, start_date, end_date, mode="sync")
|
||||||
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")
|
|
||||||
context = build_daily_sync_context(
|
context = build_daily_sync_context(
|
||||||
DailySyncConfig(
|
DailySyncConfig(
|
||||||
base_url=config.base_url,
|
base_url=config.base_url,
|
||||||
@@ -133,27 +201,50 @@ def gmv_daily_sync_flow(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
log_step(f"Prefect 调度开始: {config.start_date} -> {config.end_date}")
|
log_step(f"Prefect 数据同步开始: {sync_window.start_date.isoformat()} -> {sync_window.end_date.isoformat()}")
|
||||||
try:
|
for stat_date in iter_dates(sync_window.start_date, sync_window.end_date):
|
||||||
for stat_date in iter_dates(start_date_value, end_date_value):
|
|
||||||
log_step(f"Prefect 开始处理日期: {stat_date.isoformat()}")
|
log_step(f"Prefect 开始处理日期: {stat_date.isoformat()}")
|
||||||
run_daily_sync_for_date(context, stat_date)
|
run_daily_sync_for_date(context, stat_date)
|
||||||
except Exception:
|
if not config.dry_run:
|
||||||
notify_feishu.with_options(name="notify-feishu-failed")(config, config.start_date, config.end_date)
|
mark_sync_window_completed(sync_window)
|
||||||
raise
|
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:
|
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("--start-date", default="", help="开始日期,格式 YYYY-MM-DD,默认读取 .env 中的 SYNC_START_DATE")
|
||||||
parser.add_argument("--end-date", default="", help="结束日期,格式 YYYY-MM-DD,默认昨天")
|
parser.add_argument("--end-date", default="", help="结束日期,格式 YYYY-MM-DD,默认昨天")
|
||||||
parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径")
|
parser.add_argument("--table-map-file", default="", help="可选,项目到表名的映射 JSON 文件路径")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口")
|
parser.add_argument("--dry-run", action="store_true", help="只输出将要更新的内容,不调用飞书写接口")
|
||||||
parser.add_argument("--skip-missing-tables", action="store_true", help="找不到对应月份的飞书表时跳过")
|
parser.add_argument("--skip-missing-tables", action="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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -167,15 +258,29 @@ def main() -> None:
|
|||||||
"table_map_file": args.table_map_file,
|
"table_map_file": args.table_map_file,
|
||||||
}
|
}
|
||||||
if args.serve:
|
if args.serve:
|
||||||
gmv_daily_sync_flow.serve(
|
gmv_daily_data_sync_flow.serve(
|
||||||
name="gmv-daily-sync-9am",
|
name="gmv-daily-data-sync-8am",
|
||||||
cron="0 9 * * *",
|
cron="0 8 * * *",
|
||||||
timezone="Asia/Shanghai",
|
timezone="Asia/Shanghai",
|
||||||
parameters=flow_kwargs,
|
parameters=flow_kwargs,
|
||||||
tags=["gmv", "feishu", "bitable"],
|
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
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+97
-70
@@ -124,7 +124,7 @@ UNION ALL
|
|||||||
SELECT
|
SELECT
|
||||||
统计日期,
|
统计日期,
|
||||||
CASE
|
CASE
|
||||||
WHEN 业务模式 IN ('三方计算','三方分销')
|
WHEN 业务模式 IN ('三方结算','三方分销')
|
||||||
THEN 原始店铺名称 || '_三方'
|
THEN 原始店铺名称 || '_三方'
|
||||||
ELSE 原始店铺名称 || '_自营'
|
ELSE 原始店铺名称 || '_自营'
|
||||||
END AS 店铺名称,
|
END AS 店铺名称,
|
||||||
@@ -664,44 +664,24 @@ class FeishuBitableClient:
|
|||||||
self.request(
|
self.request(
|
||||||
"POST",
|
"POST",
|
||||||
f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields",
|
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)
|
self._fields_cache.pop(table_id, None)
|
||||||
log_step(f"字段创建完成: table_id={table_id}, field={field_name}")
|
log_step(f"字段创建完成: table_id={table_id}, field={field_name}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def try_list_field_groups(self, table_id: str) -> list[dict[str, Any]] | None:
|
def create_field_groups(self, table_id: str, field_groups: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
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:
|
|
||||||
log_step(
|
log_step(
|
||||||
f"开始创建字段编组: table_id={table_id}, "
|
f"开始创建字段编组: table_id={table_id}, "
|
||||||
f"groups={[(group.get('name') or '', len(group.get('children') or [])) for group in field_groups]}"
|
f"groups={[(group.get('name') or '', len(group.get('children') or [])) for group in field_groups]}"
|
||||||
)
|
)
|
||||||
self.request(
|
data = self.request(
|
||||||
"POST",
|
"POST",
|
||||||
f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/field_groups",
|
f"/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/field_groups",
|
||||||
json={"field_groups": field_groups},
|
json={"field_groups": field_groups},
|
||||||
)
|
)
|
||||||
log_step(f"字段编组创建请求完成: table_id={table_id}")
|
log_step(f"字段编组创建请求完成: table_id={table_id}")
|
||||||
|
return data
|
||||||
|
|
||||||
def list_records(self, table_id: str) -> list[dict[str, Any]]:
|
def list_records(self, table_id: str) -> list[dict[str, Any]]:
|
||||||
if table_id in self._records_cache:
|
if table_id in self._records_cache:
|
||||||
@@ -1000,6 +980,7 @@ def build_field_group_payload(
|
|||||||
store_field_name: str,
|
store_field_name: str,
|
||||||
allowed_field_names: set[str] | None = None,
|
allowed_field_names: set[str] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
summary_fields: list[dict[str, str]] = []
|
||||||
gmv_fields: list[dict[str, str]] = []
|
gmv_fields: list[dict[str, str]] = []
|
||||||
other_fields: list[dict[str, str]] = []
|
other_fields: list[dict[str, str]] = []
|
||||||
for field in fields:
|
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:
|
if allowed_field_names is not None and field_name not in allowed_field_names:
|
||||||
continue
|
continue
|
||||||
child = {"type": "field", "id": field_id}
|
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():
|
if "gmv" in field_name.lower():
|
||||||
gmv_fields.append(child)
|
gmv_fields.append(child)
|
||||||
else:
|
else:
|
||||||
other_fields.append(child)
|
other_fields.append(child)
|
||||||
groups: list[dict[str, Any]] = []
|
groups: list[dict[str, Any]] = []
|
||||||
|
if summary_fields:
|
||||||
|
groups.append({"name": "汇总金额", "children": summary_fields})
|
||||||
if gmv_fields:
|
if gmv_fields:
|
||||||
groups.append({"name": "GMV编组", "children": gmv_fields})
|
groups.append({"name": "GMV编组", "children": gmv_fields})
|
||||||
if other_fields:
|
if other_fields:
|
||||||
@@ -1024,22 +1010,13 @@ def build_field_group_payload(
|
|||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
def normalize_field_groups(field_groups: list[dict[str, Any]]) -> dict[str, set[str]]:
|
def get_incremental_field_names_for_day(stat_date: date) -> set[str]:
|
||||||
normalized: dict[str, set[str]] = {}
|
return {
|
||||||
for group in field_groups:
|
get_summary_gmv_field_name(),
|
||||||
name = str(group.get("name") or "").strip()
|
get_gmv_field_name(stat_date),
|
||||||
if not name:
|
get_sales_field_name(stat_date),
|
||||||
continue
|
get_refund_field_name(stat_date),
|
||||||
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 ensure_field_groups_for_table(
|
def ensure_field_groups_for_table(
|
||||||
@@ -1057,21 +1034,6 @@ def ensure_field_groups_for_table(
|
|||||||
return
|
return
|
||||||
desired_summary = {group["name"]: len(group.get("children") or []) for group in desired_groups}
|
desired_summary = {group["name"]: len(group.get("children") or []) for group in desired_groups}
|
||||||
log_step(f"目标字段编组: 表={table.name}, summary={desired_summary}")
|
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:
|
if dry_run:
|
||||||
print(f"[DRY-RUN] 表={table.name} 将创建/更新字段编组: {desired_summary}")
|
print(f"[DRY-RUN] 表={table.name} 将创建/更新字段编组: {desired_summary}")
|
||||||
return
|
return
|
||||||
@@ -1087,6 +1049,12 @@ def ensure_field_groups_for_table(
|
|||||||
"原因=应用缺少 base:field_group:create 权限,请在飞书开放平台开通后重试"
|
"原因=应用缺少 base:field_group:create 权限,请在飞书开放平台开通后重试"
|
||||||
)
|
)
|
||||||
return
|
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:
|
if "1254122" in error_text or "Child belongs to multiple field groups" in error_text:
|
||||||
log_step(f"检测到字段已被其他编组占用,尝试逐个创建编组: 表={table.name}")
|
log_step(f"检测到字段已被其他编组占用,尝试逐个创建编组: 表={table.name}")
|
||||||
for group in desired_groups:
|
for group in desired_groups:
|
||||||
@@ -1143,6 +1111,31 @@ def sync_field_groups_for_range(
|
|||||||
log_step("字段编组同步完成")
|
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(
|
def sync_field_groups_for_day_with_context(
|
||||||
context: DailySyncContext,
|
context: DailySyncContext,
|
||||||
stat_date: date,
|
stat_date: date,
|
||||||
@@ -1155,11 +1148,7 @@ def sync_field_groups_for_day_with_context(
|
|||||||
if allowed_field_names_override is not None:
|
if allowed_field_names_override is not None:
|
||||||
allowed_field_names = set(allowed_field_names_override)
|
allowed_field_names = set(allowed_field_names_override)
|
||||||
elif not force_full_rebuild and stat_date.day != 1:
|
elif not force_full_rebuild and stat_date.day != 1:
|
||||||
allowed_field_names = {
|
allowed_field_names = get_incremental_field_names_for_day(stat_date)
|
||||||
get_gmv_field_name(stat_date),
|
|
||||||
get_sales_field_name(stat_date),
|
|
||||||
get_refund_field_name(stat_date),
|
|
||||||
}
|
|
||||||
log_step(f"增量编组目标字段: 日期={stat_date.isoformat()}, fields={sorted(allowed_field_names)}")
|
log_step(f"增量编组目标字段: 日期={stat_date.isoformat()}, fields={sorted(allowed_field_names)}")
|
||||||
for project in SUPPORTED_PROJECTS:
|
for project in SUPPORTED_PROJECTS:
|
||||||
try:
|
try:
|
||||||
@@ -1355,19 +1344,24 @@ def get_gmv_field_name(stat_date: date) -> str:
|
|||||||
return stat_date.strftime("%m-%d GMV")
|
return stat_date.strftime("%m-%d GMV")
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary_gmv_field_name() -> str:
|
||||||
|
return "汇总GMV"
|
||||||
|
|
||||||
|
|
||||||
def sync_gmv_table_for_day(
|
def sync_gmv_table_for_day(
|
||||||
client: FeishuBitableClient,
|
client: FeishuBitableClient,
|
||||||
table: TableInfo,
|
table: TableInfo,
|
||||||
stat_date: date,
|
stat_date: date,
|
||||||
preferred_store_field_name: str,
|
preferred_store_field_name: str,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> bool:
|
) -> set[str]:
|
||||||
log_step(f"开始计算 GMV: 表={table.name}, 日期={stat_date.isoformat()}")
|
log_step(f"开始计算 GMV: 表={table.name}, 日期={stat_date.isoformat()}")
|
||||||
fields = client.list_fields(table.table_id)
|
fields = client.list_fields(table.table_id)
|
||||||
store_field_name = resolve_store_field_name(fields, preferred_store_field_name)
|
store_field_name = resolve_store_field_name(fields, preferred_store_field_name)
|
||||||
sales_field_name = get_sales_field_name(stat_date)
|
sales_field_name = get_sales_field_name(stat_date)
|
||||||
refund_field_name = get_refund_field_name(stat_date)
|
refund_field_name = get_refund_field_name(stat_date)
|
||||||
gmv_field_name = get_gmv_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]
|
field_names = [str(item.get("field_name") or "") for item in fields]
|
||||||
log_debug(
|
log_debug(
|
||||||
f"GMV 字段检查: 表={table.name}, 店铺字段={store_field_name}, 销售字段={sales_field_name in field_names}, "
|
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:
|
if sales_field_name not in field_names or refund_field_name not in field_names:
|
||||||
log_step(f"跳过 GMV 计算: 表={table.name}, 原因=缺少销售额或退款额字段")
|
log_step(f"跳过 GMV 计算: 表={table.name}, 原因=缺少销售额或退款额字段")
|
||||||
return False
|
return set()
|
||||||
created_field = False
|
created_fields: set[str] = set()
|
||||||
if not dry_run:
|
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)
|
records = client.list_records(table.table_id)
|
||||||
preview_stores = []
|
preview_stores = []
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
@@ -1398,18 +1402,27 @@ def sync_gmv_table_for_day(
|
|||||||
skipped_empty_count += 1
|
skipped_empty_count += 1
|
||||||
continue
|
continue
|
||||||
gmv_value = round((sales_value or 0.0) - (refund_value or 0.0), 2)
|
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:
|
if dry_run:
|
||||||
print(
|
print(
|
||||||
f"[DRY-RUN] 表={table.name} 店铺={store_name} 字段={gmv_field_name} "
|
f"[DRY-RUN] 表={table.name} 店铺={store_name} 字段={gmv_field_name} "
|
||||||
f"销售额={sales_value if sales_value is not None else ''} "
|
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:
|
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
|
updated_count += 1
|
||||||
log_debug(f"飞书记录店铺预览: 表={table.name}, stores={preview_list(preview_stores)}")
|
log_debug(f"飞书记录店铺预览: 表={table.name}, stores={preview_list(preview_stores)}")
|
||||||
log_step(f"GMV 计算完成: 表={table.name}, 更新记录数={updated_count}, 双空跳过={skipped_empty_count}")
|
log_step(f"GMV 计算完成: 表={table.name}, 更新记录数={updated_count}, 双空跳过={skipped_empty_count}")
|
||||||
return created_field
|
return created_fields
|
||||||
|
|
||||||
|
|
||||||
def run_gmv_sync_with_context(
|
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,
|
preferred_store_field_name=context.config.store_field_name,
|
||||||
dry_run=context.config.dry_run,
|
dry_run=context.config.dry_run,
|
||||||
)
|
)
|
||||||
if created:
|
created_fields.update(created)
|
||||||
created_fields.add(get_gmv_field_name(stat_date))
|
|
||||||
return MetricSyncArtifacts(created_fields=created_fields)
|
return MetricSyncArtifacts(created_fields=created_fields)
|
||||||
|
|
||||||
|
|
||||||
@@ -1460,6 +1472,17 @@ def run_gmv_sync(
|
|||||||
run_gmv_sync_with_context(context, start_date, end_date)
|
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:
|
def run_daily_sync_for_date(context: DailySyncContext, stat_date: date) -> None:
|
||||||
log_step(f"开始处理日期: {stat_date.isoformat()}")
|
log_step(f"开始处理日期: {stat_date.isoformat()}")
|
||||||
created_fields: set[str] = set()
|
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)
|
should_sync_field_groups = stat_date.day == 1 or bool(created_fields)
|
||||||
if should_sync_field_groups:
|
if should_sync_field_groups:
|
||||||
|
if stat_date.day == 1:
|
||||||
|
sync_reason = "每月1号完整编组"
|
||||||
|
else:
|
||||||
|
sync_reason = f"新增字段={sorted(created_fields)}"
|
||||||
log_step(
|
log_step(
|
||||||
f"触发字段编组同步: 日期={stat_date.isoformat()}, "
|
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(
|
sync_field_groups_for_day_with_context(
|
||||||
context=context,
|
context=context,
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user