"""HTTP surface for the approval-flow registry (审批流程台账). Mounts under ``/api/v1/approval-flows``. Reads are open to the governance lane (:func:`can_view_approval_registry`); every write is ``global_admin`` only, because IT maintains the register. ``GET /published`` is the exception and is deliberately ungated beyond being signed in: it is the company's own list of its own approvals, and the point of publishing it is that anyone can find out which flow to use and who to ask without opening a ticket. """ from __future__ import annotations import html as _html import logging import uuid from typing import Any import jwt from fastapi import APIRouter, Depends, Form, Query, Request from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.exceptions import AppException, ForbiddenError from app.core.exporting import xlsx_export_response from app.core.idempotent_route import idempotent from app.core.rbac import ( can_manage_approval_registry, can_view_approval_registry, ) from app.core.response import paginated_response, success_response from app.core.security import decode_action_token from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import constants as const from app.domains.approval_registry import discovery, notify from app.domains.approval_registry import requests as reqs from app.domains.approval_registry import service as svc from app.domains.approval_registry.schemas import ( FlowCreateRequest, FlowRequestDecision, FlowRequestRegister, FlowUpdateRequest, ImportRequest, OpenRoundRequest, OwnerAssignRequest, RetireRequest, SyncRequest, ) from app.domains.approval_registry.serializers import ( serialize_event, serialize_flow, serialize_published, serialize_request, serialize_round, serialize_round_item, ) from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/approval-flows", tags=["approval-registry"]) _RESOURCE = "registry_approval_flow" def _require_view(user: AuthUser) -> None: if not can_view_approval_registry(user.role_names): raise ForbiddenError( "governance, HR or global_admin role required to read the " "approval-flow registry", ) def _require_manage(user: AuthUser) -> None: if not can_manage_approval_registry(user.role_names): raise ForbiddenError( "global_admin role required to maintain the approval-flow " "registry", ) async def _actor(db: AsyncSession, user: AuthUser) -> IdentityPerson | None: return await svc.load_person_optional(db, user.id) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict[str, Any] | None = None, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type=_RESOURCE, resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) # ════════════════════════════════════════════════════════════════════ # All-staff publication # ════════════════════════════════════════════════════════════════════ @router.get("/published", summary="公示:全公司审批流程与负责人(全员可见)") async def list_published( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """No role gate above ``get_current_user`` on purpose — see module docstring. Grouped server-side so every surface (web, mobile, the handbook table) renders the same taxonomy instead of three front-ends each inventing their own grouping rule.""" rows = await svc.list_published(db) groups: dict[str, list[dict[str, Any]]] = {} for row in rows: payload = serialize_published(row) groups.setdefault(payload["category"] or "未分类", []).append(payload) return success_response( { "total": len(rows), "groups": [ {"category": name, "flows": flows} for name, flows in sorted(groups.items()) ], }, request=request, ) # ════════════════════════════════════════════════════════════════════ # Metadata # ════════════════════════════════════════════════════════════════════ @router.get("/meta") async def get_meta( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response( { "statuses": list(const.STATUSES), "live_statuses": sorted(const.LIVE_STATUSES), "owner_sources": list(const.OWNER_SOURCES), "sources": list(const.SOURCES), "event_types": list(const.EVENT_TYPES), "categories": await svc.list_categories(db), "confirmation_valid_days": const.CONFIRMATION_VALID_DAYS, # Writes are global_admin only while reads are wider, so the # UI needs to know which of the two the caller is — a # read-only viewer should get a page without buttons rather # than buttons that 403. "can_manage": can_manage_approval_registry(user.role_names), "inference": { "min_tasks": const.MIN_INFERENCE_TASKS, "min_share": const.MIN_INFERENCE_SHARE, "window_days": const.INFERENCE_WINDOW_DAYS, }, "summary": await svc.coverage_summary(db), }, request=request, ) # ════════════════════════════════════════════════════════════════════ # Reads # ════════════════════════════════════════════════════════════════════ @router.get("") async def list_flows( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), q: str | None = None, status: str | None = None, category: str | None = None, owner_source: str | None = None, owner_person_id: uuid.UUID | None = None, ownerless: bool | None = None, machine_owner: bool | None = None, unconfirmed: bool | None = None, published: bool | None = None, source: str | None = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, total = await svc.list_flows( db, q=q, status=status, category=category, owner_source=owner_source, owner_person_id=owner_person_id, ownerless=ownerless, machine_owner=machine_owner, unconfirmed=unconfirmed, published=published, source=source, page=page, page_size=page_size, ) return paginated_response( [serialize_flow(r) for r in rows], total=total, page=page, page_size=page_size, request=request, ) @router.get( "/feishu-drift", summary="飞书后台待同步:台账负责人 ≠ 飞书流程管理员", ) async def list_feishu_admin_drift( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The one hand-off step Helix structurally cannot perform. Feishu's approval v4 definition API is create / get / subscribe — no update verb, no write for 流程管理员. So every ownership change made here (an owner pressing 「转交」 in the half-year round, an offboarding hand-over, IT re-pointing a row) leaves the console naming the previous person, and the console is the side that grants the rights: editing the flow, reading its instance data, receiving its admin notices. The successor ends up with the accountability and none of the access, and until this list existed nobody was told. Unpaginated — it is a worklist somebody drains by hand in the console, not a directory. Declared above the ``/{flow_id}`` route for the usual reason: a literal segment registered after a UUID matcher is shadowed by it, and 「feishu-drift」 is not a UUID, so the symptom would be a 422 on a route that looks present. Read-gated like the rest of the operational view. There is no write here to gate: the only action is 「去飞书后台改」, and Feishu judges that permission itself. """ _require_view(user) rows = await svc.flows_with_feishu_admin_drift(db) return success_response( { "items": [serialize_flow(r) for r in rows], "total": len(rows), "console_url": const.FEISHU_APPROVAL_ADMIN_URL, "renag_days": const.DRIFT_RENAG_DAYS, }, request=request, ) @router.get("/export") async def export_flows( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, total = await svc.list_flows(db, page=1, page_size=1000) records = [ { "approval_code": r.approval_code, "name": r.name, "category": r.category or "", "source_group": r.source_group or "", "status": r.status, "owner": (r.owner.display_name or r.owner.work_email) if r.owner else "", "owner_email": r.owner.work_email if r.owner else "", "owner_source": r.owner_source, "owner_confirmed_at": ( r.owner_confirmed_at.isoformat() if r.owner_confirmed_at else "" ), "observed_instances": r.observed_instances, "description": r.description or "", } for r in rows ] return await xlsx_export_response( db, request, user, columns=[ ("approval_code", "审批 Code"), ("name", "流程名称"), ("category", "分类"), ("source_group", "飞书分组"), ("status", "状态"), ("owner", "负责人"), ("owner_email", "负责人邮箱"), ("owner_source", "负责人来源"), ("owner_confirmed_at", "最近确认时间"), ("observed_instances", "近期实例数"), ("description", "用途说明"), ], rows=records, total_matched=total, filename_stem="approval-flow-registry", sheet_title="审批流程台账", audit_action="approval_registry.export", audit_resource_type=_RESOURCE, ) @router.post("", status_code=201) @idempotent("approval_registry.create") async def create_flow( payload: FlowCreateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) flow = await svc.create_flow( db, approval_code=payload.approval_code, name=payload.name, category=payload.category, description=payload.description, status=payload.status, published=payload.published, owner_person_id=payload.owner_person_id, actor=actor, ) await _audit( db, request, user, action="approval_registry.create", resource_id=str(flow.id), detail={"approval_code": flow.approval_code, "name": flow.name}, ) await db.commit() flow = await svc.get_flow(db, flow.id) return success_response(serialize_flow(flow), request=request) @router.patch("/{flow_id}") async def update_flow( flow_id: uuid.UUID, payload: FlowUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) flow = await svc.get_flow(db, flow_id) fields = payload.model_dump(exclude_unset=True) await svc.update_flow( db, flow, name=fields.get("name"), category=fields.get("category"), description=fields.get("description"), status=fields.get("status"), published=fields.get("published"), # An explicit ``category: null`` clears it; an absent key leaves # it alone. One nullable field cannot express both. clear_category=("category" in fields and fields["category"] is None), actor=actor, ) await _audit( db, request, user, action="approval_registry.update", resource_id=str(flow.id), detail={"fields": sorted(fields)}, ) await db.commit() flow = await svc.get_flow(db, flow_id) return success_response(serialize_flow(flow), request=request) @router.post("/{flow_id}/owner") async def set_owner( flow_id: uuid.UUID, payload: OwnerAssignRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) flow = await svc.get_flow(db, flow_id) # ``set_owner`` announces the handover to the new owner itself. await svc.set_owner( db, flow, owner_person_id=payload.owner_person_id, actor=actor, note=payload.note, ) await _audit( db, request, user, action="approval_registry.set_owner", resource_id=str(flow.id), detail={ "owner_person_id": ( str(payload.owner_person_id) if payload.owner_person_id else None ), }, ) await db.commit() flow = await svc.get_flow(db, flow_id) return success_response(serialize_flow(flow), request=request) @router.post("/{flow_id}/retire") async def retire_flow( flow_id: uuid.UUID, payload: RetireRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) flow = await svc.get_flow(db, flow_id) await svc.retire_flow(db, flow, actor=actor, reason=payload.note) await _audit( db, request, user, action="approval_registry.retire", resource_id=str(flow.id), detail={"note": payload.note}, ) await db.commit() return success_response({"id": str(flow_id), "status": "retired"}, request=request) @router.post("/import") async def import_flows( payload: ImportRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Bulk-register flows from a pasted table. Partial success is the contract: rows that fail come back with their line number and reason while the good ones land, because a 200-row paste that rejects wholesale on one typo gets retried by hand and loses data. """ _require_manage(user) actor = await _actor(db, user) result = await svc.import_flows( db, [row.model_dump() for row in payload.rows], actor=actor, ) await _audit( db, request, user, action="approval_registry.import", resource_id=None, detail={ "created": result["created"], "updated": result["updated"], "failed": result["failed"], }, ) await db.commit() return success_response(result, request=request) @router.post("/sync") async def trigger_sync( payload: SyncRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Run the Feishu sweep now. The same code path the nightly cron runs, so a manual run cannot drift from the scheduled one. Long — the caller is an admin who pressed a button and is watching a spinner. """ _require_manage(user) result = await discovery.run_sync( db, window_days=payload.window_days, person_limit=payload.person_limit, discover=payload.discover, sync_definitions=payload.sync_definitions, infer_owners=payload.infer_owners, ) await _audit( db, request, user, action="approval_registry.sync", resource_id=None, detail=result, ) await db.commit() return success_response(result, request=request) # ════════════════════════════════════════════════════════════════════ # Half-yearly confirmation rounds # ════════════════════════════════════════════════════════════════════ @router.get("/rounds", summary="半年度负责人确认:历轮") async def list_rounds( request: Request, limit: int = Query(20, ge=1, le=100), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rounds = await conf.list_rounds(db, limit=limit) return success_response( [ serialize_round(r, await conf.round_summary(db, r)) for r in rounds ], request=request, ) @router.get("/rounds/{round_id}", summary="半年度负责人确认:一轮的明细") async def get_round( round_id: uuid.UUID, request: Request, state: str | None = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) round_row = await conf.get_round(db, round_id) items = await conf.round_items(db, round_id) if state: items = [i for i in items if i.state == state] flows = { f.id: f for f in await svc.flows_by_ids(db, [i.flow_id for i in items]) } people = await svc.people_by_ids( db, [i.owner_person_id for i in items if i.owner_person_id] + [i.transferred_to_person_id for i in items if i.transferred_to_person_id], ) return success_response( { "round": serialize_round( round_row, await conf.round_summary(db, round_row), ), "items": [ serialize_round_item( i, flow=flows.get(i.flow_id), owner_name=svc.person_label(people.get(i.owner_person_id)), successor_name=svc.person_label( people.get(i.transferred_to_person_id), ), ) for i in items ], }, request=request, ) @router.post("/rounds/open-now", summary="立即开盘(错过窗口时的手动入口)") async def open_round_now( payload: OpenRoundRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Open the round outside the boundary window. The cron deliberately refuses to open retroactively — a mid-cycle deploy must not push a card to every owner in the company. When a window is genuinely missed, this is the conscious way back in: a human decides to send it. """ _require_manage(user) actor = await _actor(db, user) label = payload.label or conf.label_for(conf._now()) round_row, created = await conf.open_round( db, label=label, due_days=payload.due_days or const.ROUND_DUE_DAYS, actor=actor, ) items = await conf.round_items(db, round_row.id) await _audit( db, request, user, action="approval_registry.open_round", resource_id=str(round_row.id), detail={"label": label, "created": created, "items": len(items)}, ) await db.commit() notified = 0 if created: from app.domains.approval_registry import notify notified = await notify.notify_confirmation_round(db, round_row, items) round_row.notified_owners = notified await db.commit() return success_response( { "round": serialize_round( round_row, await conf.round_summary(db, round_row), ), "created": created, "owners_notified": notified, }, request=request, ) @router.post("/rounds/{round_id}/close", summary="关闭这一轮并冻结完成率") async def close_round( round_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) round_row = await conf.get_round(db, round_id) stats = await conf.close_round(db, round_row, actor=actor) await _audit( db, request, user, action="approval_registry.close_round", resource_id=str(round_id), detail=stats, ) await db.commit() return success_response( {"round": serialize_round(round_row, stats)}, request=request, ) # ════════════════════════════════════════════════════════════════════ # One-click confirmation from the Feishu card (no login) # ════════════════════════════════════════════════════════════════════ # # The card is read inside Feishu, usually on a phone. Requiring a login # to answer 「还是我」 is how a half-year round gets a 30% response rate. # The token's signature IS the authorisation: it is purpose-scoped, so # it cannot be redirected at another action, and it carries the person # and the round it was minted for. def _token_page( *, title: str, heading: str, body_html: str, tone: str = "info", ) -> HTMLResponse: """A tiny self-contained bilingual page — no SPA, no login, no i18n bundle. Works in the Feishu in-app browser and any mail client's external browser.""" accents = { "ok": ("#1a7f37", "#e7f6ec", "✓"), "err": ("#cf222e", "#fdecec", "!"), "info": ("#0969da", "#eaf2fb", "i"), } accent, badge_bg, glyph = accents.get(tone, accents["info"]) doc = ( "
" "" "" f"