Files
vergabe-teilnahme/vergabe_teilnahme/apps/core/services.py
tegwick e4eb5bc368 feat(WP-0014): Aufgaben-Phasenzuordnung und Fertigstellungs-Scores
Aufgabe bekommt ein phase-Feld (1–8). aufgaben_score()-Helper in
core/services.py berechnet abgeschlossen/total/score_pct für jedes
QuerySet. Score-Spalten in Ausschreibungen-Liste, Lose-Liste und
Ausschreibungs-Detail; per-Phase-Scores in der Seitenleisten-Navigation.
Phasenfilter in Aufgaben-Liste. 68 Tests grün.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:47:05 +02:00

121 lines
3.8 KiB
Python

from datetime import date
from decimal import Decimal
PHASEN = [
(1, 'Recherche & Unterlagen'),
(2, 'Teilnahmeentscheidung'),
(3, 'Detaillierte Durchsicht'),
(4, 'Bieterfragen & Klärung'),
(5, 'Preismodell'),
(6, 'Unterlagen finalisieren'),
(7, 'Abgabe'),
(8, 'Zuschlag / Nachbetrachtung'),
]
# Maps Ausschreibung.status integer to phase number
STATUS_TO_PHASE = {
1: 1, 2: 1, # Recherche
3: 2, # Teilnahmeentscheidung
4: 3, 5: 3, # Durchsicht
6: 4, 7: 4, # Bieterfragen
8: 5, # Preise
9: 6, # Finalisierung
10: 7, 11: 7, # Abgabe
12: 8, 13: 8, # Nachbetrachtung
}
def aufgaben_score(qs):
total = qs.count()
abgeschlossen = qs.filter(status__in=['erledigt', 'verworfen']).count()
pct = round(abgeschlossen / total * 100) if total else 0
return {'total': total, 'abgeschlossen': abgeschlossen, 'score_pct': pct}
def build_phase_nav(ausschreibung, current_url=''):
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1)
base = f'/ausschreibungen/{ausschreibung.pk}'
phase_urls = {
1: f'{base}/',
2: f'{base}/entscheidung/',
3: f'{base}/lose/anforderungen/',
4: f'{base}/bieterfragen/',
5: f'{base}/preise/',
6: f'{base}/dokumente/',
7: f'{base}/abgabe/',
8: f'{base}/nachbetrachtung/',
}
aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung)
phasen_stats = {
num: aufgaben_score(aufgaben_qs.filter(phase=num))
for num, _ in PHASEN
}
return [
{
'nummer': num,
'name': name,
'url': phase_urls[num],
'aktiv': num == aktuelle_phase,
'erledigt': num < aktuelle_phase,
'warnung': False,
'aufgaben_score': phasen_stats[num],
}
for num, name in PHASEN
]
def get_deadline_warnings(ausschreibung):
"""Gibt Liste von Warnungen für nahende Fristen zurück."""
warnings = []
heute = date.today()
if ausschreibung.bieterfragen_bis:
delta = (ausschreibung.bieterfragen_bis - heute).days
if delta <= 3:
warnings.append({
'typ': 'bieterfragen',
'tage': delta,
'farbe': 'red' if delta <= 1 else 'amber',
})
if ausschreibung.abgabe_bis:
abgabe_date = (
ausschreibung.abgabe_bis.date()
if hasattr(ausschreibung.abgabe_bis, 'date')
else ausschreibung.abgabe_bis
)
delta = (abgabe_date - heute).days
if delta <= 14:
warnings.append({
'typ': 'abgabe',
'tage': delta,
'farbe': 'red' if delta <= 3 else 'amber',
})
return warnings
def gewichteter_durchschnitt(preispunkte, feld='einzelpreis'):
"""Berechnet gewichteten Durchschnitt für Preispunkte.
Punkte mit Gewicht 0,0 werden ausgeschlossen.
Gibt None zurück wenn keine verwertbaren Punkte vorhanden.
"""
relevante = [
p for p in preispunkte
if getattr(p, feld) is not None and p.vergleichsgewicht > 0
]
if not relevante:
return None
summe_gewichte = sum(p.vergleichsgewicht for p in relevante)
if summe_gewichte == 0:
return None
summe = sum(getattr(p, feld) * p.vergleichsgewicht for p in relevante)
werte = [getattr(p, feld) for p in relevante]
return {
'wert': summe / summe_gewichte,
'summe_gewichte': summe_gewichte,
'anzahl': len(relevante),
'minimum': min(werte),
'maximum': max(werte),
'ungewichtet': sum(werte) / len(werte),
}