"""GET /api/departments, /api/staff, /api/accounts — organization browse endpoints. Read-only queries over the mirrored ``department``, ``staff``, ``wechat_account`` and ``whatsapp_account`` tables. * ``GET /api/departments`` -> ``list[DepartmentOut]`` ordered by ``path`` so the frontend can assemble the tree with a stable traversal order. * ``GET /api/staff`` -> ``Page[StaffOut]`` with optional ``department_id`` and ``is_on_job`` filters. * ``GET /api/accounts`` -> ``Page[AccountOut]`` projecting both staff-messaging account tables onto a unified shape (see :class:`AccountOut`). The optional ``channel`` filter restricts to one of ``wechat`` / ``whatsapp``; when omitted, both tables are UNIONed at the SQL level and paginated in-database (no full-table Python materialization). Staff-account volumes are modest in practice today, but the SQL-level pagination keeps the endpoint safe if a tenant ever scales past thousands of rows. """ from __future__ import annotations from typing import Annotated import sqlalchemy as sa from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session from app.api.schemas import AccountOut, DepartmentOut, Page, StaffOut from app.db.models import Department, Staff, WechatAccount, WhatsappAccount router = APIRouter(prefix="/api", tags=["organization"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] PageParam = Annotated[int, Query(ge=1)] PageSizeParam = Annotated[int, Query(ge=1, le=100)] def account_avatar(raw_json: dict | None) -> str | None: """从账号原始记录提取头像 URL(共享 helper,2026-08-25)。 WeChat ``wxUser/list`` 记录携带 ``headImageUrl``;WA ``businessSalesList`` 记录无该键 → ``None``。accounts 列表与 moments 头部等多处复用, 避免同键提取散落各处。 """ return (raw_json or {}).get("headImageUrl") @router.get("/departments") async def list_departments(session: SessionDep) -> list[DepartmentOut]: """Return every department ordered by ``path`` (nulls last). A plain list — not paginated — because the frontend rebuilds the tree from ``parent_id``/``path`` and pagination would split the topology awkwardly. """ stmt = sa.select(Department).order_by( sa.nullslast(Department.path.asc()), Department.dept_id.asc() ) rows = (await session.execute(stmt)).scalars().all() return [DepartmentOut.model_validate(row) for row in rows] @router.get("/staff") async def list_staff( session: SessionDep, page: PageParam = 1, page_size: PageSizeParam = 20, department_id: Annotated[str | None, Query()] = None, is_on_job: Annotated[bool | None, Query()] = None, ) -> Page[StaffOut]: """List staff with optional ``department_id`` / ``is_on_job`` filters.""" conditions = [] if department_id is not None: conditions.append(Staff.dept_id == department_id) if is_on_job is not None: conditions.append(Staff.is_on_job.is_(is_on_job)) total_stmt = sa.select(sa.func.count()).select_from(Staff) if conditions: total_stmt = total_stmt.where(*conditions) total = (await session.execute(total_stmt)).scalar_one() list_stmt = sa.select(Staff) if conditions: list_stmt = list_stmt.where(*conditions) list_stmt = ( list_stmt.order_by(Staff.user_id.asc()) .limit(page_size) .offset((page - 1) * page_size) ) rows = (await session.execute(list_stmt)).scalars().all() items = [StaffOut.model_validate(row) for row in rows] return Page(items=items, total=total, page=page, page_size=page_size) @router.get("/accounts") async def list_accounts( session: SessionDep, page: PageParam = 1, page_size: PageSizeParam = 20, channel: Annotated[str | None, Query()] = None, ) -> Page[AccountOut]: """List staff messaging accounts (WeChat / WhatsApp) in a unified shape. ``channel`` optional; one of ``wechat`` / ``whatsapp``. When omitted, both tables are UNIONed at the SQL level. Pagination and total count are pushed down to the database (single ``COUNT(*)`` over the UNION subquery + a paginated ``SELECT`` with ``OFFSET``/``LIMIT``) so we never materialize the full cross-table row set in Python. Stable ordering is ``(channel, account_id)``. ``AccountOut`` field mapping (identical shape from both source tables): * WeChat: account_id=wx_id, nickname=nick_name, user_id, user_name, phone, contacts_num, group_num, is_on_job, raw_json (verbatim upstream record). * WhatsApp: account_id=uid (+ same projection). ``avatar`` is extracted from ``raw_json.headImageUrl`` (WeChat wxUser/list records carry it; WhatsApp businessSalesList records have no avatar key, so they naturally resolve to ``None`` — same key logic for both channels). """ wechat_q = sa.select( WechatAccount.wx_id.label("account_id"), sa.literal("wechat").label("channel"), WechatAccount.nick_name.label("nickname"), WechatAccount.user_id.label("user_id"), WechatAccount.user_name.label("user_name"), WechatAccount.phone.label("phone"), WechatAccount.contacts_num.label("contacts_num"), WechatAccount.group_num.label("group_num"), WechatAccount.is_on_job.label("is_on_job"), WechatAccount.raw_json.label("raw_json"), WechatAccount.list_order.label("list_order"), ) whatsapp_q = sa.select( WhatsappAccount.uid.label("account_id"), sa.literal("whatsapp").label("channel"), WhatsappAccount.nick_name.label("nickname"), WhatsappAccount.user_id.label("user_id"), WhatsappAccount.user_name.label("user_name"), WhatsappAccount.phone.label("phone"), WhatsappAccount.contacts_num.label("contacts_num"), WhatsappAccount.group_num.label("group_num"), WhatsappAccount.is_on_job.label("is_on_job"), WhatsappAccount.raw_json.label("raw_json"), sa.literal(None).label("list_order"), ) if channel == "wechat": parts: list[sa.Select] = [wechat_q] elif channel == "whatsapp": parts = [whatsapp_q] else: parts = [wechat_q, whatsapp_q] base_q: sa.Select = parts[0] if len(parts) == 1 else sa.union_all(*parts) # Wrap in a subquery so COUNT and paginated SELECT both have a stable # column set (the labelled columns become ``sub.c.