generated from coulomb/repo-seed
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>
This commit is contained in:
@@ -7,13 +7,14 @@ class AufgabeForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Aufgabe
|
model = Aufgabe
|
||||||
fields = [
|
fields = [
|
||||||
'titel', 'beschreibung', 'typ', 'prioritaet', 'frist',
|
'titel', 'beschreibung', 'typ', 'phase', 'prioritaet', 'frist',
|
||||||
'verantwortlicher', 'los', 'anforderung', 'bieterfrage',
|
'verantwortlicher', 'los', 'anforderung', 'bieterfrage',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
|
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
|
||||||
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||||
'typ': forms.Select(attrs={'class': 'form-input'}),
|
'typ': forms.Select(attrs={'class': 'form-input'}),
|
||||||
|
'phase': forms.Select(attrs={'class': 'form-input'}),
|
||||||
'prioritaet': forms.RadioSelect(),
|
'prioritaet': forms.RadioSelect(),
|
||||||
'frist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
'frist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||||
'verantwortlicher': forms.Select(attrs={'class': 'form-input'}),
|
'verantwortlicher': forms.Select(attrs={'class': 'form-input'}),
|
||||||
@@ -30,6 +31,7 @@ class AufgabeForm(forms.ModelForm):
|
|||||||
self.fields['anforderung'].queryset = Anforderung.objects.filter(ausschreibung=ausschreibung)
|
self.fields['anforderung'].queryset = Anforderung.objects.filter(ausschreibung=ausschreibung)
|
||||||
self.fields['bieterfrage'].queryset = Bieterfrage.objects.filter(ausschreibung=ausschreibung)
|
self.fields['bieterfrage'].queryset = Bieterfrage.objects.filter(ausschreibung=ausschreibung)
|
||||||
self.fields['beschreibung'].required = False
|
self.fields['beschreibung'].required = False
|
||||||
|
self.fields['phase'].required = False
|
||||||
self.fields['frist'].required = False
|
self.fields['frist'].required = False
|
||||||
self.fields['verantwortlicher'].required = False
|
self.fields['verantwortlicher'].required = False
|
||||||
self.fields['los'].required = False
|
self.fields['los'].required = False
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-13 23:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('aufgaben', '0002_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='aufgabe',
|
||||||
|
name='phase',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Recherche & Unterlagen'), (2, 'Teilnahmeentscheidung'), (3, 'Detaillierte Durchsicht'), (4, 'Bieterfragen & Klärung'), (5, 'Preismodell'), (6, 'Unterlagen finalisieren'), (7, 'Abgabe'), (8, 'Zuschlag / Nachbetrachtung')], null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -26,6 +26,16 @@ class Aufgabe(FlexibleModel):
|
|||||||
('ueberfaellig', 'Überfällig'),
|
('ueberfaellig', 'Überfällig'),
|
||||||
]
|
]
|
||||||
PRIORITAET_CHOICES = [(1, 'Hoch'), (2, 'Mittel'), (3, 'Niedrig')]
|
PRIORITAET_CHOICES = [(1, 'Hoch'), (2, 'Mittel'), (3, 'Niedrig')]
|
||||||
|
PHASE_CHOICES = [
|
||||||
|
(1, 'Recherche & Unterlagen'),
|
||||||
|
(2, 'Teilnahmeentscheidung'),
|
||||||
|
(3, 'Detaillierte Durchsicht'),
|
||||||
|
(4, 'Bieterfragen & Klärung'),
|
||||||
|
(5, 'Preismodell'),
|
||||||
|
(6, 'Unterlagen finalisieren'),
|
||||||
|
(7, 'Abgabe'),
|
||||||
|
(8, 'Zuschlag / Nachbetrachtung'),
|
||||||
|
]
|
||||||
|
|
||||||
ausschreibung = models.ForeignKey(
|
ausschreibung = models.ForeignKey(
|
||||||
'ausschreibungen.Ausschreibung', on_delete=models.CASCADE, related_name='aufgaben'
|
'ausschreibungen.Ausschreibung', on_delete=models.CASCADE, related_name='aufgaben'
|
||||||
@@ -55,6 +65,7 @@ class Aufgabe(FlexibleModel):
|
|||||||
'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True
|
'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True
|
||||||
)
|
)
|
||||||
ergebnis = models.TextField(blank=True)
|
ergebnis = models.TextField(blank=True)
|
||||||
|
phase = models.PositiveSmallIntegerField(choices=PHASE_CHOICES, null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['prioritaet', 'frist']
|
ordering = ['prioritaet', 'frist']
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ def aufgaben_liste(request, ausschreibung_id=None):
|
|||||||
if typ_filter:
|
if typ_filter:
|
||||||
qs = qs.filter(typ=typ_filter)
|
qs = qs.filter(typ=typ_filter)
|
||||||
|
|
||||||
|
phase_filter = request.GET.get('phase')
|
||||||
|
if phase_filter:
|
||||||
|
qs = qs.filter(phase=phase_filter)
|
||||||
|
|
||||||
verantwortlicher_filter = request.GET.get('verantwortlicher')
|
verantwortlicher_filter = request.GET.get('verantwortlicher')
|
||||||
if verantwortlicher_filter:
|
if verantwortlicher_filter:
|
||||||
qs = qs.filter(verantwortlicher=verantwortlicher_filter)
|
qs = qs.filter(verantwortlicher=verantwortlicher_filter)
|
||||||
@@ -67,6 +71,8 @@ def aufgaben_liste(request, ausschreibung_id=None):
|
|||||||
'mitarbeiter': Mitarbeiter.objects.all(),
|
'mitarbeiter': Mitarbeiter.objects.all(),
|
||||||
'current_status': status_filter or '',
|
'current_status': status_filter or '',
|
||||||
'current_typ': typ_filter or '',
|
'current_typ': typ_filter or '',
|
||||||
|
'phase_choices': Aufgabe.PHASE_CHOICES,
|
||||||
|
'current_phase': phase_filter or '',
|
||||||
'current_verantwortlicher': verantwortlicher_filter or '',
|
'current_verantwortlicher': verantwortlicher_filter or '',
|
||||||
'breadcrumbs': breadcrumbs,
|
'breadcrumbs': breadcrumbs,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ def ausschreibung_liste(request):
|
|||||||
if bid_manager_filter:
|
if bid_manager_filter:
|
||||||
qs = qs.filter(bid_manager=bid_manager_filter)
|
qs = qs.filter(bid_manager=bid_manager_filter)
|
||||||
|
|
||||||
qs = qs.select_related('bid_manager').order_by('-geaendert_am')
|
from django.db.models import Count, Q
|
||||||
|
qs = qs.select_related('bid_manager').annotate(
|
||||||
|
aufgaben_total=Count('aufgaben', distinct=True),
|
||||||
|
aufgaben_erledigt=Count(
|
||||||
|
'aufgaben',
|
||||||
|
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
).order_by('-geaendert_am')
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
'ausschreibungen': qs,
|
'ausschreibungen': qs,
|
||||||
@@ -100,7 +108,8 @@ def ausschreibung_neu(request):
|
|||||||
|
|
||||||
|
|
||||||
def ausschreibung_detail(request, pk):
|
def ausschreibung_detail(request, pk):
|
||||||
from vergabe_teilnahme.apps.core.services import build_phase_nav, get_deadline_warnings
|
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||||
|
from vergabe_teilnahme.apps.core.services import aufgaben_score, build_phase_nav, get_deadline_warnings
|
||||||
|
|
||||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||||
ctx = {
|
ctx = {
|
||||||
@@ -108,6 +117,7 @@ def ausschreibung_detail(request, pk):
|
|||||||
'ausschreibung_id': pk,
|
'ausschreibung_id': pk,
|
||||||
'phases': build_phase_nav(a),
|
'phases': build_phase_nav(a),
|
||||||
'warnungen': get_deadline_warnings(a),
|
'warnungen': get_deadline_warnings(a),
|
||||||
|
'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)),
|
||||||
'breadcrumbs': [
|
'breadcrumbs': [
|
||||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||||
{'label': a.titel, 'url': None},
|
{'label': a.titel, 'url': None},
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ STATUS_TO_PHASE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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=''):
|
def build_phase_nav(ausschreibung, current_url=''):
|
||||||
|
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||||
aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1)
|
aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1)
|
||||||
base = f'/ausschreibungen/{ausschreibung.pk}'
|
base = f'/ausschreibungen/{ausschreibung.pk}'
|
||||||
phase_urls = {
|
phase_urls = {
|
||||||
@@ -38,6 +46,11 @@ def build_phase_nav(ausschreibung, current_url=''):
|
|||||||
7: f'{base}/abgabe/',
|
7: f'{base}/abgabe/',
|
||||||
8: f'{base}/nachbetrachtung/',
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
'nummer': num,
|
'nummer': num,
|
||||||
@@ -46,6 +59,7 @@ def build_phase_nav(ausschreibung, current_url=''):
|
|||||||
'aktiv': num == aktuelle_phase,
|
'aktiv': num == aktuelle_phase,
|
||||||
'erledigt': num < aktuelle_phase,
|
'erledigt': num < aktuelle_phase,
|
||||||
'warnung': False,
|
'warnung': False,
|
||||||
|
'aufgaben_score': phasen_stats[num],
|
||||||
}
|
}
|
||||||
for num, name in PHASEN
|
for num, name in PHASEN
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ def _ausschreibung_breadcrumbs(ausschreibung, *extra):
|
|||||||
# ─── Lose ────────────────────────────────────────────────────────────────────
|
# ─── Lose ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def lose_liste(request, ausschreibung_id):
|
def lose_liste(request, ausschreibung_id):
|
||||||
|
from django.db.models import Count, Q
|
||||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||||
lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer')
|
lose = Los.objects.filter(ausschreibung=ausschreibung).annotate(
|
||||||
|
aufgaben_total=Count('aufgaben', distinct=True),
|
||||||
|
aufgaben_erledigt=Count(
|
||||||
|
'aufgaben',
|
||||||
|
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
).order_by('losnummer')
|
||||||
return render(request, 'lose/liste.html', {
|
return render(request, 'lose/liste.html', {
|
||||||
'ausschreibung': ausschreibung,
|
'ausschreibung': ausschreibung,
|
||||||
'lose': lose,
|
'lose': lose,
|
||||||
|
|||||||
@@ -32,6 +32,15 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Phase</label>
|
||||||
|
<select name="phase" class="form-input text-xs">
|
||||||
|
<option value="">Alle Phasen</option>
|
||||||
|
{% for val, label in phase_choices %}
|
||||||
|
<option value="{{ val }}"{% if current_phase == val|stringformat:"s" %} selected{% endif %}>{{ val }}. {{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Verantwortlich</label>
|
<label class="form-label">Verantwortlich</label>
|
||||||
<select name="verantwortlicher" class="form-input text-xs">
|
<select name="verantwortlicher" class="form-input text-xs">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<p class="text-sm text-slate-500 mt-0.5">{{ ausschreibung.ausschreiber }}</p>
|
<p class="text-sm text-slate-500 mt-0.5">{{ ausschreibung.ausschreiber }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% with s=aufgaben_score %}{% if s.total %}<span class="text-xs text-slate-500">Aufgaben: {{ s.abgeschlossen }}/{{ s.total }} ({{ s.score_pct }} %)</span>{% endif %}{% endwith %}
|
||||||
{% include "ausschreibungen/partials/status_widget.html" %}
|
{% include "ausschreibungen/partials/status_widget.html" %}
|
||||||
<a href="{% url 'ausschreibungen:bearbeiten' ausschreibung.pk %}" class="btn-ghost text-xs">Bearbeiten</a>
|
<a href="{% url 'ausschreibungen:bearbeiten' ausschreibung.pk %}" class="btn-ghost text-xs">Bearbeiten</a>
|
||||||
<a href="{% url 'ausschreibungen:archivieren' ausschreibung.pk %}" class="btn-ghost text-xs text-slate-400">Archivieren</a>
|
<a href="{% url 'ausschreibungen:archivieren' ausschreibung.pk %}" class="btn-ghost text-xs text-slate-400">Archivieren</a>
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
{% render_field ausschreibung "ausschreiber" "Ausschreiber" %}
|
{% render_field ausschreibung "ausschreiber" "Ausschreiber" %}
|
||||||
{% render_field ausschreibung "vergabeart" "Vergabeart" %}
|
{% render_field ausschreibung "vergabeart" "Vergabeart" %}
|
||||||
{% render_field ausschreibung "rechtsgrundlage" "Rechtsgrundlage" %}
|
{% render_field ausschreibung "rechtsgrundlage" "Rechtsgrundlage" %}
|
||||||
|
{% render_field ausschreibung "rechtsgrundlage_details" "Rechtsgrundlage Details" %}
|
||||||
{% render_field ausschreibung "vergabenummer" "Vergabenummer" %}
|
{% render_field ausschreibung "vergabenummer" "Vergabenummer" %}
|
||||||
{% render_field ausschreibung "vergabeplattform" "Plattform" %}
|
{% render_field ausschreibung "vergabeplattform" "Plattform" %}
|
||||||
{% render_field ausschreibung "branche" "Branche" %}
|
{% render_field ausschreibung "branche" "Branche" %}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Ausschreiber</th>
|
<th class="text-left px-4 py-2 font-medium text-slate-600">Ausschreiber</th>
|
||||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Titel</th>
|
<th class="text-left px-4 py-2 font-medium text-slate-600">Titel</th>
|
||||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Volumen (gesch.)</th>
|
<th class="text-left px-4 py-2 font-medium text-slate-600">Volumen (gesch.)</th>
|
||||||
|
<th class="text-left px-4 py-2 font-medium text-slate-600">Aufgaben</th>
|
||||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Status</th>
|
<th class="text-left px-4 py-2 font-medium text-slate-600">Status</th>
|
||||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Abgabe</th>
|
<th class="text-left px-4 py-2 font-medium text-slate-600">Abgabe</th>
|
||||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Bid Manager</th>
|
<th class="text-left px-4 py-2 font-medium text-slate-600">Bid Manager</th>
|
||||||
@@ -24,6 +25,12 @@
|
|||||||
<td class="px-4 py-2 text-slate-600 text-right whitespace-nowrap">
|
<td class="px-4 py-2 text-slate-600 text-right whitespace-nowrap">
|
||||||
{% if a.geschaetztes_volumen %}{{ a.geschaetztes_volumen|floatformat:0 }} €{% else %}—{% endif %}
|
{% if a.geschaetztes_volumen %}{{ a.geschaetztes_volumen|floatformat:0 }} €{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-2 text-slate-600 whitespace-nowrap">
|
||||||
|
{% if a.aufgaben_total %}
|
||||||
|
{{ a.aufgaben_erledigt }}/{{ a.aufgaben_total }}
|
||||||
|
({% widthratio a.aufgaben_erledigt a.aufgaben_total 100 %} %)
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
{% status_badge a.status a.get_status_display %}
|
{% status_badge a.status a.get_status_display %}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<th class="pb-2 pr-4">Nr.</th>
|
<th class="pb-2 pr-4">Nr.</th>
|
||||||
<th class="pb-2 pr-4">Titel</th>
|
<th class="pb-2 pr-4">Titel</th>
|
||||||
<th class="pb-2 pr-4">Zuständig</th>
|
<th class="pb-2 pr-4">Zuständig</th>
|
||||||
|
<th class="pb-2 pr-4">Aufgaben</th>
|
||||||
<th class="pb-2 pr-4">Teilnahme</th>
|
<th class="pb-2 pr-4">Teilnahme</th>
|
||||||
<th class="pb-2"></th>
|
<th class="pb-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
class="text-brand-600 hover:underline font-medium">{{ los.lostitel }}</a>
|
class="text-brand-600 hover:underline font-medium">{{ los.lostitel }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 pr-4 text-slate-600">{{ los.zustaendiger|default:"—" }}</td>
|
<td class="py-2 pr-4 text-slate-600">{{ los.zustaendiger|default:"—" }}</td>
|
||||||
|
<td class="py-2 pr-4 text-slate-600 whitespace-nowrap">
|
||||||
|
{% if los.aufgaben_total %}
|
||||||
|
{{ los.aufgaben_erledigt }}/{{ los.aufgaben_total }}
|
||||||
|
({% widthratio los.aufgaben_erledigt los.aufgaben_total 100 %} %)
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
<td class="py-2 pr-4">
|
<td class="py-2 pr-4">
|
||||||
{% if los.teilnahme is None %}
|
{% if los.teilnahme is None %}
|
||||||
<span class="text-slate-400 text-xs">Offen</span>
|
<span class="text-slate-400 text-xs">Offen</span>
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
{{ phase.nummer }}
|
{{ phase.nummer }}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">{{ phase.name }}</span>
|
<span class="truncate">{{ phase.name }}</span>
|
||||||
{% if phase.warnung %}
|
{% if phase.aufgaben_score.total %}
|
||||||
|
<span class="ml-auto text-slate-400 text-xs shrink-0">{{ phase.aufgaben_score.abgeschlossen }}/{{ phase.aufgaben_score.total }}</span>
|
||||||
|
{% elif phase.warnung %}
|
||||||
<span class="ml-auto text-amber-500 text-xs shrink-0">⚠</span>
|
<span class="ml-auto text-amber-500 text-xs shrink-0">⚠</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
242
workplans/WP-0014-aufgaben-lose-scores.md
Normal file
242
workplans/WP-0014-aufgaben-lose-scores.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
---
|
||||||
|
id: WP-0014
|
||||||
|
title: Aufgaben-Phasenzuordnung und Fertigstellungs-Scores in Übersichten
|
||||||
|
status: done
|
||||||
|
phase: 14-of-n
|
||||||
|
created: "2026-05-14"
|
||||||
|
depends_on: WP-0013
|
||||||
|
---
|
||||||
|
|
||||||
|
# WP-0014 — Aufgaben-Phasenzuordnung und Scores
|
||||||
|
|
||||||
|
Aufgaben bekommen ein Phasenfeld (1–8). In den Übersichten von
|
||||||
|
Ausschreibungen, Losen und der Phasennavigation wird jeweils die Anzahl
|
||||||
|
der verknüpften Aufgaben sowie ein Fertigstellungs-Score angezeigt
|
||||||
|
(z.B. "8/10 (80%)"). Status "erledigt" und "verworfen" gelten als
|
||||||
|
abgeschlossen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T01
|
||||||
|
title: Aufgabe.phase — Feld + Migration
|
||||||
|
status: done
|
||||||
|
|
||||||
|
`apps/aufgaben/models.py` — Feld ergänzen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PHASE_CHOICES = [
|
||||||
|
(1, 'Recherche & Unterlagen'),
|
||||||
|
(2, 'Teilnahmeentscheidung'),
|
||||||
|
(3, 'Detaillierte Durchsicht'),
|
||||||
|
(4, 'Bieterfragen & Klärung'),
|
||||||
|
(5, 'Preismodell'),
|
||||||
|
(6, 'Unterlagen finalisieren'),
|
||||||
|
(7, 'Abgabe'),
|
||||||
|
(8, 'Zuschlag / Nachbetrachtung'),
|
||||||
|
]
|
||||||
|
phase = models.PositiveSmallIntegerField(
|
||||||
|
choices=PHASE_CHOICES, null=True, blank=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/aufgaben/forms.py` — `phase` zu den Feldern hinzufügen,
|
||||||
|
Widget: `forms.Select(attrs={'class': 'form-input'})`.
|
||||||
|
|
||||||
|
Migration erstellen und ausführen.
|
||||||
|
```
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T02
|
||||||
|
title: aufgaben_score() Helper in core/services.py
|
||||||
|
status: done
|
||||||
|
|
||||||
|
Wiederverwendbare Hilfsfunktion, die für ein beliebiges Aufgaben-QuerySet
|
||||||
|
den Score berechnet:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def aufgaben_score(qs):
|
||||||
|
"""
|
||||||
|
Gibt dict zurück: total, abgeschlossen, score_pct
|
||||||
|
Abgeschlossen = status in ['erledigt', 'verworfen']
|
||||||
|
"""
|
||||||
|
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}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein Template-Tag — die Views rufen die Funktion auf und übergeben
|
||||||
|
das Ergebnis direkt im Kontext.
|
||||||
|
```
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T03
|
||||||
|
title: Aufgaben-Score in Ausschreibungen-Liste
|
||||||
|
status: done
|
||||||
|
|
||||||
|
`apps/ausschreibungen/views.py` — in der Liste-View das QuerySet mit
|
||||||
|
`annotate` ergänzen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
qs = qs.annotate(
|
||||||
|
aufgaben_total=Count('aufgaben', distinct=True),
|
||||||
|
aufgaben_erledigt=Count(
|
||||||
|
'aufgaben',
|
||||||
|
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`templates/ausschreibungen/liste_partial.html` — neue Spalte "Aufgaben"
|
||||||
|
nach der Volumen-Spalte:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<th>Aufgaben</th>
|
||||||
|
...
|
||||||
|
<td>
|
||||||
|
{% if a.aufgaben_total %}
|
||||||
|
{{ a.aufgaben_erledigt }}/{{ a.aufgaben_total }}
|
||||||
|
({% widthratio a.aufgaben_erledigt a.aufgaben_total 100 %} %)
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T04
|
||||||
|
title: Aufgaben-Score in Ausschreibungs-Detail
|
||||||
|
status: done
|
||||||
|
|
||||||
|
`apps/ausschreibungen/views.py` — in `ausschreibung_detail` den Score
|
||||||
|
berechnen und übergeben:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from vergabe_teilnahme.apps.core.services import aufgaben_score
|
||||||
|
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||||
|
ctx['aufgaben_score'] = aufgaben_score(
|
||||||
|
Aufgabe.objects.filter(ausschreibung=a)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`templates/ausschreibungen/detail.html` — Aufgaben-Score-Badge im
|
||||||
|
Kopfbereich (neben Phase-Status), z.B.:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% with s=aufgaben_score %}
|
||||||
|
{% if s.total %}
|
||||||
|
<span class="text-xs text-slate-500">
|
||||||
|
Aufgaben: {{ s.abgeschlossen }}/{{ s.total }} ({{ s.score_pct }} %)
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T05
|
||||||
|
title: Aufgaben-Score in Lose-Liste
|
||||||
|
status: done
|
||||||
|
|
||||||
|
`apps/lose/views.py` — in `lose_liste` das Lose-QuerySet annotieren:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
lose = Los.objects.filter(ausschreibung=a).annotate(
|
||||||
|
aufgaben_total=Count('aufgaben', distinct=True),
|
||||||
|
aufgaben_erledigt=Count(
|
||||||
|
'aufgaben',
|
||||||
|
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`templates/lose/partials/los_row.html` und `templates/lose/liste.html`
|
||||||
|
— Spalte "Aufgaben" mit Score ergänzen.
|
||||||
|
```
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T06
|
||||||
|
title: Aufgaben-Score in Phase-Navigation
|
||||||
|
status: done
|
||||||
|
|
||||||
|
`apps/core/services.py` — `build_phase_nav` erweitern: für jede Phase
|
||||||
|
die zugehörigen Aufgaben der Ausschreibung zählen und den Score
|
||||||
|
berechnen. Nur sinnvoll wenn Aufgaben vorhanden sind.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
|
||||||
|
aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung)
|
||||||
|
phasen_stats = {
|
||||||
|
num: aufgaben_score(aufgaben_qs.filter(phase=num))
|
||||||
|
for num, _ in PHASEN
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeden Phase-Dict um `aufgaben_score`-Werte erweitern.
|
||||||
|
|
||||||
|
`templates/partials/phase_nav.html` — pro Phaseneintrag einen
|
||||||
|
Fortschrittsbalken oder Score-Text anzeigen wenn `aufgaben_total > 0`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% if phase.aufgaben_score.total %}
|
||||||
|
<span class="text-xs text-slate-400">
|
||||||
|
{{ phase.aufgaben_score.abgeschlossen }}/{{ phase.aufgaben_score.total }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T07
|
||||||
|
title: Phasen-Filter in Aufgaben-Liste
|
||||||
|
status: done
|
||||||
|
|
||||||
|
`apps/aufgaben/views.py` — Phasenfilter ergänzen (analog zum
|
||||||
|
bestehenden Status- und Typ-Filter):
|
||||||
|
|
||||||
|
```python
|
||||||
|
phase_filter = request.GET.get('phase')
|
||||||
|
if phase_filter:
|
||||||
|
qs = qs.filter(phase=phase_filter)
|
||||||
|
```
|
||||||
|
|
||||||
|
`templates/aufgaben/liste.html` — Phasen-Dropdown im Filterbereich:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Phase</label>
|
||||||
|
<select name="phase" class="form-input text-xs">
|
||||||
|
<option value="">Alle Phasen</option>
|
||||||
|
{% for val, label in phase_choices %}
|
||||||
|
<option value="{{ val }}"{% if current_phase == val|stringformat:"s" %} selected{% endif %}>
|
||||||
|
{{ val }}. {{ label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Kontext: `phase_choices=Aufgabe.PHASE_CHOICES`, `current_phase=phase_filter or ''`.
|
||||||
|
```
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: WP-0014-T08
|
||||||
|
title: Tests + Smoke-Check aller geänderten Seiten
|
||||||
|
status: done
|
||||||
|
|
||||||
|
pytest ausführen — alle 68 bestehenden Tests müssen grün bleiben.
|
||||||
|
|
||||||
|
Zusätzlich per Django-Test-Client prüfen:
|
||||||
|
- Ausschreibungen-Liste: Status 200, enthält Score-Spalte
|
||||||
|
- Lose-Liste: Status 200
|
||||||
|
- Aufgaben-Liste mit ?phase=1: Status 200
|
||||||
|
- Ausschreibungs-Detail: Status 200
|
||||||
|
- Aufgabe anlegen mit phase=3: wird korrekt gespeichert
|
||||||
|
```
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user