添加字段编组

This commit is contained in:
2026-03-16 14:21:12 +08:00
parent 5bf8a012ce
commit 682985ffc0
5 changed files with 236 additions and 1 deletions
Vendored
BIN
View File
Binary file not shown.
+4 -1
View File
@@ -1,4 +1,7 @@
__pycache__/
__pycache__/*
*.pyc
.vscode/
.env
created/
创建编组.zip
.DS_Store
Binary file not shown.
+223
View File
@@ -614,6 +614,39 @@ class FeishuBitableClient:
self._fields_cache.pop(table_id, None)
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]]:
log_step(f"开始获取记录列表: table_id={table_id}")
items: list[dict[str, Any]] = []
@@ -881,6 +914,196 @@ def iter_dates(start_date: date, end_date: date) -> list[date]:
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(
client: FeishuBitableClient,
table: TableInfo,
+9
View File
@@ -17,6 +17,7 @@ from project_bitable_common import (
run_metric_sync,
send_feishu_app_card,
send_feishu_sync_card,
sync_field_groups_for_day,
validate_date,
)
@@ -78,6 +79,14 @@ def main() -> None:
dry_run=args.dry_run,
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()}")
if not args.dry_run:
if args.chat_id: