添加字段编组
This commit is contained in:
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/*
|
||||||
*.pyc
|
*.pyc
|
||||||
.vscode/
|
.vscode/
|
||||||
.env
|
.env
|
||||||
|
created/
|
||||||
|
创建编组.zip
|
||||||
|
.DS_Store
|
||||||
|
|||||||
Binary file not shown.
@@ -614,6 +614,39 @@ class FeishuBitableClient:
|
|||||||
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}")
|
||||||
|
|
||||||
|
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_step(f"尝试读取字段编组: table_id={table_id}")
|
||||||
|
resp = self.session.request("GET", url, timeout=60)
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
log_step(f"字段编组接口返回非 JSON,跳过读取: table_id={table_id}, status={resp.status_code}")
|
||||||
|
return None
|
||||||
|
if resp.status_code == 404:
|
||||||
|
log_step(f"字段编组读取接口不可用: table_id={table_id}, payload={payload}")
|
||||||
|
return None
|
||||||
|
if resp.status_code >= 400 or payload.get("code") != 0:
|
||||||
|
log_step(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_step(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(
|
||||||
|
f"开始创建字段编组: table_id={table_id}, "
|
||||||
|
f"groups={[(group.get('name') or '', len(group.get('children') or [])) for group in field_groups]}"
|
||||||
|
)
|
||||||
|
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}")
|
||||||
|
|
||||||
def list_records(self, table_id: str) -> list[dict[str, Any]]:
|
def list_records(self, table_id: str) -> list[dict[str, Any]]:
|
||||||
log_step(f"开始获取记录列表: table_id={table_id}")
|
log_step(f"开始获取记录列表: table_id={table_id}")
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
@@ -881,6 +914,196 @@ def iter_dates(start_date: date, end_date: date) -> list[date]:
|
|||||||
return dates
|
return dates
|
||||||
|
|
||||||
|
|
||||||
|
def build_group_dates_by_month(start_date: date, end_date: date) -> dict[tuple[int, int], list[date]]:
|
||||||
|
grouped: dict[tuple[int, int], list[date]] = defaultdict(list)
|
||||||
|
for stat_date in iter_dates(start_date, end_date):
|
||||||
|
grouped[(stat_date.year, stat_date.month)].append(stat_date)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def build_field_group_payload(
|
||||||
|
fields: list[dict[str, Any]],
|
||||||
|
store_field_name: str,
|
||||||
|
allowed_field_names: set[str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
gmv_fields: list[dict[str, str]] = []
|
||||||
|
other_fields: list[dict[str, str]] = []
|
||||||
|
for field in fields:
|
||||||
|
field_id = str(field.get("field_id") or "").strip()
|
||||||
|
field_name = str(field.get("field_name") or "").strip()
|
||||||
|
if not field_id:
|
||||||
|
continue
|
||||||
|
if field_name == store_field_name:
|
||||||
|
continue
|
||||||
|
if allowed_field_names is not None and field_name not in allowed_field_names:
|
||||||
|
continue
|
||||||
|
child = {"type": "field", "id": field_id}
|
||||||
|
if "gmv" in field_name.lower():
|
||||||
|
gmv_fields.append(child)
|
||||||
|
else:
|
||||||
|
other_fields.append(child)
|
||||||
|
groups: list[dict[str, Any]] = []
|
||||||
|
if gmv_fields:
|
||||||
|
groups.append({"name": "GMV", "children": gmv_fields})
|
||||||
|
if other_fields:
|
||||||
|
groups.append({"name": "销售额/退款额", "children": other_fields})
|
||||||
|
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 ensure_field_groups_for_table(
|
||||||
|
client: FeishuBitableClient,
|
||||||
|
table: TableInfo,
|
||||||
|
store_field_name: str,
|
||||||
|
allowed_field_names: set[str] | None,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> None:
|
||||||
|
log_step(f"开始检查字段编组: 表={table.name}")
|
||||||
|
fields = client.list_fields(table.table_id)
|
||||||
|
desired_groups = build_field_group_payload(fields, store_field_name, allowed_field_names)
|
||||||
|
if not desired_groups:
|
||||||
|
log_step(f"跳过字段编组: 表={table.name}, 原因=没有可分组字段")
|
||||||
|
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_step(
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
client.create_field_groups(table.table_id, desired_groups)
|
||||||
|
log_step(f"字段编组处理完成: 表={table.name}")
|
||||||
|
except Exception as exc:
|
||||||
|
error_text = str(exc)
|
||||||
|
log_step(f"字段编组创建失败: 表={table.name}, error={error_text}")
|
||||||
|
if "99991672" in error_text or "base:field_group:create" in error_text:
|
||||||
|
log_step(
|
||||||
|
f"跳过字段编组创建: 表={table.name}, "
|
||||||
|
"原因=应用缺少 base:field_group:create 权限,请在飞书开放平台开通后重试"
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
client.create_field_groups(table.table_id, [group])
|
||||||
|
log_step(
|
||||||
|
f"单个字段编组创建成功: 表={table.name}, group={group.get('name')}, "
|
||||||
|
f"字段数={len(group.get('children') or [])}"
|
||||||
|
)
|
||||||
|
except Exception as single_exc:
|
||||||
|
log_step(
|
||||||
|
f"单个字段编组创建失败: 表={table.name}, group={group.get('name')}, error={single_exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def sync_field_groups_for_range(
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
base_url: str,
|
||||||
|
store_field_name: str,
|
||||||
|
table_map_file: str,
|
||||||
|
dry_run: bool,
|
||||||
|
skip_missing_tables: bool,
|
||||||
|
) -> None:
|
||||||
|
log_step(f"开始同步字段编组: {start_date.isoformat()} -> {end_date.isoformat()}")
|
||||||
|
table_overrides = load_table_name_overrides(table_map_file)
|
||||||
|
if table_overrides:
|
||||||
|
log_step(f"加载表名映射完成: {len(table_overrides)} 条")
|
||||||
|
client = FeishuBitableClient(base_url)
|
||||||
|
tables = client.list_tables()
|
||||||
|
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(tables, project, year, month, table_overrides)
|
||||||
|
except ValueError as exc:
|
||||||
|
if skip_missing_tables:
|
||||||
|
log_step(f"跳过字段编组同步: {exc}")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
ensure_field_groups_for_table(
|
||||||
|
client=client,
|
||||||
|
table=table,
|
||||||
|
store_field_name=store_field_name,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
log_step("字段编组同步完成")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_field_groups_for_day(
|
||||||
|
stat_date: date,
|
||||||
|
base_url: str,
|
||||||
|
store_field_name: str,
|
||||||
|
table_map_file: str,
|
||||||
|
dry_run: bool,
|
||||||
|
skip_missing_tables: bool,
|
||||||
|
) -> None:
|
||||||
|
day_mode = "完整编组" if stat_date.day == 1 else "增量编组"
|
||||||
|
log_step(f"开始同步字段编组: 日期={stat_date.isoformat()}, 模式={day_mode}")
|
||||||
|
table_overrides = load_table_name_overrides(table_map_file)
|
||||||
|
if table_overrides:
|
||||||
|
log_step(f"加载表名映射完成: {len(table_overrides)} 条")
|
||||||
|
client = FeishuBitableClient(base_url)
|
||||||
|
tables = client.list_tables()
|
||||||
|
allowed_field_names: set[str] | None = None
|
||||||
|
if 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),
|
||||||
|
}
|
||||||
|
log_step(f"增量编组目标字段: 日期={stat_date.isoformat()}, fields={sorted(allowed_field_names)}")
|
||||||
|
for project in SUPPORTED_PROJECTS:
|
||||||
|
try:
|
||||||
|
table = find_target_table(tables, project, stat_date.year, stat_date.month, table_overrides)
|
||||||
|
except ValueError as exc:
|
||||||
|
if skip_missing_tables:
|
||||||
|
log_step(f"跳过字段编组同步: {exc}")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
ensure_field_groups_for_table(
|
||||||
|
client=client,
|
||||||
|
table=table,
|
||||||
|
store_field_name=store_field_name,
|
||||||
|
allowed_field_names=allowed_field_names,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
log_step(f"字段编组同步完成: 日期={stat_date.isoformat()}, 模式={day_mode}")
|
||||||
|
|
||||||
|
|
||||||
def sync_metric_table_for_month(
|
def sync_metric_table_for_month(
|
||||||
client: FeishuBitableClient,
|
client: FeishuBitableClient,
|
||||||
table: TableInfo,
|
table: TableInfo,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from project_bitable_common import (
|
|||||||
run_metric_sync,
|
run_metric_sync,
|
||||||
send_feishu_app_card,
|
send_feishu_app_card,
|
||||||
send_feishu_sync_card,
|
send_feishu_sync_card,
|
||||||
|
sync_field_groups_for_day,
|
||||||
validate_date,
|
validate_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,6 +79,14 @@ def main() -> None:
|
|||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
skip_missing_tables=args.skip_missing_tables,
|
skip_missing_tables=args.skip_missing_tables,
|
||||||
)
|
)
|
||||||
|
sync_field_groups_for_day(
|
||||||
|
stat_date=stat_date,
|
||||||
|
base_url=args.base_url,
|
||||||
|
store_field_name=args.store_field_name,
|
||||||
|
table_map_file=args.table_map_file,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
skip_missing_tables=args.skip_missing_tables,
|
||||||
|
)
|
||||||
log_step(f"日期处理完成: {stat_date.isoformat()}")
|
log_step(f"日期处理完成: {stat_date.isoformat()}")
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
if args.chat_id:
|
if args.chat_id:
|
||||||
|
|||||||
Reference in New Issue
Block a user