"""SQLAlchemy-backed global business → agent / chain mapping store. Global / shared (no ``user_id`` scoping): every account reads and writes the same rows. Fixed business rows are seeded once on startup (``ensure_seed_defaults``) with empty agent/chain; users fill those in from the management page via ``update_mapping``. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.business_mapping.model import BusinessMappingRow _UNSET = object() # Fixed business rows (业务码 → 展示名). Seeded on first boot; agent/chain left empty. # 8BF 页面尚未建好,先占位保留行。顺序即管理表的展示顺序。 # AFX = goPath=agentfx 深链(新系统 iframe 嵌入的智能体问答,任务详情取本系统 cop-task-detail)。 # b5-1 = 外部写入本系统详情后,由本系统读取的任务分析单智能体深链。 # 3q-0…3q-6 / 6-1…6-4 / 7-1…7-4 = 独立配置位(与 3Q/6BF/7BF 等无关),固定排在表尾。 # 3q-1…3q-6 默认按「自定义类型」落库(goPath 走自定义深链);配置管理页默认不展示这 6 行。 _CUSTOM_SLOT = {"is_custom": True, "mapping_type": "自定义类型"} DEFAULT_BUSINESS_ROWS: list[dict[str, Any]] = [ {"business_code": "3Q", "label": "3QFX"}, {"business_code": "6BF", "label": "任务FX"}, {"business_code": "7BF", "label": "XDFX"}, {"business_code": "8BF", "label": "8BF"}, {"business_code": "AFX", "label": "AGENTFX"}, {"business_code": "b5-1", "label": "任务分析"}, # 独立配置位,与上方业务码无关;固定排在表尾。 {"business_code": "3q-0", "label": "3q-0"}, {"business_code": "3q-1", "label": "3q-1", **_CUSTOM_SLOT}, {"business_code": "3q-2", "label": "3q-2", **_CUSTOM_SLOT}, {"business_code": "3q-3", "label": "3q-3", **_CUSTOM_SLOT}, {"business_code": "3q-4", "label": "3q-4", **_CUSTOM_SLOT}, {"business_code": "3q-5", "label": "3q-5", **_CUSTOM_SLOT}, {"business_code": "3q-6", "label": "3q-6", **_CUSTOM_SLOT}, {"business_code": "6-1", "label": "6-1"}, {"business_code": "6-2", "label": "6-2"}, {"business_code": "6-3", "label": "6-3"}, {"business_code": "6-4", "label": "6-4"}, {"business_code": "7-1", "label": "7-1"}, {"business_code": "7-2", "label": "7-2"}, {"business_code": "7-3", "label": "7-3"}, {"business_code": "7-4", "label": "7-4"}, ] def _seed_type(seed: dict[str, Any]) -> tuple[bool, str]: is_custom = bool(seed.get("is_custom", False)) mapping_type = str(seed.get("mapping_type") or "").strip() if not mapping_type: mapping_type = "自定义类型" if is_custom else "内置业务" return is_custom, mapping_type class BusinessMappingRepository: def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._sf = session_factory @staticmethod def _to_dict(row: BusinessMappingRow) -> dict[str, Any]: return { "business_code": row.business_code, "label": row.label, "mapping_type": row.mapping_type or "内置业务", "is_custom": bool(row.is_custom), "agent_id": row.agent_id, "agent_name": row.agent_name, "chain_id": row.chain_id, "chain_title": row.chain_title, "sort_order": row.sort_order, "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at, } async def list_mappings(self) -> list[dict[str, Any]]: stmt = select(BusinessMappingRow).order_by( BusinessMappingRow.sort_order.asc(), BusinessMappingRow.business_code.asc() ) async with self._sf() as session: result = await session.execute(stmt) return [self._to_dict(row) for row in result.scalars()] async def get_mapping(self, business_code: str) -> dict[str, Any] | None: async with self._sf() as session: row = await session.get(BusinessMappingRow, business_code) return self._to_dict(row) if row is not None else None async def update_mapping( self, business_code: str, *, agent_id=_UNSET, agent_name=_UNSET, chain_id=_UNSET, chain_title=_UNSET, ) -> dict[str, Any] | None: """Update an existing (seeded) business row. Returns None if the code is unknown. Only forwarded fields are touched; explicit ``None`` clears that field. """ async with self._sf() as session: row = await session.get(BusinessMappingRow, business_code) if row is None: return None if agent_id is not _UNSET: row.agent_id = agent_id or None if agent_name is not _UNSET: row.agent_name = agent_name or None if chain_id is not _UNSET: row.chain_id = chain_id or None if chain_title is not _UNSET: row.chain_title = chain_title or None row.updated_at = datetime.now(UTC) await session.commit() await session.refresh(row) return self._to_dict(row) async def create_custom_mapping( self, *, business_code: str, mapping_type: str, ) -> dict[str, Any] | None: """Create one administrator-defined mapping. ``business_code`` is deliberately the externally supplied goPath. The case-insensitive uniqueness check mirrors frontend deep-link matching, preventing two rows that could resolve from the same login URL. """ normalized_code = business_code.strip() normalized_type = mapping_type.strip() async with self._sf() as session: existing = await session.scalar( select(BusinessMappingRow).where( func.lower(BusinessMappingRow.business_code) == normalized_code.lower() ) ) if existing is not None: return None max_sort_order = await session.scalar(select(func.max(BusinessMappingRow.sort_order))) now = datetime.now(UTC) row = BusinessMappingRow( business_code=normalized_code, label=normalized_code, mapping_type=normalized_type, is_custom=True, agent_id=None, agent_name=None, chain_id=None, chain_title=None, sort_order=(int(max_sort_order) if max_sort_order is not None else -1) + 1, created_at=now, updated_at=now, ) session.add(row) await session.commit() await session.refresh(row) return self._to_dict(row) async def ensure_seed_defaults(self) -> int: """Idempotently insert the fixed business rows if missing. Returns inserted count. Never overwrites an existing row's agent/chain (respects user config). Re-syncs ``sort_order``, empty ``label``, and seed ``is_custom`` / ``mapping_type`` so newly inserted codes keep the intended table order and 3q-1…3q-6 stay classified as custom type. """ inserted = 0 dirty = False now = datetime.now(UTC) async with self._sf() as session: for idx, seed in enumerate(DEFAULT_BUSINESS_ROWS): is_custom, mapping_type = _seed_type(seed) existing = await session.get(BusinessMappingRow, seed["business_code"]) if existing is not None: if existing.sort_order != idx: existing.sort_order = idx dirty = True seed_label = str(seed.get("label") or "") if seed_label and not (existing.label or "").strip(): existing.label = seed_label dirty = True if bool(existing.is_custom) != is_custom: existing.is_custom = is_custom dirty = True if (existing.mapping_type or "") != mapping_type: existing.mapping_type = mapping_type dirty = True continue session.add( BusinessMappingRow( business_code=seed["business_code"], label=seed.get("label", ""), mapping_type=mapping_type, is_custom=is_custom, agent_id=None, agent_name=None, chain_id=None, chain_title=None, sort_order=idx, created_at=now, updated_at=now, ) ) inserted += 1 dirty = True if dirty: await session.commit() return inserted