修feat: 渠道库存同步支持字段任意排序并增加字段异常告警
- 按字段名校验并写入渠道库存,取消字段顺序校验及表头重建 - 严格校验必填字段名称、类型和数字格式,失败时不修改表头或记录 - 字段校验失败时发送飞书群通知 - 添加字段校验与通知流程测试 - 忽略 Python 字节码、__pycache__ 和 .DS_Store
This commit is contained in:
+51
-72
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
@@ -9,7 +8,7 @@ from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from .settings import AppConfig
|
||||
from .specs import FIELD_SPECS, LEGACY_NAME_MAP, PRIMARY_FIELD_NAME
|
||||
from .specs import FIELD_SPECS, PRIMARY_FIELD_NAME
|
||||
|
||||
|
||||
FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
|
||||
@@ -19,6 +18,10 @@ class FeishuApiError(RuntimeError):
|
||||
"""Raised when the Feishu API returns a non-zero code."""
|
||||
|
||||
|
||||
class FeishuFieldValidationError(FeishuApiError):
|
||||
"""Raised when a channel-inventory table does not match its required schema."""
|
||||
|
||||
|
||||
class FeishuClient:
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
self.config = config
|
||||
@@ -133,6 +136,21 @@ class FeishuClient:
|
||||
def list_records(self, access_token: str, app_token: str, table_id: str) -> list[dict[str, Any]]:
|
||||
return self._paginate(access_token, f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records", 500)
|
||||
|
||||
def send_text_message(self, access_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a plain-text notification to a Feishu group chat."""
|
||||
data = self.request_json(
|
||||
f"{FEISHU_API_BASE}/im/v1/messages?receive_id_type=chat_id",
|
||||
method="POST",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
payload={
|
||||
"receive_id": chat_id,
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": text}, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
if data.get("code") != 0:
|
||||
raise FeishuApiError(f"发送飞书群消息失败: {data}")
|
||||
|
||||
def delete_field(self, access_token: str, app_token: str, table_id: str, field_id: str) -> None:
|
||||
data = self.request_json(
|
||||
f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}",
|
||||
@@ -162,14 +180,6 @@ class FeishuClient:
|
||||
if data.get("code") != 0:
|
||||
raise FeishuApiError(f"新增字段失败({payload.get('field_name')}): {data}")
|
||||
|
||||
def _is_managed_field(self, field_name: str) -> bool:
|
||||
managed_names = {spec.feishu_name for spec in FIELD_SPECS}
|
||||
managed_names.update(LEGACY_NAME_MAP.keys())
|
||||
managed_names.update(LEGACY_NAME_MAP.values())
|
||||
if field_name in managed_names:
|
||||
return True
|
||||
return bool(re.fullmatch(r"\d{1,2}月销售数量", field_name) or re.fullmatch(r"\d{6}销售数量", field_name))
|
||||
|
||||
def batch_delete_records(self, access_token: str, app_token: str, table_id: str, record_ids: list[str]) -> int:
|
||||
deleted = 0
|
||||
for start in range(0, len(record_ids), 500):
|
||||
@@ -212,71 +222,40 @@ class FeishuClient:
|
||||
fields = self.list_fields(access_token, app_token, table_id)
|
||||
primary_field = next((field for field in fields if field.get("is_primary")), None)
|
||||
if not primary_field:
|
||||
raise FeishuApiError("未找到主字段")
|
||||
expected_field_names = {spec.feishu_name for spec in field_specs}
|
||||
expected_field_order = [spec.feishu_name for spec in field_specs]
|
||||
actual_managed_field_names = {
|
||||
field.get("field_name")
|
||||
for field in fields
|
||||
if not field.get("is_primary") and field.get("field_name") in expected_field_names
|
||||
}
|
||||
actual_expected_field_order = [
|
||||
field.get("field_name")
|
||||
for field in fields
|
||||
if not field.get("is_primary") and field.get("field_name") in expected_field_names
|
||||
]
|
||||
obsolete_managed_field_names = {
|
||||
field.get("field_name")
|
||||
for field in fields
|
||||
if (
|
||||
not field.get("is_primary")
|
||||
and field.get("field_name") not in expected_field_names
|
||||
and self._is_managed_field(field.get("field_name") or "")
|
||||
)
|
||||
}
|
||||
primary_matches = primary_field.get("field_name") == PRIMARY_FIELD_NAME
|
||||
fields_match = (
|
||||
actual_managed_field_names == expected_field_names
|
||||
and actual_expected_field_order == expected_field_order
|
||||
and not obsolete_managed_field_names
|
||||
)
|
||||
if primary_matches and fields_match:
|
||||
return [field.get("field_name") for field in fields if field.get("field_name")]
|
||||
|
||||
duplicate_primary = next(
|
||||
(field for field in fields if not field.get("is_primary") and field.get("field_name") == PRIMARY_FIELD_NAME),
|
||||
None,
|
||||
)
|
||||
if duplicate_primary:
|
||||
self.delete_field(access_token, app_token, table_id, duplicate_primary["field_id"])
|
||||
time.sleep(0.2)
|
||||
fields = self.list_fields(access_token, app_token, table_id)
|
||||
primary_field = next((field for field in fields if field.get("is_primary")), None)
|
||||
if not primary_field:
|
||||
raise FeishuApiError("删除重复字段后未找到主字段")
|
||||
raise FeishuFieldValidationError("未找到渠道库存表主字段")
|
||||
|
||||
validation_errors: list[str] = []
|
||||
if primary_field.get("field_name") != PRIMARY_FIELD_NAME:
|
||||
payload = {"field_name": PRIMARY_FIELD_NAME, "type": primary_field.get("type")}
|
||||
if primary_field.get("property") is not None:
|
||||
payload["property"] = primary_field.get("property")
|
||||
self.update_field(access_token, app_token, table_id, primary_field["field_id"], payload)
|
||||
time.sleep(0.2)
|
||||
|
||||
fields = self.list_fields(access_token, app_token, table_id)
|
||||
for field in fields:
|
||||
field_name = field.get("field_name")
|
||||
field_id = field.get("field_id")
|
||||
if field.get("is_primary") or not field_name or not field_id:
|
||||
continue
|
||||
if field_name in expected_field_names or self._is_managed_field(field_name):
|
||||
self.delete_field(access_token, app_token, table_id, field_id)
|
||||
time.sleep(0.2)
|
||||
validation_errors.append(
|
||||
f"主字段名称错误:期望“{PRIMARY_FIELD_NAME}”,实际“{primary_field.get('field_name') or '未命名'}”"
|
||||
)
|
||||
if primary_field.get("type") != 1:
|
||||
validation_errors.append(
|
||||
f"主字段类型错误:字段“{PRIMARY_FIELD_NAME}”应为文本,实际类型 {primary_field.get('type')}"
|
||||
)
|
||||
|
||||
fields_by_name = {
|
||||
field.get("field_name"): field
|
||||
for field in fields
|
||||
if not field.get("is_primary") and field.get("field_name")
|
||||
}
|
||||
for spec in field_specs:
|
||||
payload = {"field_name": spec.feishu_name, "type": spec.field_type}
|
||||
field = fields_by_name.get(spec.feishu_name)
|
||||
if not field:
|
||||
validation_errors.append(f"缺少必需字段或字段已改名:“{spec.feishu_name}”")
|
||||
continue
|
||||
if field.get("type") != spec.field_type:
|
||||
validation_errors.append(
|
||||
f"字段类型错误:“{spec.feishu_name}”应为 {spec.field_type},实际为 {field.get('type')}"
|
||||
)
|
||||
if spec.formatter is not None:
|
||||
payload["property"] = {"formatter": spec.formatter}
|
||||
self.create_field(access_token, app_token, table_id, payload)
|
||||
time.sleep(0.2)
|
||||
actual_formatter = (field.get("property") or {}).get("formatter")
|
||||
if actual_formatter != spec.formatter:
|
||||
validation_errors.append(
|
||||
f"字段格式错误:“{spec.feishu_name}”应为 {spec.formatter},实际为 {actual_formatter or '未设置'}"
|
||||
)
|
||||
|
||||
return [field.get("field_name") for field in self.list_fields(access_token, app_token, table_id) if field.get("field_name")]
|
||||
if validation_errors:
|
||||
raise FeishuFieldValidationError(";".join(validation_errors))
|
||||
|
||||
return [field.get("field_name") for field in fields if field.get("field_name")]
|
||||
|
||||
Reference in New Issue
Block a user