generated from coulomb/repo-seed
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>
121 lines
3.8 KiB
Python
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),
|
|
}
|