"""HTTP surface for close *tasks* (PR2) — mounted at /api/v1/finance/close. Tasks, templates, the entity catalogue, instantiation + roll-forward, and the per-owner / per-process / overall progress rollups. Reads ``can_view_finance``; writes ``can_operate_finance`` + leaver-gate + rate-limit + audit. """ from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends, Query, 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 ( check_sensitive_endpoint_rate_limit, get_current_user, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_operate_finance, can_view_finance from app.core.response import success_response from app.domains.finance import close_serializers as ser from app.domains.finance import close_service, close_signals, close_task_service from app.domains.finance.close_models import FinanceCloseEntity, FinanceCloseTaskTemplate from app.domains.finance.close_task_schemas import ( BulkReassignBody, BulkStatusBody, EntityMapBody, RollForwardBody, TaskCommentBody, TaskCreateBody, TaskStatusBody, TaskUpdateBody, TemplateOwnerBody, ) router = APIRouter( prefix="/api/v1/finance/close", tags=["finance-close"], dependencies=[Depends(_leaver_runtime_gate)], ) def _trace_id(request: Request) -> str: return getattr(request.state, "trace_id", None) or str(uuid.uuid4()) async def _audit(request, db, user, action, resource_id, detail=None): await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type="finance.close_task", resource_id=str(resource_id), detail=detail, ) def _require_view(user): if not can_view_finance(user.role_names): raise ForbiddenError() def _require_operate(user): if not can_operate_finance(user.role_names): raise ForbiddenError() # ── tasks + aggregation ── @router.get("/runs/{period}/tasks") async def list_tasks( request: Request, period: str, process: Annotated[str | None, Query()] = None, owner_person_id: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None, calendar_day: Annotated[str | None, Query()] = None, q: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Tasks for a period (filterable) + per-owner / per-process / overall rollup.""" _require_view(user) close_service.validate_period(period) tasks = await close_task_service.list_tasks( db, period, process=process, owner_person_id=owner_person_id, status=status, calendar_day=calendar_day, q=q, ) agg = await close_task_service.aggregate(db, period) signals = await close_signals.evaluate_signals(db, period) # Resolve owner person ids → real display names (the checklist labels are # nicknames; the console shows the real person once resolved). from app.core.coordinators import finance_close_coordinator as coord owner_ids = [t.owner_person_id for t in tasks if t.owner_person_id] owner_ids += [b["owner_person_id"] for b in agg["by_owner"] if b.get("owner_person_id")] names = await coord.resolve_person_names(db, owner_ids) task_dicts = [] for t in tasks: d = ser.task_to_dict(t) d["owner_name"] = names.get(t.owner_person_id or "") or t.owner_label task_dicts.append(d) for b in agg["by_owner"]: b["owner_name"] = names.get(b.get("owner_person_id") or "") or b.get("owner_label") return success_response( data={"tasks": task_dicts, "aggregate": agg, "signals": signals}, request=request, ) @router.get("/runs/{period}/signals") async def list_signals( request: Request, period: str, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """All Helix-native close signals for the period (decision support).""" _require_view(user) close_service.validate_period(period) return success_response( data=await close_signals.evaluate_signals(db, period), request=request, ) @router.get("/runs/{period}/tasks/export.xlsx") async def export_tasks( request: Request, period: str, ids: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Export the period's close checklist as a real .xlsx workbook. ``ids`` (comma-separated) narrows to exactly the rows the console is showing — the page filters client-side, so it passes the visible task ids and the download matches the screen. Omitted → the full period. Audited via the standard export primitive. """ _require_view(user) close_service.validate_period(period) from app.core.exporting import xlsx_export_response tasks = await close_task_service.list_tasks(db, period) if ids: wanted = {int(x) for x in ids.split(",") if x.strip().isdigit()} if wanted: tasks = [t for t in tasks if t.id in wanted] from app.core.coordinators import finance_close_coordinator as coord names = await coord.resolve_person_names( db, [t.owner_person_id for t in tasks if t.owner_person_id] ) columns = [ ("process", "Process"), ("sub_process", "Sub-process"), ("description", "Task"), ("owner", "Owner"), ("frequency", "Frequency"), ("calendar_day", "Day"), ("deadline", "Deadline"), ("status", "Status"), ("estimated_hours", "Est. hours"), ("submit_to_fpa", "Submit to FP&A"), ("ai_work", "AI work"), ("entities", "Entities"), ("comment", "Comment"), ("link", "Link"), ] rows = [ { "process": t.process, "sub_process": t.sub_process or "", "description": t.description, "owner": names.get(t.owner_person_id or "") or t.owner_label or "", "frequency": t.frequency, "calendar_day": t.calendar_day, "deadline": t.deadline.isoformat() if t.deadline else "", "status": t.status, "estimated_hours": "" if t.estimated_hours is None else t.estimated_hours, "submit_to_fpa": "Yes" if t.submit_to_fpa else "", "ai_work": t.ai_work, "entities": ", ".join(t.entity_codes or []), "comment": t.comment or "", "link": t.link or "", } for t in tasks ] return await xlsx_export_response( db, request, user, columns=columns, rows=rows, total_matched=len(rows), filename_stem=f"close_{period}", sheet_title=f"Close {period}", audit_action="finance.close.export", audit_resource_type="finance.close_task", filters={"period": period, "ids": ids}, ) @router.post("/runs/{period}/tasks") async def create_task( request: Request, period: str, body: TaskCreateBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) await close_service.ensure_run(db, period, actor_id=str(user.id)) task = await close_task_service.create_task( db, period, fields=body.model_dump(exclude_unset=True), actor_id=str(user.id), ) await _audit(request, db, user, "finance.close.task_create", task.id, {"period": period}) await db.commit() return success_response(data=ser.task_to_dict(task), request=request) @router.patch("/tasks/{task_id}") async def update_task( request: Request, task_id: int, body: TaskUpdateBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) task = await close_task_service.update_task( db, task_id, fields=body.model_dump(exclude_unset=True), ) await _audit(request, db, user, "finance.close.task_update", task_id) await db.commit() return success_response(data=ser.task_to_dict(task), request=request) @router.post("/tasks/{task_id}/status") async def set_task_status( request: Request, task_id: int, body: TaskStatusBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) task = await close_task_service.set_status( db, task_id, status=body.status, actor_id=str(user.id), ) await _audit(request, db, user, "finance.close.task_status", task_id, {"status": body.status}) await db.commit() return success_response(data=ser.task_to_dict(task), request=request) @router.post("/tasks/{task_id}/ai-assist") async def task_ai_assist( request: Request, task_id: int, mode: Annotated[str | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Real AI assistance for an assist/perform task (Bailian, deterministic fallback). Advisory only — never mutates or completes the task.""" if not can_operate_finance(user.role_names): raise ForbiddenError() from app.domains.finance import close_ai_service try: result = await close_ai_service.generate_task_assist(db, task_id, mode=mode) except close_ai_service.CloseTaskNotEligible as exc: from app.core.exceptions import ValidationError raise ValidationError(str(exc)) from exc await _audit(request, db, user, "finance.close.ai_assist", task_id, {"mode": result["mode"], "source": result["source"]}) await db.commit() # audit only return success_response(data=result, request=request) @router.post("/tasks/{task_id}/comment") async def set_task_comment( request: Request, task_id: int, body: TaskCommentBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) task = await close_task_service.set_comment( db, task_id, comment=body.comment, commenter_person_id=body.commenter_person_id, commenter_label=body.commenter_label, ) await _audit(request, db, user, "finance.close.task_comment", task_id) await db.commit() return success_response(data=ser.task_to_dict(task), request=request) @router.delete("/tasks/{task_id}") async def delete_task( request: Request, task_id: int, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) await close_task_service.delete_task(db, task_id) await _audit(request, db, user, "finance.close.task_delete", task_id) await db.commit() return success_response(data={"removed": task_id}, request=request) @router.post("/tasks/bulk-status") async def bulk_status( request: Request, body: BulkStatusBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) n = 0 for tid in body.task_ids: await close_task_service.set_status(db, tid, status=body.status, actor_id=str(user.id)) n += 1 await _audit(request, db, user, "finance.close.task_bulk_status", "bulk", {"count": n, "status": body.status}) await db.commit() return success_response(data={"updated": n}, request=request) @router.post("/tasks/bulk-reassign") async def bulk_reassign( request: Request, body: BulkReassignBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) fields = {"owner_person_id": body.owner_person_id, "owner_label": body.owner_label} n = 0 for tid in body.task_ids: await close_task_service.update_task(db, tid, fields=fields) n += 1 await _audit(request, db, user, "finance.close.task_bulk_reassign", "bulk", {"count": n}) await db.commit() return success_response(data={"updated": n}, request=request) # ── instantiation + roll-forward ── @router.post("/runs/{period}/instantiate") async def instantiate( request: Request, period: str, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Populate a period with one task per active template (idempotent).""" _require_operate(user) result = await close_task_service.instantiate_from_templates( db, period, actor_id=str(user.id), ) await _audit(request, db, user, "finance.close.instantiate", period, result) await db.commit() return success_response(data=result, request=request) @router.post("/roll-forward") async def roll_forward( request: Request, body: RollForwardBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Clone a source period's tasks into a new period (statuses reset).""" _require_operate(user) result = await close_task_service.roll_forward( db, from_period=body.from_period, to_period=body.to_period, actor_id=str(user.id), ) await _audit(request, db, user, "finance.close.roll_forward", body.to_period, result) await db.commit() return success_response(data=result, request=request) # ── templates + entities ── @router.get("/templates") async def list_templates( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) templates = await close_task_service.list_templates(db) return success_response( data=[ser.template_to_dict(t) for t in templates], request=request, ) @router.post("/templates/seed") async def seed_templates( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """(Re)seed the template library + entity catalogue; re-resolve owners/links.""" _require_operate(user) result = await close_task_service.ensure_seeded(db) await _audit(request, db, user, "finance.close.templates_seed", "templates", result) await db.commit() return success_response(data=result, request=request) @router.patch("/templates/{template_id}/owner") async def set_template_owner( request: Request, template_id: int, body: TemplateOwnerBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): _require_operate(user) tpl = await db.get(FinanceCloseTaskTemplate, template_id) if tpl is None: raise NotFoundError("template not found") tpl.owner_person_id = body.owner_person_id if body.owner_label is not None: tpl.owner_label = body.owner_label await _audit(request, db, user, "finance.close.template_owner", template_id) await db.commit() return success_response(data=ser.template_to_dict(tpl), request=request) @router.get("/entities") async def list_entities( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows = await close_task_service.list_entities(db) return success_response(data=[ser.entity_to_dict(e) for e in rows], request=request) @router.get("/legal-entities") async def list_legal_entities( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Asset legal entities, for the entity-code mapping picker.""" _require_view(user) from app.core.coordinators import finance_close_coordinator as coord return success_response(data=await coord.list_legal_entities(db), request=request) @router.patch("/entities/{entity_id}") async def map_entity( request: Request, entity_id: int, body: EntityMapBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Map a checklist entity code to an asset legal entity (admin override).""" _require_operate(user) e = await db.get(FinanceCloseEntity, entity_id) if e is None: raise NotFoundError("entity not found") e.legal_entity_id = body.legal_entity_id e.legal_entity_name = body.legal_entity_name await _audit(request, db, user, "finance.close.entity_map", entity_id) await db.commit() return success_response(data=ser.entity_to_dict(e), request=request)