from __future__ import annotations import logging from collections import OrderedDict from collections.abc import Sequence from typing import Any from inventory_analytics.logging_utils import log_event as emit_log_event from .feishu import FeishuApiError, FeishuClient from .postgres import query_postgres from .settings import AppConfig from .specs import BRAND_SYNC_TARGETS, FIELD_SPECS, PRIMARY_FIELD_NAME, PRIMARY_FIELD_SOURCE, BrandSyncTarget, FieldSpec logger = logging.getLogger("inventory_sync.service") LOG_FIELD_LABELS = { "advanced_permission_enabled": "高级权限已开启", "brand_name": "品牌", "created_records": "新增记录数", "deleted_records": "删除记录数", "existing_records": "已有记录数", "rows": "行数", "source_rows": "源数据行数", "stage": "阶段", "shop_count": "店铺数量", "table_count": "表数量", "table_name": "表名", "target_count": "目标数量", } def log_event(level: int, event: str, **fields: Any) -> None: emit_log_event(logger, LOG_FIELD_LABELS, level, event, **fields) def build_feishu_record(row: dict[str, Any], field_specs: Sequence[FieldSpec] = FIELD_SPECS) -> dict[str, Any]: if PRIMARY_FIELD_SOURCE not in row: raise KeyError(f"映射缺少主字段来源列: {PRIMARY_FIELD_SOURCE}") fields: dict[str, Any] = { PRIMARY_FIELD_NAME: "" if row.get(PRIMARY_FIELD_SOURCE) is None else str(row.get(PRIMARY_FIELD_SOURCE)), } for spec in field_specs: if spec.source_name not in row: if spec.source_name in {base_spec.source_name for base_spec in FIELD_SPECS}: raise KeyError(f"映射缺少 SQL 列: {spec.source_name} -> {spec.feishu_name}") value = 0 else: value = row.get(spec.source_name) if value is None or value == "": fields[spec.feishu_name] = None elif spec.field_type == 2: number = float(value) fields[spec.feishu_name] = int(round(number)) if spec.scale == 0 else round(number, spec.scale or 0) else: fields[spec.feishu_name] = str(value) return {"fields": fields} def _to_number(value: Any) -> float: if value is None or value == "": return 0.0 return float(value) def build_shop_stock_field_specs(shop_names: Sequence[str]) -> list[FieldSpec]: return [FieldSpec(shop_name, shop_name, 2, "0", 0) for shop_name in shop_names] def build_channel_inventory_field_specs(shop_names: Sequence[str]) -> list[FieldSpec]: shop_stock_specs = build_shop_stock_field_specs(shop_names) insert_after_field = "零售价" field_specs: list[FieldSpec] = [] for spec in FIELD_SPECS: field_specs.append(spec) if spec.feishu_name == insert_after_field: field_specs.extend(shop_stock_specs) return field_specs def pivot_shop_stock_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]: shop_names = list(OrderedDict.fromkeys(str(row.get("店铺名称") or "").strip() for row in rows if row.get("店铺名称"))) rows_by_code: OrderedDict[str, dict[str, Any]] = OrderedDict() for row in rows: code = "" if row.get(PRIMARY_FIELD_SOURCE) is None else str(row.get(PRIMARY_FIELD_SOURCE)) if code not in rows_by_code: rows_by_code[code] = { PRIMARY_FIELD_SOURCE: row.get(PRIMARY_FIELD_SOURCE), "品牌名称": row.get("品牌名称"), "货品简称": row.get("货品简称"), "一级分类": row.get("一级分类"), "商品名称": row.get("商品名称"), "条形码": row.get("条形码"), "零售价": row.get("零售价"), "总库存": row.get("总库存"), "可用总库存": 0, } shop_name = str(row.get("店铺名称") or "").strip() if not shop_name: continue stock = _to_number(row.get("店铺挂单量")) current_stock = _to_number(rows_by_code[code].get(shop_name)) rows_by_code[code][shop_name] = current_stock + stock rows_by_code[code]["可用总库存"] = _to_number(rows_by_code[code].get("可用总库存")) + stock return list(rows_by_code.values()), shop_names def run_feishu_summary(client: FeishuClient) -> dict[str, Any]: log_event(logging.INFO, "开始获取飞书访问令牌", stage="feishu_auth") access_token = client.get_tenant_access_token() log_event(logging.INFO, "飞书访问令牌获取完成", stage="feishu_auth") base_info = client.parse_base_info(client.build_base_url(), access_token) log_event(logging.INFO, "飞书Base信息解析完成", stage="feishu_base") advanced_permission_enabled = client.enable_advanced_permission(access_token, base_info["app_token"] or "") tables = client.list_tables(access_token, base_info["app_token"] or "") log_event( logging.INFO, "飞书Base表列表读取完成", stage="feishu_tables", table_count=len(tables), advanced_permission_enabled=advanced_permission_enabled, ) return { "auth_mode": "tenant_access_token", "advanced_permission_enabled": advanced_permission_enabled, "tables": [{"name": table.get("name"), "revision": table.get("revision")} for table in tables], "table_count": len(tables), } def sync_brand_target( client: FeishuClient, access_token: str, app_token: str, tables: list[dict[str, Any]], target: BrandSyncTarget, postgres_rows: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: log_event( logging.INFO, "开始同步目标飞书表", stage="sync_target", brand_name=target.brand_name, table_name=target.table_name, source_rows=len(postgres_rows or []), ) target_table = next( (table for table in tables if table.get("name") == target.table_name), None, ) if not target_table: raise FeishuApiError(f"未找到目标飞书表: {target.table_name}") table_id = target_table.get("table_id") if not table_id: raise FeishuApiError(f"目标飞书表缺少 table_id: {target.table_name}") source_rows = postgres_rows or [] target_rows, shop_names = pivot_shop_stock_rows(source_rows) field_specs = build_channel_inventory_field_specs(shop_names) available_fields = client.ensure_target_fields(access_token, app_token, table_id, field_specs) existing_records = client.list_records(access_token, app_token, table_id) existing_record_ids = [record.get("record_id") for record in existing_records if record.get("record_id")] log_event( logging.INFO, "目标飞书表已有记录读取完成", stage="sync_target", brand_name=target.brand_name, table_name=target.table_name, existing_records=len(existing_record_ids), ) deleted_count = client.batch_delete_records(access_token, app_token, table_id, existing_record_ids) if existing_record_ids else 0 created_count = client.batch_create_records( access_token, app_token, table_id, [build_feishu_record(row, field_specs) for row in target_rows], ) if target_rows else 0 log_event( logging.INFO, "目标飞书表同步完成", stage="sync_target", brand_name=target.brand_name, table_name=target.table_name, deleted_records=deleted_count, created_records=created_count, shop_count=len(shop_names), ) return { "source_row_count": len(source_rows), "filtered_row_count": len(target_rows), "deleted_record_count": deleted_count, "created_record_count": created_count, "available_field_count": len(available_fields), "shop_count": len(shop_names), } def run_sync_brand(config: AppConfig, client: FeishuClient, postgres_result: dict[str, Any] | None = None) -> dict[str, Any]: log_event(logging.INFO, "开始准备品牌同步", stage="sync_prepare", target_count=len(BRAND_SYNC_TARGETS)) access_token = client.get_tenant_access_token() base_info = client.parse_base_info(client.build_base_url(), access_token) app_token = base_info["app_token"] or "" client.enable_advanced_permission(access_token, app_token) tables = client.list_tables(access_token, app_token) log_event(logging.INFO, "品牌同步准备完成", stage="sync_prepare", table_count=len(tables)) results = [] source_row_count = 0 for index, target in enumerate(BRAND_SYNC_TARGETS): if postgres_result is not None and index == 0: postgres_payload = postgres_result else: postgres_payload = query_postgres(config, target=target) postgres_rows = postgres_payload["rows"] log_event( logging.INFO, "目标品牌PostgreSQL数据已准备", stage="sync_prepare", brand_name=target.brand_name, table_name=target.table_name, rows=len(postgres_rows or []), ) source_row_count += len(postgres_rows or []) results.append(sync_brand_target(client, access_token, app_token, tables, target, postgres_rows)) return { "target_count": len(BRAND_SYNC_TARGETS), "source_row_count": source_row_count, "filtered_row_count": sum(int(result.get("filtered_row_count") or 0) for result in results), "deleted_record_count": sum(int(result.get("deleted_record_count") or 0) for result in results), "created_record_count": sum(int(result.get("created_record_count") or 0) for result in results), "shop_count": sum(int(result.get("shop_count") or 0) for result in results), }