feat(nachbetrachtung): Abgabe-Checkliste, Dokumentation und Nachbetrachtung (WP-0009)

Vollständigkeitsprüfung mit Freigaben-Check, Abgabe dokumentieren mit
Nachweis-Upload, Nachbetrachtung mit Kickoff-Aufgabe (gewonnen) und
Alpine.js-gesteuerter Verlustanalyse (verloren). 5 Tests grün.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-05-11 15:09:38 +02:00
parent bca4eb15d8
commit a1cc317b3b
10 changed files with 583 additions and 12 deletions

View file

@ -1,3 +1,11 @@
from django.urls import path
urlpatterns = []
from . import abgabe_views as views
app_name = 'abgabe'
urlpatterns = [
path('', views.abgabe_checkliste, name='checkliste'),
path('dokumentieren/', views.abgabe_dokumentieren, name='dokumentieren'),
path('problem/', views.abgabe_problem, name='problem'),
]

View file

@ -0,0 +1,120 @@
from django import forms
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.core.models import Freigabe
from vergabe_teilnahme.apps.dokumente.models import Dokument
def abgabe_vollstaendigkeit(ausschreibung):
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(ausschreibung)
freigaben = Freigabe.objects.filter(content_type=ct, object_id=ausschreibung.pk)
def hat_freigabe(typ):
return freigaben.filter(freigabe_typ=typ, status='erteilt').exists()
return {
'dokumente_gesamt': Dokument.objects.filter(ausschreibung=ausschreibung).count(),
'dokumente_freigegeben': Dokument.objects.filter(
ausschreibung=ausschreibung, status__in=['freigegeben', 'final_abgegeben']).count(),
'teilnahme_freigabe': hat_freigabe('teilnahme'),
'preis_freigabe': hat_freigabe('preis'),
'recht_freigabe': hat_freigabe('recht'),
'abgabe_freigabe': hat_freigabe('abgabe'),
'entscheidung_getroffen': ausschreibung.teilnahmeentscheidung == 'teilnahme',
}
def abgabe_checkliste(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
vollstaendigkeit = abgabe_vollstaendigkeit(ausschreibung)
dokumente = Dokument.objects.filter(ausschreibung=ausschreibung).order_by('kategorie', 'bezeichnung')
punkte = [
('entscheidung_getroffen', 'Teilnahmeentscheidung getroffen'),
('teilnahme_freigabe', 'Teilnahme-Freigabe erteilt'),
('preis_freigabe', 'Preis-Freigabe erteilt'),
('recht_freigabe', 'Rechtliche Freigabe erteilt'),
('abgabe_freigabe', 'Abgabe-Freigabe erteilt'),
]
erfuellt = sum(1 for k, _ in punkte if vollstaendigkeit.get(k))
gesamt = len(punkte)
ctx = {
'ausschreibung': ausschreibung,
'vollstaendigkeit': vollstaendigkeit,
'punkte': [(label, vollstaendigkeit.get(key)) for key, label in punkte],
'erfuellt': erfuellt,
'gesamt': gesamt,
'dokumente': dokumente,
}
return render(request, 'nachbetrachtung/abgabe.html', ctx)
class AbgabeForm(forms.Form):
abgabe_zeitpunkt = forms.DateTimeField(
widget=forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-input'}),
label='Abgabezeitpunkt',
)
abgabe_plattform = forms.CharField(
max_length=200, required=False,
widget=forms.TextInput(attrs={'class': 'form-input'}),
label='Abgabeplattform',
)
verantwortlicher = forms.ModelChoiceField(
queryset=Mitarbeiter.objects.all(),
required=False,
widget=forms.Select(attrs={'class': 'form-select'}),
label='Verantwortlicher',
)
abgabenachweis = forms.FileField(
required=False,
widget=forms.FileInput(attrs={'class': 'form-input'}),
label='Abgabenachweis (Eingangsbestätigung)',
)
kommentar = forms.CharField(
required=False,
widget=forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
label='Kommentar',
)
def abgabe_dokumentieren(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
if request.method == 'POST':
form = AbgabeForm(request.POST, request.FILES)
if form.is_valid():
if form.cleaned_data.get('abgabenachweis'):
Dokument.objects.create(
ausschreibung=ausschreibung,
datei=form.cleaned_data['abgabenachweis'],
kategorie='abgabenachweis',
status='final_abgegeben',
finale_abgabeversion=True,
)
ausschreibung.status = 9
ausschreibung.save(update_fields=['status', 'geaendert_am'])
messages.success(request, 'Abgabe dokumentiert.')
return redirect('ausschreibungen:detail', pk=ausschreibung_id)
else:
form = AbgabeForm()
return render(request, 'nachbetrachtung/abgabe_formular.html', {
'form': form,
'ausschreibung': ausschreibung,
})
def abgabe_problem(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
if request.method == 'POST':
kommentar = request.POST.get('kommentar', '')
ausschreibung.status = 7
ausschreibung.save(update_fields=['status', 'geaendert_am'])
messages.warning(request, f'Problem bei Abgabe vermerkt. {kommentar}')
return redirect('ausschreibungen:nachbetrachtung:abgabe:checkliste',
ausschreibung_id=ausschreibung_id)
return render(request, 'nachbetrachtung/abgabe_problem.html', {'ausschreibung': ausschreibung})

View file

@ -1,3 +1,74 @@
from django.test import TestCase
import pytest
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
# Create your tests here.
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
from vergabe_teilnahme.apps.core.models import Freigabe
from .abgabe_views import abgabe_vollstaendigkeit
from .models import Nachbetrachtung
@pytest.mark.django_db
def test_abgabe_vollstaendigkeit_ohne_freigaben():
a = AusschreibungFactory(teilnahmeentscheidung='offen')
v = abgabe_vollstaendigkeit(a)
assert v['teilnahme_freigabe'] is False
assert v['preis_freigabe'] is False
assert v['recht_freigabe'] is False
assert v['abgabe_freigabe'] is False
assert v['entscheidung_getroffen'] is False
@pytest.mark.django_db
def test_abgabe_vollstaendigkeit_mit_freigabe():
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
user = Mitarbeiter.objects.create_user(username='pruefer', password='x')
a = AusschreibungFactory(teilnahmeentscheidung='teilnahme')
ct = ContentType.objects.get_for_model(a)
Freigabe.objects.create(
content_type=ct, object_id=a.pk,
freigabe_typ='preis', status='erteilt',
freigebende_person=user,
)
v = abgabe_vollstaendigkeit(a)
assert v['preis_freigabe'] is True
assert v['teilnahme_freigabe'] is False
assert v['entscheidung_getroffen'] is True
@pytest.mark.django_db
def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client):
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
a = AusschreibungFactory(status=9)
url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {
'ergebnis': 'gewonnen',
'verlustgruende': '[]',
})
assert response.status_code == 302
a.refresh_from_db()
assert a.status == 10
assert Aufgabe.objects.filter(ausschreibung=a, titel='Kickoff vorbereiten').exists()
@pytest.mark.django_db
def test_ergebnis_verloren_setzt_status_11(client):
a = AusschreibungFactory(status=9)
url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk})
client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'})
a.refresh_from_db()
assert a.status == 11
@pytest.mark.django_db
def test_verlustgruende_json_gespeichert(client):
import json
a = AusschreibungFactory(status=9)
url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk})
gruende = [{'grund': 'Zu teuer', 'kategorie': 'preis', 'verlaesslichkeit': 4}]
client.post(url, {'ergebnis': 'verloren', 'verlustgruende': json.dumps(gruende)})
nb = Nachbetrachtung.objects.get(ausschreibung=a)
assert nb.verlustgruende[0]['grund'] == 'Zu teuer'
assert nb.verlustgruende[0]['kategorie'] == 'preis'
assert nb.verlustgruende[0]['verlaesslichkeit'] == 4

View file

@ -1,2 +1,9 @@
from django.urls import path
urlpatterns = []
from . import views
app_name = 'nachbetrachtung'
urlpatterns = [
path('', views.nachbetrachtung_detail, name='detail'),
]

View file

@ -1,3 +1,102 @@
from django.shortcuts import render
import json
# Create your views here.
from django import forms
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from .models import Nachbetrachtung
class NachbetrachtungForm(forms.ModelForm):
class Meta:
model = Nachbetrachtung
fields = [
'ergebnis', 'zuschlagsdatum', 'projektverantwortlicher',
'abgegebene_unterlagen', 'abgegebene_preise',
'ausschlaggebende_zuschlagsmerkmale', 'lessons_learned',
'empfehlungen', 'wiederverwendbare_erkenntnisse_markiert',
]
widgets = {
'ergebnis': forms.RadioSelect(attrs={'class': 'mr-2'}),
'zuschlagsdatum': forms.DateInput(attrs={'type': 'date', 'class': 'form-input'}),
'projektverantwortlicher': forms.Select(attrs={'class': 'form-select'}),
'abgegebene_unterlagen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'abgegebene_preise': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'ausschlaggebende_zuschlagsmerkmale': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'lessons_learned': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}),
'empfehlungen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['projektverantwortlicher'].queryset = Mitarbeiter.objects.all()
self.fields['projektverantwortlicher'].required = False
self.fields['zuschlagsdatum'].required = False
def nachbetrachtung_detail(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
nb, _ = Nachbetrachtung.objects.get_or_create(ausschreibung=ausschreibung)
if request.method == 'POST':
form = NachbetrachtungForm(request.POST, instance=nb)
verlustgruende_raw = request.POST.get('verlustgruende', '[]')
try:
verlustgruende = json.loads(verlustgruende_raw)
except (json.JSONDecodeError, ValueError):
verlustgruende = []
if form.is_valid():
nb = form.save(commit=False)
nb.verlustgruende = verlustgruende
nb.save()
ergebnis = form.cleaned_data['ergebnis']
if ergebnis == 'gewonnen':
ausschreibung.status = 10
ausschreibung.save(update_fields=['status', 'geaendert_am'])
aufgabe, created = Aufgabe.objects.get_or_create(
ausschreibung=ausschreibung,
titel='Kickoff vorbereiten',
defaults={
'typ': 'fachlich',
'prioritaet': 1,
'verantwortlicher': form.cleaned_data.get('projektverantwortlicher'),
'beschreibung': (
f'Kickoff für {ausschreibung.titel}. '
'Angebotsumfang und Annahmen übergeben.'
),
},
)
if created:
pv = form.cleaned_data.get('projektverantwortlicher')
pv_name = str(pv) if pv else ''
messages.success(request, f'Kickoff-Aufgabe erstellt für {pv_name}.')
elif ergebnis == 'verloren':
ausschreibung.status = 11
ausschreibung.save(update_fields=['status', 'geaendert_am'])
messages.success(request, 'Nachbetrachtung gespeichert.')
return redirect('ausschreibungen:nachbetrachtung:detail',
ausschreibung_id=ausschreibung_id)
else:
form = NachbetrachtungForm(instance=nb)
kickoff_aufgabe = None
if nb.ergebnis == 'gewonnen':
kickoff_aufgabe = Aufgabe.objects.filter(
ausschreibung=ausschreibung, titel='Kickoff vorbereiten'
).first()
ctx = {
'ausschreibung': ausschreibung,
'nachbetrachtung': nb,
'form': form,
'kickoff_aufgabe': kickoff_aufgabe,
'verlustgruende_json': json.dumps(nb.verlustgruende),
}
return render(request, 'nachbetrachtung/detail.html', ctx)