Finance-Accounts/ar-aging-app/backend/app/api/routes/settings.py

135 lines
5.1 KiB
Python

"""Per-session settings (reserves, FX rates) + global header-mapping rules."""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session as OrmSession
from ...core.column_map import FIELD_ORDER, normalize_header
from ...db import models
from ..deps import db_dep, get_session_or_404, to_dict
router = APIRouter(prefix="/api/sessions", tags=["settings"])
rules_router = APIRouter(prefix="/api/mapping-rules", tags=["mapping"])
meta_router = APIRouter(prefix="/api", tags=["meta"])
@meta_router.get("/definitions")
def definitions() -> dict:
"""What every dashboard figure means: formula + source columns/types, per metric key.
Content lives in core/definitions.py, next to the engine code it documents, so the UI's
(i) buttons cannot drift from what the engine actually computes."""
from ...core.definitions import DEFINITIONS
return DEFINITIONS
class RuleIn(BaseModel):
header: str # raw or normalized source header
field: str # canonical internal field
@rules_router.get("")
def list_rules(db: OrmSession = Depends(db_dep)) -> dict:
rows = db.query(models.MappingRule).order_by(models.MappingRule.normalized_header).all()
return {
"fields": FIELD_ORDER,
"rules": [{"id": r.id, "normalized_header": r.normalized_header, "field": r.field}
for r in rows],
}
@rules_router.put("")
def upsert_rules(items: list[RuleIn], db: OrmSession = Depends(db_dep)) -> dict:
"""Save admin header-alias rules; applied to every future parse (no code change needed)."""
for it in items:
if it.field not in FIELD_ORDER:
raise HTTPException(400, f"Unknown field '{it.field}'.")
norm = normalize_header(it.header)
if not norm:
raise HTTPException(400, "Empty header.")
row = db.query(models.MappingRule).filter(
models.MappingRule.normalized_header == norm).first()
if row is None:
db.add(models.MappingRule(normalized_header=norm, field=it.field))
else:
row.field = it.field
db.commit()
return list_rules(db)
@rules_router.delete("/{rule_id}")
def delete_rule(rule_id: int, db: OrmSession = Depends(db_dep)) -> dict:
db.query(models.MappingRule).filter(models.MappingRule.id == rule_id).delete()
db.commit()
return list_rules(db)
class ReserveIn(BaseModel):
marketplace: str
account_type: str
amount: float = 0.0
class FxIn(BaseModel):
marketplace: str
currency: str = "USD"
rate: float = 1.0
source: str = "manual"
rate_date: date | None = None
@router.get("/{session_id}/reserves")
def get_reserves(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db)
rows = db.query(models.Reserve).filter(models.Reserve.session_id == session_id).all()
return [to_dict(r, ["marketplace", "account_type", "amount"]) for r in rows]
@router.put("/{session_id}/reserves")
def put_reserves(session_id: int, items: list[ReserveIn],
db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db)
db.query(models.Reserve).filter(models.Reserve.session_id == session_id).delete()
for it in items:
db.add(models.Reserve(session_id=session_id, marketplace=it.marketplace,
account_type=it.account_type, amount=it.amount))
db.commit()
return get_reserves(session_id, db)
@router.get("/{session_id}/fx")
def get_fx(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
get_session_or_404(session_id, db)
rows = db.query(models.FxRate).filter(models.FxRate.session_id == session_id).all()
return [to_dict(r, ["marketplace", "currency", "rate", "source", "rate_date"]) for r in rows]
@router.put("/{session_id}/fx")
def put_fx(session_id: int, items: list[FxIn], db: OrmSession = Depends(db_dep)) -> list[dict]:
s = get_session_or_404(session_id, db)
# Upsert per marketplace — NOT delete-all-then-insert. This used to wipe every rate not
# named in the body, so a partial PUT silently removed the other marketplaces' rates and
# the close fell back to the hardcoded Jan-26 defaults without a word.
existing = {r.marketplace: r for r in db.query(models.FxRate).filter(
models.FxRate.session_id == session_id)}
for it in items:
row = existing.get(it.marketplace)
if row is None:
row = models.FxRate(session_id=session_id, marketplace=it.marketplace)
db.add(row)
# Changing a rate withdraws the confirmation that was given for the old one.
if row.rate != it.rate or (row.currency or "") != it.currency:
row.confirmed_by = ""
row.confirmed_at = None
row.confirmed_month = ""
row.currency, row.rate, row.source, row.rate_date = (
it.currency, it.rate, it.source, it.rate_date)
db.commit()
if s.status in ("processed", "blocked", "completed"):
from ...services.controls_run import run_and_persist
run_and_persist(db, session_id)
return get_fx(session_id, db)