"""Self-service surface: the approval flows I am responsible for. No role gate — **the scope IS the authorisation**, the same contract as ``/api/v1/me/systems``. An employee learns they are on the hook for a flow here, before a half-year card or an offboarding blocker tells them for the first time. """ from __future__ import annotations import logging import uuid from typing import Any from fastapi import APIRouter, Depends, Request 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 NotFoundError from app.core.response import success_response from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import 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 ( ConfirmRequest, DisputeRequest, FlowRequestCreate, OwnerDescriptionRequest, TransferRequest, ) from app.domains.approval_registry.serializers import ( owner_state, serialize_flow, serialize_request, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/me/approval-flows", tags=["approval-registry"]) _RESOURCE = "registry_approval_flow" async def _mine_or_404( db: AsyncSession, flow_id: uuid.UUID, person_id: uuid.UUID, ): """404, never 403, when the flow is not mine. A 403 would confirm the row exists and that somebody else owns it — information this endpoint has no reason to hand out. """ flow = await svc.get_flow(db, flow_id) if flow.owner_person_id != person_id: raise NotFoundError("ApprovalFlow", str(flow_id)) return flow async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str, 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"), ) @router.get("", summary="我负责的审批流程") async def my_flows( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): rows = await svc.flows_owned_by(db, user.id) return success_response( { "total": len(rows), "flows": [serialize_flow(r) for r in rows], # The count the employee actually has to act on, derived from # the SAME ``owner_state`` the pages, the published list and # the assistant read. It used to be a second hand-rolled # rule, and the two disagreed: a confirmation older than the # validity horizon rendered as 「确认已过期」 and counted as # unconfirmed for the admin chasing it, while this told the # person being chased that nothing needed doing. "needs_confirmation": len( [r for r in rows if owner_state(r) != "confirmed"], ), }, request=request, ) @router.post("/{flow_id}/confirm", summary="确认这个流程仍由我负责") async def confirm( flow_id: uuid.UUID, payload: ConfirmRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await svc.load_person(db, user.id) flow = await _mine_or_404(db, flow_id, user.id) # Through the round when one is open, so the campaign's completion # rate and the register cannot disagree about the same person on the # same day. outcome = await conf.record_self_answer( db, flow, actor=person, keep=True, note=payload.note, via="web", ) await _audit( db, request, user, action="approval_registry.confirm_owner", resource_id=str(flow_id), detail=outcome, ) await db.commit() flow = await svc.get_flow(db, flow_id) return success_response(serialize_flow(flow), request=request) @router.post("/{flow_id}/dispute", summary="这个流程不归我负责") async def dispute( flow_id: uuid.UUID, payload: DisputeRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Records the objection on the flow's own trail. It does not clear the owner: letting one person's say-so blank the register would make a contested flow vanish from the coverage read instead of surfacing for IT to re-point.""" person = await svc.load_person(db, user.id) flow = await _mine_or_404(db, flow_id, user.id) await svc.dispute_owner(db, flow, actor=person, reason=payload.reason) await _audit( db, request, user, action="approval_registry.dispute_owner", resource_id=str(flow_id), detail={"reason": payload.reason}, ) await db.commit() # After the commit, and best-effort: the objection is recorded # whether or not Feishu is reachable. ``notified`` is returned as # the real number rather than a 「已通知」 the caller cannot check. notified = 0 try: notified = await notify.notify_ownership_disputed( db, flow, disputer=person, note=payload.reason, ) await db.commit() except Exception: # noqa: BLE001 — never fail a recorded objection logger.warning( "approval_registry: dispute push failed for %s", flow.approval_code, exc_info=True, ) await db.rollback() return success_response( {"id": str(flow_id), "disputed": True, "notified": notified}, request=request, ) @router.patch("/{flow_id}/description", summary="写一句这个流程是干什么的") async def set_description( flow_id: uuid.UUID, payload: OwnerDescriptionRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The owner's own words for what their flow is for. Self-scope, same ``_mine_or_404`` gate as every other verb here: you may describe a flow you answer for and no other. It writes the SAME ``description`` the all-staff page publishes and IT can edit — one sentence per flow, not two competing ones — and leaves an event naming who changed it, so 「这句话是谁写的」 stays answerable. Deliberately not part of the confirmation flow. Somebody fixing a stale sentence in March should not have to pretend it is the half-year round to do it. """ person = await svc.load_person(db, user.id) flow = await _mine_or_404(db, flow_id, user.id) await svc.set_description( db, flow, description=payload.description, actor=person, ) await _audit( db, request, user, action="approval_registry.set_description", resource_id=str(flow_id), detail={"length": len(payload.description or "")}, ) await db.commit() flow = await svc.get_flow(db, flow_id) return success_response(serialize_flow(flow), request=request) @router.post("/{flow_id}/transfer", summary="把这个流程转交给别人") async def transfer( flow_id: uuid.UUID, payload: TransferRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Hand a flow to a named successor. This is what 「这不该由我负责」 turns into once the person knows who it should be — and it counts as answering the round, because the round is measuring whether a human engaged, not whether the name stayed the same. The successor's ownership lands as IT-assigned rather than confirmed: they say so themselves next round. """ person = await svc.load_person(db, user.id) flow = await _mine_or_404(db, flow_id, user.id) outcome = await conf.record_self_answer( db, flow, actor=person, keep=False, transfer_to_person_id=payload.to_person_id, note=payload.note, via="web", ) await _audit( db, request, user, action="approval_registry.transfer_owner", resource_id=str(flow_id), detail={**outcome, "to_person_id": str(payload.to_person_id)}, ) await db.commit() flow = await svc.get_flow(db, flow_id) return success_response(serialize_flow(flow), request=request) @router.get("/round", summary="当前这一轮里我的确认项") async def my_round( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """What the card asked me, and what I have answered so far. Returns ``round: null`` when no round is open — the page then shows the standing list instead of implying a campaign that is not running. """ round_row = await conf.current_open_round(db) if round_row is None: return success_response({"round": None, "items": []}, request=request) items = await conf.items_for_owner(db, round_row.id, user.id) flows = { f.id: f for f in await svc.flows_by_ids(db, [i.flow_id for i in items]) } return success_response( { "round": { "id": str(round_row.id), "label": round_row.label, "due_at": ( round_row.due_at.isoformat() if round_row.due_at else None ), }, "items": [ { "flow_id": str(i.flow_id), "flow_name": ( flows[i.flow_id].name if i.flow_id in flows else None ), "state": i.state, } for i in items ], "pending": len([i for i in items if i.state == "pending"]), }, request=request, ) # ════════════════════════════════════════════════════════════════════ # 「公司还没有这个流程」 # ════════════════════════════════════════════════════════════════════ @router.post("/requests", status_code=201, summary="申请新建一个审批流程") async def submit_flow_request( payload: FlowRequestCreate, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The front door for a flow that does not exist yet. Self-scope: anyone can ask. The register publishes what exists and the Feishu console belongs to IT, so before this there was nowhere to put the question except a chat message that nobody could track. """ person = await svc.load_person(db, user.id) row = await reqs.submit_request( db, requester=person, proposed_name=payload.proposed_name, purpose=payload.purpose, proposed_category=payload.proposed_category, proposed_owner_person_id=payload.proposed_owner_person_id, ) await _audit( db, request, user, action="approval_registry.submit_flow_request", resource_id=str(row.id), detail={"name": row.proposed_name}, ) await db.commit() row = await reqs.get_request(db, row.id) notified = 0 try: notified = await notify.notify_flow_request_submitted(db, row) await db.commit() except Exception: # noqa: BLE001 — the request stands either way logger.warning( "approval_registry: new-flow request push failed for %s", row.id, exc_info=True, ) await db.rollback() # Re-read after the last commit: the session expires on commit and the # serializer walks relationships, which lazy-loads from sync code. payload_out = serialize_request(await reqs.get_request(db, row.id)) payload_out["notified"] = notified return success_response(payload_out, request=request) @router.get("/requests", summary="我提过的流程申请") async def my_flow_requests( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): rows, total = await reqs.list_requests( db, requester_person_id=user.id, page=1, page_size=50, ) return success_response( {"total": total, "requests": [serialize_request(r) for r in rows]}, request=request, ) @router.post("/requests/{request_id}/cancel", summary="撤回我的流程申请") async def cancel_flow_request( request_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await svc.load_person(db, user.id) row = await reqs.get_request(db, request_id) # 404, never 403 — a 403 confirms somebody else's request exists. if row.requester_person_id != user.id: raise NotFoundError("ApprovalFlowRequest", str(request_id)) await reqs.cancel_request(db, row, actor=person) await _audit( db, request, user, action="approval_registry.cancel_flow_request", resource_id=str(request_id), ) await db.commit() return success_response( serialize_request(await reqs.get_request(db, request_id)), request=request, )