Konsolidierte Funde aus postgres-/sql-/jwt-/owasp-top10-expert-Review: - Race Conditions gefixt: doppelte aktive Kontrolle (SAVEPOINT + partieller Unique-Index), doppelte Mindermengen-Genehmigung (FOR UPDATE + Unique-Index), Lost-Update bei Nachfüllung (FOR UPDATE auf Fehlbestand/Objektposition). - Migration 0007: partielle Unique-Indizes als DB-Sicherheitsnetz + fehlende FK-Indizes (fehlbestand.material_id, kontrolle(objekt_id,status), zustaendigkeit, benutzer_rolle.rolle, objektposition.ablaufdatum u.a.). - Connection-Pool explizit begrenzt (pool_size=5, max_overflow=5) - ohne das könnte jeder uvicorn-Worker den Postgres max_connections-Wert sprengen. - Timing-Angriff bei Login-Enumeration gefixt (konstante Antwortzeit über Dummy-Hash), JWT-Decode verlangt jetzt exp/sub-Claims. - App-seitiges Rate-Limiting (slowapi, 5/min) auf /auth/login als Verteidigung in der Tiefe zusätzlich zum nginx-Limit. - nginx: TLS mit selbstsigniertem Zertifikat (LAN-Betrieb, keine Domain), HSTS, Content-Security-Policy, Permissions-Policy ergänzt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
|
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
|
|
from app.models.nachfuellung import Nachfuellung
|
|
from app.models.objektposition import Objektposition
|
|
from app.services import historie as historie_service
|
|
|
|
|
|
class FehlbestandBereitsErledigtError(Exception):
|
|
"""Ein bereits erledigter Fehlbestand kann nicht erneut nachgefüllt werden."""
|
|
|
|
|
|
async def nachfuellen(
|
|
db: AsyncSession,
|
|
*,
|
|
fehlbestand: Fehlbestand,
|
|
menge: Decimal,
|
|
benutzer_id: int,
|
|
zustaendiger_server_id: int,
|
|
) -> tuple[Nachfuellung, Decimal]:
|
|
"""Vier-Kernbegriffe (Prompt 02.9): Nachfüllung ist die einzige Aktion, die
|
|
Objektposition.istmenge tatsächlich ändert. Deckt sowohl Sofort-Nachfüllung
|
|
während der Kontrolle (Karte 07) als auch spätere/externe Nachfüllung
|
|
(Prompt 02.5) ab - technisch derselbe Vorgang, nur anderer Zeitpunkt.
|
|
|
|
Überbestand durch Nachfüllung ist erlaubt (Prompt 21.9/E4): kein Fehler, nur
|
|
als Info im Rückgabewert `ueberbestand` gekennzeichnet.
|
|
|
|
Wichtig: `fehlbestand.istmenge` ist die zuletzt GEZÄHLTE Wahrheit (aus der
|
|
Kontrolle, die den Fehlbestand ausgelöst hat) - Objektposition.istmenge wird
|
|
hier auf den neuen Wert GESETZT, nicht unabhängig davon inkrementiert. Sonst
|
|
bliebe Objektposition.istmenge bei ihrem alten (ggf. 0/"nicht kontrolliert")
|
|
Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt -
|
|
"Ist-Menge wird auf Soll korrigiert" (Prompt 02.3) meint genau dieses Setzen.
|
|
"""
|
|
# Row-Lock gegen Lost-Update bei gleichzeitiger Nachfüllung derselben Position
|
|
# (z. B. zwei Stationen erfassen parallel, sql-expert-Review-Fund) - blockiert
|
|
# bis Transaktionsende (get_db committet/rollbacked nach dem Request), danach
|
|
# sieht der zweite Aufruf bereits die aktualisierte istmenge/status.
|
|
result = await db.execute(select(Fehlbestand).where(Fehlbestand.id == fehlbestand.id).with_for_update())
|
|
fehlbestand = result.scalar_one()
|
|
|
|
if fehlbestand.status == FehlbestandStatus.erledigt:
|
|
raise FehlbestandBereitsErledigtError()
|
|
|
|
neue_istmenge = fehlbestand.istmenge + menge
|
|
ueberbestand = max(Decimal(0), neue_istmenge - fehlbestand.sollmenge)
|
|
neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge)
|
|
|
|
result = await db.execute(
|
|
select(Objektposition)
|
|
.where(
|
|
Objektposition.objekt_id == fehlbestand.objekt_id,
|
|
Objektposition.material_id == fehlbestand.material_id,
|
|
)
|
|
.with_for_update()
|
|
)
|
|
objektposition = result.scalar_one_or_none()
|
|
if objektposition is not None:
|
|
objektposition.istmenge = neue_istmenge
|
|
|
|
fehlbestand.istmenge = neue_istmenge
|
|
fehlbestand.fehlmenge = neue_fehlmenge
|
|
wird_erledigt = neue_fehlmenge <= 0
|
|
if wird_erledigt:
|
|
fehlbestand.status = FehlbestandStatus.erledigt
|
|
fehlbestand.erledigt_am = datetime.now(timezone.utc)
|
|
else:
|
|
fehlbestand.status = FehlbestandStatus.nachgefuellt_teilweise
|
|
|
|
nachfuellung = Nachfuellung(
|
|
fehlbestand_id=fehlbestand.id,
|
|
objekt_id=fehlbestand.objekt_id,
|
|
material_id=fehlbestand.material_id,
|
|
menge=menge,
|
|
benutzer_id=benutzer_id,
|
|
zeitpunkt=datetime.now(timezone.utc),
|
|
)
|
|
db.add(nachfuellung)
|
|
await db.flush()
|
|
|
|
await historie_service.log(
|
|
db,
|
|
zustaendiger_server_id=zustaendiger_server_id,
|
|
benutzer_id=benutzer_id,
|
|
ereignistyp="nachfuellung_erfasst",
|
|
entitaet_typ="fehlbestand",
|
|
entitaet_id=fehlbestand.id,
|
|
neuer_wert={"menge": str(menge), "neue_istmenge": str(neue_istmenge), "ueberbestand": str(ueberbestand)},
|
|
)
|
|
|
|
if wird_erledigt:
|
|
await historie_service.log(
|
|
db,
|
|
zustaendiger_server_id=zustaendiger_server_id,
|
|
benutzer_id=benutzer_id,
|
|
ereignistyp="fehlbestand_erledigt",
|
|
entitaet_typ="fehlbestand",
|
|
entitaet_id=fehlbestand.id,
|
|
neuer_wert={"status": "erledigt"},
|
|
)
|
|
# U7 (Prompt 04.4): wird die Abweichung behoben, erledigt sich eine noch
|
|
# aktive Mindermengen-Genehmigung gemeinsam mit dem Fehlbestand.
|
|
result = await db.execute(
|
|
select(MindermengenGenehmigung).where(
|
|
MindermengenGenehmigung.fehlbestand_id == fehlbestand.id,
|
|
MindermengenGenehmigung.status == MindermengeStatus.aktiv,
|
|
)
|
|
)
|
|
genehmigung = result.scalar_one_or_none()
|
|
if genehmigung is not None:
|
|
genehmigung.status = MindermengeStatus.beendet_durch_erledigung
|
|
genehmigung.beendet_am = datetime.now(timezone.utc)
|
|
await historie_service.log(
|
|
db,
|
|
zustaendiger_server_id=zustaendiger_server_id,
|
|
benutzer_id=benutzer_id,
|
|
ereignistyp="mindermenge_beendet_durch_erledigung",
|
|
entitaet_typ="mindermengen_genehmigung",
|
|
entitaet_id=genehmigung.id,
|
|
neuer_wert={"status": "beendet_durch_erledigung"},
|
|
)
|
|
await db.flush()
|
|
return nachfuellung, ueberbestand
|