Workplan Preise has been implemented

This commit is contained in:
tegwick 2026-05-11 12:23:20 +02:00
parent b99f45c7af
commit 6991b0989e
12 changed files with 878 additions and 11 deletions

View file

@ -0,0 +1,59 @@
from decimal import Decimal
from django import forms
from vergabe_teilnahme.apps.lose.models import Los
from vergabe_teilnahme.apps.partner.models import Subunternehmer
from .models import Preispunkt
NUMBER_ATTRS = {'class': 'form-input', 'step': '0.01'}
WEIGHT_ATTRS = {'class': 'form-input', 'step': '0.1', 'min': '0.0', 'max': '2.0', 'type': 'number'}
class PreispunktForm(forms.ModelForm):
class Meta:
model = Preispunkt
fields = [
'leistungstyp', 'konkrete_leistung', 'mengeneinheit', 'menge',
'einzelpreis', 'gesamtpreis', 'waehrung', 'preisstand',
'wiederkehrend', 'laufzeitbezug',
'subunternehmeranteil', 'subunternehmer',
'vergleichsgewicht', 'gewichtungsbegruendung', 'kommentar', 'los',
]
widgets = {
'leistungstyp': forms.TextInput(attrs={'class': 'form-input', 'list': 'leistungstypen-list'}),
'konkrete_leistung': forms.TextInput(attrs={'class': 'form-input'}),
'mengeneinheit': forms.TextInput(attrs={'class': 'form-input'}),
'menge': forms.NumberInput(attrs={**NUMBER_ATTRS, 'id': 'id_menge'}),
'einzelpreis': forms.NumberInput(attrs={**NUMBER_ATTRS, 'id': 'id_einzelpreis'}),
'gesamtpreis': forms.NumberInput(attrs={**NUMBER_ATTRS, 'id': 'id_gesamtpreis'}),
'waehrung': forms.TextInput(attrs={'class': 'form-input'}),
'preisstand': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
'laufzeitbezug': forms.TextInput(attrs={'class': 'form-input'}),
'vergleichsgewicht': forms.NumberInput(attrs=WEIGHT_ATTRS),
'gewichtungsbegruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
'kommentar': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
'subunternehmer': forms.Select(attrs={'class': 'form-select'}),
'los': forms.Select(attrs={'class': 'form-select'}),
}
def __init__(self, *args, ausschreibung=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields['vergleichsgewicht'].initial = Decimal('1.0')
for field in ['menge', 'gesamtpreis', 'preisstand', 'laufzeitbezug',
'subunternehmeranteil', 'subunternehmer',
'gewichtungsbegruendung', 'kommentar', 'los']:
self.fields[field].required = False
if ausschreibung is not None:
self.fields['los'].queryset = Los.objects.filter(ausschreibung=ausschreibung)
else:
self.fields['los'].queryset = Los.objects.none()
self.fields['subunternehmer'].queryset = Subunternehmer.objects.all()
def clean_vergleichsgewicht(self):
wert = self.cleaned_data.get('vergleichsgewicht')
if wert is not None:
if wert < Decimal('0.0') or wert > Decimal('2.0'):
raise forms.ValidationError('Vergleichsgewicht muss zwischen 0,0 und 2,0 liegen.')
return wert

View file

@ -1,3 +1,80 @@
from django.test import TestCase
from decimal import Decimal
# Create your tests here.
import factory
import pytest
from django.urls import reverse
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
from vergabe_teilnahme.apps.core.services import gewichteter_durchschnitt
from .models import Preispunkt
class PreispunktFactory(factory.django.DjangoModelFactory):
class Meta:
model = Preispunkt
ausschreibung = factory.SubFactory(AusschreibungFactory)
leistungstyp = 'IT-Dienstleistung'
konkrete_leistung = factory.Sequence(lambda n: f'Leistung {n}')
mengeneinheit = 'Stück'
einzelpreis = Decimal('100.00')
vergleichsgewicht = Decimal('1.0')
@pytest.mark.django_db
def test_vergleichsgewicht_null_gespeichert(client):
a = AusschreibungFactory()
url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {
'leistungstyp': 'Test',
'konkrete_leistung': 'Leistung mit Gewicht 0',
'mengeneinheit': 'Stück',
'waehrung': 'EUR',
'vergleichsgewicht': '0.0',
})
assert response.status_code == 302
pp = Preispunkt.objects.get(ausschreibung=a)
assert pp.vergleichsgewicht == Decimal('0.0')
@pytest.mark.django_db
def test_vergleichsgewicht_zu_gross_validation_error(client):
a = AusschreibungFactory()
url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {
'leistungstyp': 'Test',
'konkrete_leistung': 'Leistung',
'mengeneinheit': 'Stück',
'waehrung': 'EUR',
'vergleichsgewicht': '2.5',
})
assert response.status_code == 200
assert not Preispunkt.objects.filter(ausschreibung=a).exists()
def test_gewichteter_durchschnitt_berechnung():
class FakePP:
def __init__(self, einzelpreis, gewicht):
self.einzelpreis = Decimal(str(einzelpreis))
self.vergleichsgewicht = Decimal(str(gewicht))
punkte = [
FakePP(100, 1.0),
FakePP(110, 2.0),
FakePP(90, 0.0), # ausgeschlossen
]
ergebnis = gewichteter_durchschnitt(punkte)
assert ergebnis is not None
expected = (Decimal('100') * 1 + Decimal('110') * 2) / (1 + 2)
assert round(ergebnis['wert'], 2) == round(expected, 2)
@pytest.mark.django_db
def test_auswertung_view_200(client):
a = AusschreibungFactory()
PreispunktFactory(ausschreibung=a, einzelpreis=Decimal('150.00'))
url = reverse('ausschreibungen:preise:auswertung', kwargs={'ausschreibung_id': a.pk})
response = client.get(url)
assert response.status_code == 200
assert 'ergebnis' in response.context

View file

@ -1,2 +1,13 @@
from django.urls import path
urlpatterns = []
from . import views
app_name = 'preise'
urlpatterns = [
path('', views.preispunkte_liste, name='liste'),
path('neu/', views.preispunkt_neu, name='neu'),
path('<int:pk>/', views.preispunkt_detail, name='detail'),
path('<int:pk>/bearbeiten/', views.preispunkt_bearbeiten, name='bearbeiten'),
path('<int:pk>/loeschen/', views.preispunkt_loeschen, name='loeschen'),
path('auswertung/', views.leistungstyp_auswertung, name='auswertung'),
]

View file

@ -1,3 +1,192 @@
from django.shortcuts import render
from decimal import Decimal
# Create your views here.
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404, redirect, render
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.core.models import Freigabe
from vergabe_teilnahme.apps.core.services import gewichteter_durchschnitt
from .forms import PreispunktForm
from .models import Preispunkt
def preispunkte_liste(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
qs = Preispunkt.objects.filter(ausschreibung=ausschreibung).select_related('los', 'subunternehmer')
leistungstyp_filter = request.GET.get('leistungstyp', '').strip()
los_filter = request.GET.get('los', '').strip()
sub_filter = request.GET.get('subunternehmer', '')
if leistungstyp_filter:
qs = qs.filter(leistungstyp__icontains=leistungstyp_filter)
if los_filter:
qs = qs.filter(los_id=los_filter)
if sub_filter == 'ja':
qs = qs.filter(subunternehmeranteil=True)
elif sub_filter == 'nein':
qs = qs.filter(subunternehmeranteil=False)
summe_gesamt = sum(p.gesamtpreis for p in qs if p.gesamtpreis is not None)
from vergabe_teilnahme.apps.lose.models import Los
lose = Los.objects.filter(ausschreibung=ausschreibung)
leistungstypen = Preispunkt.objects.filter(ausschreibung=ausschreibung).values_list(
'leistungstyp', flat=True
).distinct()
ctx = {
'ausschreibung': ausschreibung,
'preispunkte': qs,
'summe_gesamt': summe_gesamt,
'lose': lose,
'leistungstypen': leistungstypen,
'filter': {
'leistungstyp': leistungstyp_filter,
'los': los_filter,
'subunternehmer': sub_filter,
},
}
return render(request, 'preise/liste.html', ctx)
def preispunkt_neu(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
form = PreispunktForm(request.POST or None, ausschreibung=ausschreibung)
if request.method == 'POST' and form.is_valid():
pp = form.save(commit=False)
pp.ausschreibung = ausschreibung
pp.save()
return redirect('ausschreibungen:preise:liste', ausschreibung_id=ausschreibung_id)
leistungstypen = Preispunkt.objects.values_list('leistungstyp', flat=True).distinct()
return render(request, 'preise/form.html', {
'ausschreibung': ausschreibung,
'form': form,
'leistungstypen': leistungstypen,
'titel': 'Neuer Preispunkt',
})
def preispunkt_detail(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
pp = get_object_or_404(Preispunkt, pk=pk, ausschreibung=ausschreibung)
ct = ContentType.objects.get_for_model(Preispunkt)
freigaben = Freigabe.objects.filter(content_type=ct, object_id=pp.pk)
return render(request, 'preise/detail.html', {
'ausschreibung': ausschreibung,
'preispunkt': pp,
'freigaben': freigaben,
})
def preispunkt_bearbeiten(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
pp = get_object_or_404(Preispunkt, pk=pk, ausschreibung=ausschreibung)
form = PreispunktForm(request.POST or None, instance=pp, ausschreibung=ausschreibung)
if request.method == 'POST' and form.is_valid():
form.save()
return redirect('ausschreibungen:preise:detail', ausschreibung_id=ausschreibung_id, pk=pk)
leistungstypen = Preispunkt.objects.values_list('leistungstyp', flat=True).distinct()
return render(request, 'preise/form.html', {
'ausschreibung': ausschreibung,
'form': form,
'preispunkt': pp,
'leistungstypen': leistungstypen,
'titel': 'Preispunkt bearbeiten',
})
def preispunkt_loeschen(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
pp = get_object_or_404(Preispunkt, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
pp.delete()
return redirect('ausschreibungen:preise:liste', ausschreibung_id=ausschreibung_id)
return render(request, 'preise/loeschen_bestaetigen.html', {
'ausschreibung': ausschreibung,
'preispunkt': pp,
})
def leistungstyp_auswertung(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
leistungstyp = request.GET.get('leistungstyp', '').strip()
filter_gewonnen = request.GET.get('gewonnen')
qs = Preispunkt.objects.filter(einzelpreis__isnull=False)
if leistungstyp:
qs = qs.filter(leistungstyp__icontains=leistungstyp)
if filter_gewonnen == 'ja':
qs = qs.filter(ausschreibung_gewonnen=True)
elif filter_gewonnen == 'nein':
qs = qs.filter(ausschreibung_gewonnen=False)
ergebnis = gewichteter_durchschnitt(list(qs))
ungewichtet = None
if qs.exists():
werte = [p.einzelpreis for p in qs if p.einzelpreis is not None]
ungewichtet = sum(werte) / len(werte) if werte else None
alle_leistungstypen = Preispunkt.objects.values_list('leistungstyp', flat=True).distinct()
return render(request, 'preise/auswertung.html', {
'ausschreibung': ausschreibung,
'leistungstyp': leistungstyp,
'filter_gewonnen': filter_gewonnen,
'ergebnis': ergebnis,
'ungewichtet': ungewichtet,
'preispunkte': qs.order_by('-ausschreibung__erstellt_am'),
'alle_leistungstypen': alle_leistungstypen,
})
def globaler_preisvergleich(request):
leistungstyp = request.GET.get('leistungstyp', '').strip()
filter_gewonnen = request.GET.get('gewonnen', '')
von = request.GET.get('von', '')
bis = request.GET.get('bis', '')
qs = Preispunkt.objects.filter(einzelpreis__isnull=False).select_related('ausschreibung')
if leistungstyp:
qs = qs.filter(leistungstyp__icontains=leistungstyp)
if filter_gewonnen == 'ja':
qs = qs.filter(ausschreibung_gewonnen=True)
elif filter_gewonnen == 'nein':
qs = qs.filter(ausschreibung_gewonnen=False)
if von:
qs = qs.filter(preisstand__gte=von)
if bis:
qs = qs.filter(preisstand__lte=bis)
ergebnis = gewichteter_durchschnitt(list(qs))
ungewichtet = None
if qs.exists():
werte = [p.einzelpreis for p in qs if p.einzelpreis is not None]
ungewichtet = sum(werte) / len(werte) if werte else None
gewonnen_punkte = list(qs.filter(ausschreibung_gewonnen=True))
verloren_punkte = list(qs.filter(ausschreibung_gewonnen=False))
ergebnis_gewonnen = gewichteter_durchschnitt(gewonnen_punkte) if gewonnen_punkte else None
ergebnis_verloren = gewichteter_durchschnitt(verloren_punkte) if verloren_punkte else None
alle_leistungstypen = Preispunkt.objects.values_list('leistungstyp', flat=True).distinct()
return render(request, 'preise/globaler_vergleich.html', {
'leistungstyp': leistungstyp,
'filter_gewonnen': filter_gewonnen,
'von': von,
'bis': bis,
'ergebnis': ergebnis,
'ungewichtet': ungewichtet,
'ergebnis_gewonnen': ergebnis_gewonnen,
'ergebnis_verloren': ergebnis_verloren,
'preispunkte': qs.order_by('-ausschreibung__erstellt_am'),
'alle_leistungstypen': alle_leistungstypen,
})
def preisfreigabe(request, ausschreibung_id):
return redirect('ausschreibungen:preise:liste', ausschreibung_id=ausschreibung_id)