feat: API meldet mögliche Doppelzählung mit 409, Speichern nur mit Bestätigung (A12-T3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,10 @@ from app.db import get_session
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||
ScenarioLoan)
|
||||
from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt,
|
||||
KreditDaten, PostenDaten,
|
||||
conflicts_for_loan, conflicts_for_recurring,
|
||||
kredit_aus, posten_aus)
|
||||
from app.services.suggestions import suggest_recurring
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["planning"],
|
||||
@@ -36,6 +40,11 @@ class RecurringIn(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class RecurringCreate(RecurringIn):
|
||||
# Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class RecurringOut(RecurringIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -49,6 +58,7 @@ class RecurringPatch(BaseModel):
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
category_id: int | None = None
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class SuggestionOut(BaseModel):
|
||||
@@ -66,6 +76,26 @@ def _check_category(session: Session, category_id: int | None) -> None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
|
||||
|
||||
_CENT = Decimal("0.01")
|
||||
_FLAG = "doppelzaehlung_bestaetigt"
|
||||
|
||||
|
||||
def _doppelzaehlung(konflikte: list[Konflikt]) -> HTTPException:
|
||||
"""409 mit strukturierter Erklärung; die GUI zeigt daraus das Warnfenster."""
|
||||
return HTTPException(409, detail={
|
||||
"code": DOPPELZAEHLUNG_CODE,
|
||||
"meldung": ("Der Betrag entspricht vermutlich einer Kreditrate, die bereits "
|
||||
"über „Kredite“ eingeplant ist (oder umgekehrt). Beide zusammen "
|
||||
"zählen die Rate doppelt – Planung und Szenarien wären zu "
|
||||
"pessimistisch. Nur speichern, wenn das wirklich gewollt ist."),
|
||||
"konflikte": [{"posten": k.posten_name,
|
||||
"posten_betrag": str(k.posten_betrag.quantize(_CENT)),
|
||||
"kredit": k.kredit_name,
|
||||
"kredit_rate": str(k.kredit_rate.quantize(_CENT))}
|
||||
for k in konflikte],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/recurring", response_model=list[RecurringOut])
|
||||
def list_recurring(session: Session = Depends(get_session)):
|
||||
return [RecurringOut.model_validate(r)
|
||||
@@ -73,9 +103,16 @@ def list_recurring(session: Session = Depends(get_session)):
|
||||
|
||||
|
||||
@router.post("/recurring", response_model=RecurringOut, status_code=201)
|
||||
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)):
|
||||
def create_recurring(data: RecurringCreate, session: Session = Depends(get_session)):
|
||||
_check_category(session, data.category_id)
|
||||
item = RecurringItem(**data.model_dump())
|
||||
fields = data.model_dump(exclude={_FLAG})
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
konflikte = conflicts_for_recurring(session, PostenDaten(
|
||||
None, fields["name"], fields["amount"], fields["rhythm"],
|
||||
fields["start_date"], fields["end_date"]))
|
||||
if konflikte:
|
||||
raise _doppelzaehlung(konflikte)
|
||||
item = RecurringItem(**fields)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
@@ -94,7 +131,8 @@ def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
item = session.get(RecurringItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))}
|
||||
fields = data.model_dump(exclude_unset=True, exclude={_FLAG})
|
||||
if "category_id" in fields:
|
||||
_check_category(session, fields["category_id"])
|
||||
for key, value in fields.items():
|
||||
@@ -103,6 +141,12 @@ def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
and item.end_date < item.start_date):
|
||||
session.rollback()
|
||||
raise HTTPException(422, "Ende darf nicht vor Start liegen")
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
neu = [k for k in conflicts_for_recurring(session, posten_aus(item))
|
||||
if k.paar not in vorher]
|
||||
if neu:
|
||||
session.rollback()
|
||||
raise _doppelzaehlung(neu)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return RecurringOut.model_validate(item)
|
||||
@@ -188,6 +232,10 @@ class LoanIn(BaseModel):
|
||||
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
||||
|
||||
|
||||
class LoanCreate(LoanIn):
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class LoanOut(LoanIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -200,6 +248,7 @@ class LoanPatch(BaseModel):
|
||||
term_months: int | None = None
|
||||
payout_date: date | None = None
|
||||
repayment_type: Literal["annuity", "bullet"] | None = None
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class InstallmentOut(BaseModel):
|
||||
@@ -217,8 +266,15 @@ def list_loans(session: Session = Depends(get_session)):
|
||||
|
||||
|
||||
@router.post("/loans", response_model=LoanOut, status_code=201)
|
||||
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
|
||||
loan = Loan(**data.model_dump())
|
||||
def create_loan(data: LoanCreate, session: Session = Depends(get_session)):
|
||||
fields = data.model_dump(exclude={_FLAG})
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
konflikte = conflicts_for_loan(session, KreditDaten(
|
||||
None, fields["name"], fields["principal"], fields["annual_rate_pct"],
|
||||
fields["term_months"], fields["payout_date"], fields["repayment_type"]))
|
||||
if konflikte:
|
||||
raise _doppelzaehlung(konflikte)
|
||||
loan = Loan(**fields)
|
||||
session.add(loan)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
@@ -230,8 +286,15 @@ def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_ses
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
vorher = {k.paar for k in conflicts_for_loan(session, kredit_aus(loan))}
|
||||
for key, value in data.model_dump(exclude_unset=True, exclude={_FLAG}).items():
|
||||
setattr(loan, key, value)
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
neu = [k for k in conflicts_for_loan(session, kredit_aus(loan))
|
||||
if k.paar not in vorher]
|
||||
if neu:
|
||||
session.rollback()
|
||||
raise _doppelzaehlung(neu)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
return LoanOut.model_validate(loan)
|
||||
|
||||
Reference in New Issue
Block a user