"""HTTP surface for the month-end book-close calendar + period lifecycle (PR1). Mounted at ``/api/v1/finance/close``. Reads use ``can_view_finance``; mutations (close-run lifecycle, anchors, override, calendar config, holidays, makeup days) use ``can_operate_finance`` — the finance team's own workflow, per DECISIONS.md — and are audited + leaver-gated + rate-limited. """ from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import select 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 from app.core.rbac import can_operate_finance, can_view_finance from app.core.response import success_response from app.domains.finance import close_calendar as engine from app.domains.finance import close_serializers as ser from app.domains.finance import close_service from app.domains.finance.close_models import FinanceCloseRun from app.domains.finance.close_schemas import ( AnchorsBody, ConfigBody, HolidayBody, MakeupBody, OverrideBody, StatusBody, ) 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, status="success"): await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action=action, status=status, resource_type="finance.close_run", resource_id=resource_id, detail=detail, ) async def _preview_window(db: AsyncSession, period: str) -> engine.CloseWindow: """Compute a non-persisted window from default anchors + current config.""" config = await close_service.load_config(db) holidays = await close_service.load_holidays(db) makeup = await close_service.load_makeup(db) return engine.compute_close_window( period, target_workday=config.target_workday, work_weekend_day=config.work_weekend_day, holidays=holidays, makeup_workdays=makeup, ) # ── close runs ── @router.get("/runs") async def list_runs( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """All close runs, newest period first — feeds the month switcher.""" if not can_view_finance(user.role_names): raise ForbiddenError() rows = ( await db.execute( select(FinanceCloseRun).order_by(FinanceCloseRun.period.desc()) ) ).scalars().all() data = [] for run in rows: window = await close_service.compute_window(db, run) data.append({ "period": run.period, "label": run.label, "status": run.status, "close_date": (close_service.effective_close_date(run, window)).isoformat(), }) return success_response(data=data, request=request) @router.get("/runs/{period}") async def get_run( request: Request, period: str, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Read a close run + its computed window. If the run does not exist yet, returns ``run: null`` with a *preview* window from default anchors so the console can render the calendar before the run is materialised.""" if not can_view_finance(user.role_names): raise ForbiddenError() close_service.validate_period(period) run = await close_service.get_run(db, period) if run is None: window = await _preview_window(db, period) return success_response( data={ "run": None, "preview": ser.window_to_dict(window, window.close_date), "label": close_service.period_label(period), }, request=request, ) window = await close_service.compute_window(db, run) return success_response(data={"run": ser.run_to_dict(run, window)}, request=request) @router.post("/runs/{period}") async def create_run( request: Request, period: str, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Materialise the close run for ``period`` (idempotent).""" if not can_operate_finance(user.role_names): raise ForbiddenError() close_service.validate_period(period) existed = await close_service.get_run(db, period) is not None run = await close_service.ensure_run(db, period, actor_id=str(user.id)) window = await close_service.compute_window(db, run) if not existed: await _audit(request, db, user, "finance.close.run_create", period) await db.commit() return success_response(data={"run": ser.run_to_dict(run, window)}, request=request) @router.patch("/runs/{period}/anchors") async def update_anchors( request: Request, period: str, body: AnchorsBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Adjust the Day-1 / Day+1 deadline anchors → recomputes all deadlines.""" if not can_operate_finance(user.role_names): raise ForbiddenError() close_service.validate_period(period) run = await close_service.ensure_run(db, period, actor_id=str(user.id)) await close_service.update_anchors( db, run, anchor_minus_1=body.anchor_minus_1, anchor_plus_1=body.anchor_plus_1, ) # Anchors drive every task deadline — recompute the period's tasks. from app.domains.finance import close_task_service await close_task_service.recompute_deadlines(db, period) window = await close_service.compute_window(db, run) await _audit(request, db, user, "finance.close.anchors_update", period, { "anchor_minus_1": run.anchor_minus_1.isoformat(), "anchor_plus_1": run.anchor_plus_1.isoformat(), }) await db.commit() return success_response(data={"run": ser.run_to_dict(run, window)}, request=request) @router.post("/runs/{period}/override") async def set_override( request: Request, period: str, body: OverrideBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Set or clear the manual book-close-date override (audit reason required).""" if not can_operate_finance(user.role_names): raise ForbiddenError() close_service.validate_period(period) run = await close_service.ensure_run(db, period, actor_id=str(user.id)) await close_service.set_override( db, run, override_close_date=body.override_close_date, reason=body.reason, actor_id=str(user.id), ) window = await close_service.compute_window(db, run) await _audit(request, db, user, "finance.close.override", period, { "override_close_date": body.override_close_date.isoformat() if body.override_close_date else None, "reason": body.reason, }) await db.commit() return success_response(data={"run": ser.run_to_dict(run, window)}, request=request) @router.post("/runs/{period}/status") async def set_status( request: Request, period: str, body: StatusBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Transition the close-run lifecycle (open / in_progress / soft_closed / closed).""" if not can_operate_finance(user.role_names): raise ForbiddenError() close_service.validate_period(period) run = await close_service.ensure_run(db, period, actor_id=str(user.id)) prev = run.status await close_service.set_status(db, run, new_status=body.status, actor_id=str(user.id)) # A transition that freezes/unfreezes the run changes the effective close # window (reopen drops the frozen snapshot → live config), so task # deadlines must be recomputed — same as editing the anchors. from app.domains.finance import close_task_service await close_task_service.recompute_deadlines(db, period) window = await close_service.compute_window(db, run) await _audit(request, db, user, "finance.close.status", period, { "from": prev, "to": run.status, }) await db.commit() return success_response(data={"run": ser.run_to_dict(run, window)}, request=request) # ── calendar config / holidays / makeup ── @router.get("/config") async def get_config( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() config = await close_service.load_config(db) holidays = await close_service.list_holidays(db) makeup = await close_service.list_makeup(db) return success_response( data={ "config": ser.config_to_dict(config), "holidays": [ser.holiday_to_dict(h) for h in holidays], "makeup_workdays": [ser.makeup_to_dict(m) for m in makeup], }, request=request, ) @router.put("/config") async def put_config( request: Request, body: ConfigBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_operate_finance(user.role_names): raise ForbiddenError() config = await close_service.update_config( db, target_workday=body.target_workday, work_weekend_day=body.work_weekend_day, actor_id=str(user.id), ) await _audit(request, db, user, "finance.close.config_update", "config", { "target_workday": config.target_workday, "work_weekend_day": config.work_weekend_day, }) await db.commit() return success_response(data={"config": ser.config_to_dict(config)}, request=request) @router.get("/holidays") async def get_holidays( request: Request, year: Annotated[int | None, Query()] = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_view_finance(user.role_names): raise ForbiddenError() rows = await close_service.list_holidays(db, year=year) return success_response( data=[ser.holiday_to_dict(h) for h in rows], request=request, ) @router.post("/holidays") async def post_holiday( request: Request, body: HolidayBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_operate_finance(user.role_names): raise ForbiddenError() row = await close_service.add_holiday( db, day=body.day, country=body.country, name=body.name, ) await _audit(request, db, user, "finance.close.holiday_add", str(row.id), { "day": body.day.isoformat(), "country": body.country, }) await db.commit() return success_response(data=ser.holiday_to_dict(row), request=request) @router.delete("/holidays/{holiday_id}") async def delete_holiday( request: Request, holiday_id: int, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_operate_finance(user.role_names): raise ForbiddenError() await close_service.remove_holiday(db, holiday_id=holiday_id) await _audit(request, db, user, "finance.close.holiday_remove", str(holiday_id)) await db.commit() return success_response(data={"removed": holiday_id}, request=request) @router.post("/makeup") async def post_makeup( request: Request, body: MakeupBody, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_operate_finance(user.role_names): raise ForbiddenError() row = await close_service.add_makeup(db, day=body.day, note=body.note) await _audit(request, db, user, "finance.close.makeup_add", str(row.id), { "day": body.day.isoformat(), }) await db.commit() return success_response(data=ser.makeup_to_dict(row), request=request) @router.delete("/makeup/{makeup_id}") async def delete_makeup( request: Request, makeup_id: int, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): if not can_operate_finance(user.role_names): raise ForbiddenError() await close_service.remove_makeup(db, makeup_id=makeup_id) await _audit(request, db, user, "finance.close.makeup_remove", str(makeup_id)) await db.commit() return success_response(data={"removed": makeup_id}, request=request) @router.post("/restore-defaults") async def restore_defaults( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_sensitive_endpoint_rate_limit), ): """Re-seed the built-in PingCAP/CN holidays + makeup days and reset config. Preserves admin-added (manual) entries; removes/re-adds only ``default`` rows. Idempotent.""" if not can_operate_finance(user.role_names): raise ForbiddenError() result = await close_service.restore_defaults(db, actor_id=str(user.id)) await _audit(request, db, user, "finance.close.restore_defaults", "config", result) await db.commit() return success_response(data=result, request=request)