156 lines
6.0 KiB
Python
156 lines
6.0 KiB
Python
"""Session (month-end closing) CRUD and parameters."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session as OrmSession
|
|
|
|
from ...config import DEFAULT_CLEARING_LAG_DAYS, DEFAULT_TOLERANCE
|
|
from ...db import models
|
|
from ..deps import db_dep, get_session_or_404, session_dict
|
|
|
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SessionCreate(BaseModel):
|
|
name: str
|
|
month_end_date: date | None = None
|
|
reporting_currency: str = "USD"
|
|
clearing_lag_days: int = DEFAULT_CLEARING_LAG_DAYS
|
|
rounding_tolerance: float = DEFAULT_TOLERANCE
|
|
allowance_for_returns: float = 0.0
|
|
# zero (default) | carry_forward | manual
|
|
opening_mode: str = "zero"
|
|
opening_source_session_id: int | None = None
|
|
# Two closings for one month is almost always an accident (two competing datasets for
|
|
# the same period); creating a second one requires this explicit flag.
|
|
allow_duplicate: bool = False
|
|
|
|
|
|
class SessionUpdate(BaseModel):
|
|
name: str | None = None
|
|
month_end_date: date | None = None
|
|
reporting_currency: str | None = None
|
|
clearing_lag_days: int | None = None
|
|
rounding_tolerance: float | None = None
|
|
allowance_for_returns: float | None = None
|
|
manual_adjustment: float | None = None
|
|
manual_adjustment_note: str | None = None
|
|
opening_mode: str | None = None
|
|
opening_source_session_id: int | None = None
|
|
|
|
|
|
def _approved_session_ids(db: OrmSession) -> set[int]:
|
|
rows = db.query(models.JournalEntry.session_id).filter(
|
|
models.JournalEntry.approved_by != "").all()
|
|
return {r[0] for r in rows}
|
|
|
|
|
|
@router.get("")
|
|
def list_sessions(db: OrmSession = Depends(db_dep)) -> list[dict]:
|
|
"""Every closing, newest month first — the dashboard reads as a month timeline."""
|
|
rows = db.query(models.Session).all()
|
|
# reporting_month is "YYYY-MM" so string sort == chronological; sessions without a
|
|
# month (never given a month-end date) sort last, newest created first.
|
|
rows.sort(key=lambda s: (s.reporting_month or "",
|
|
s.created_at.isoformat() if s.created_at else ""), reverse=True)
|
|
approved = _approved_session_ids(db)
|
|
months_seen: dict[str, int] = {}
|
|
for s in rows:
|
|
if s.reporting_month:
|
|
months_seen[s.reporting_month] = months_seen.get(s.reporting_month, 0) + 1
|
|
out = []
|
|
for s in rows:
|
|
d = session_dict(s)
|
|
# "Published" = the journal is approved, which is what puts the month on the
|
|
# cross-month Accounts Summary.
|
|
d["journal_approved"] = s.id in approved
|
|
d["duplicate_month"] = bool(s.reporting_month
|
|
and months_seen.get(s.reporting_month, 0) > 1)
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
@router.post("")
|
|
def create_session(body: SessionCreate, db: OrmSession = Depends(db_dep)) -> dict:
|
|
me = body.month_end_date
|
|
month = me.strftime("%Y-%m") if me else None
|
|
if month and not body.allow_duplicate:
|
|
clash = db.query(models.Session).filter(
|
|
models.Session.reporting_month == month,
|
|
models.Session.status != "error").first()
|
|
if clash is not None:
|
|
raise HTTPException(
|
|
409,
|
|
f"A closing for {month} already exists ('{clash.name}', id {clash.id}). "
|
|
f"Open that closing instead — or pass allow_duplicate to deliberately "
|
|
f"create a second one.",
|
|
)
|
|
s = models.Session(
|
|
name=body.name,
|
|
month_end_date=me,
|
|
reporting_month=month,
|
|
reporting_currency=body.reporting_currency,
|
|
clearing_lag_days=body.clearing_lag_days,
|
|
rounding_tolerance=body.rounding_tolerance,
|
|
allowance_for_returns=body.allowance_for_returns,
|
|
opening_mode=body.opening_mode or "zero",
|
|
opening_source_session_id=body.opening_source_session_id,
|
|
status="draft",
|
|
)
|
|
db.add(s)
|
|
db.commit()
|
|
from .ar import seed_opening_from_prior
|
|
seed_opening_from_prior(db, s)
|
|
return session_dict(s)
|
|
|
|
|
|
@router.get("/{session_id}")
|
|
def get_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|
return session_dict(get_session_or_404(session_id, db))
|
|
|
|
|
|
@router.patch("/{session_id}")
|
|
def update_session(session_id: int, body: SessionUpdate,
|
|
db: OrmSession = Depends(db_dep)) -> dict:
|
|
s = get_session_or_404(session_id, db)
|
|
data = body.model_dump(exclude_unset=True)
|
|
if s.status == "completed" and set(data) - {"name"}:
|
|
raise HTTPException(409, "This closing is completed and locked — only the name can "
|
|
"be changed. Reopen it first for anything else.")
|
|
for k, v in data.items():
|
|
setattr(s, k, v)
|
|
if "month_end_date" in data and s.month_end_date:
|
|
s.reporting_month = s.month_end_date.strftime("%Y-%m")
|
|
db.commit()
|
|
return session_dict(s)
|
|
|
|
|
|
@router.post("/{session_id}/reopen")
|
|
def reopen_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|
"""Unlock a completed closing for corrections. Deliberate and logged — the opposite of
|
|
silently editing published history."""
|
|
s = get_session_or_404(session_id, db)
|
|
if s.status != "completed":
|
|
raise HTTPException(409, "Only a completed closing can be reopened.")
|
|
s.status = "blocked" if s.blocked_reason else "processed"
|
|
db.commit()
|
|
logger.warning("closing %s (%s, %s) reopened for corrections",
|
|
s.id, s.name, s.reporting_month or "no month")
|
|
return session_dict(s)
|
|
|
|
|
|
@router.delete("/{session_id}")
|
|
def delete_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|
"""Delete a closing and every row/file that belongs to it."""
|
|
s = get_session_or_404(session_id, db)
|
|
if s.status in ("processing", "exporting"):
|
|
raise HTTPException(409, "This closing is still processing — wait for it to finish.")
|
|
from ...services.store import purge_session
|
|
return purge_session(db, session_id)
|