From 6991b0989e692a4b0f6a6176b67d1e3cc7f16ff2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 11 May 2026 12:23:20 +0200 Subject: [PATCH] Workplan Preise has been implemented --- vergabe_teilnahme/apps/preise/forms.py | 59 ++++++ vergabe_teilnahme/apps/preise/tests.py | 81 +++++++- vergabe_teilnahme/apps/preise/urls.py | 13 +- vergabe_teilnahme/apps/preise/views.py | 193 +++++++++++++++++- .../templates/preise/auswertung.html | 88 ++++++++ .../templates/preise/detail.html | 71 +++++++ vergabe_teilnahme/templates/preise/form.html | 124 +++++++++++ .../templates/preise/globaler_vergleich.html | 121 +++++++++++ vergabe_teilnahme/templates/preise/liste.html | 109 ++++++++++ .../preise/loeschen_bestaetigen.html | 16 ++ vergabe_teilnahme/urls.py | 2 + workplans/WP-0008-preise.md | 12 +- 12 files changed, 878 insertions(+), 11 deletions(-) create mode 100644 vergabe_teilnahme/apps/preise/forms.py create mode 100644 vergabe_teilnahme/templates/preise/auswertung.html create mode 100644 vergabe_teilnahme/templates/preise/detail.html create mode 100644 vergabe_teilnahme/templates/preise/form.html create mode 100644 vergabe_teilnahme/templates/preise/globaler_vergleich.html create mode 100644 vergabe_teilnahme/templates/preise/liste.html create mode 100644 vergabe_teilnahme/templates/preise/loeschen_bestaetigen.html diff --git a/vergabe_teilnahme/apps/preise/forms.py b/vergabe_teilnahme/apps/preise/forms.py new file mode 100644 index 0000000..3ebcdc2 --- /dev/null +++ b/vergabe_teilnahme/apps/preise/forms.py @@ -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 diff --git a/vergabe_teilnahme/apps/preise/tests.py b/vergabe_teilnahme/apps/preise/tests.py index 7ce503c..4b791d3 100644 --- a/vergabe_teilnahme/apps/preise/tests.py +++ b/vergabe_teilnahme/apps/preise/tests.py @@ -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 diff --git a/vergabe_teilnahme/apps/preise/urls.py b/vergabe_teilnahme/apps/preise/urls.py index eb89e3b..bac3e40 100644 --- a/vergabe_teilnahme/apps/preise/urls.py +++ b/vergabe_teilnahme/apps/preise/urls.py @@ -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('/', views.preispunkt_detail, name='detail'), + path('/bearbeiten/', views.preispunkt_bearbeiten, name='bearbeiten'), + path('/loeschen/', views.preispunkt_loeschen, name='loeschen'), + path('auswertung/', views.leistungstyp_auswertung, name='auswertung'), +] diff --git a/vergabe_teilnahme/apps/preise/views.py b/vergabe_teilnahme/apps/preise/views.py index 91ea44a..d659b32 100644 --- a/vergabe_teilnahme/apps/preise/views.py +++ b/vergabe_teilnahme/apps/preise/views.py @@ -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) diff --git a/vergabe_teilnahme/templates/preise/auswertung.html b/vergabe_teilnahme/templates/preise/auswertung.html new file mode 100644 index 0000000..4d77047 --- /dev/null +++ b/vergabe_teilnahme/templates/preise/auswertung.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}Preisauswertung — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
+

Preisauswertung

+ ← Preise +
+ +
+
+ + + {% for lt in alle_leistungstypen %} +
+
+ + +
+ +
+ +{% if ergebnis %} +
+
+

Gewichteter Durchschnitt

+

{{ ergebnis.wert|floatformat:2 }}

+
+
+

Ungewichteter Durchschnitt

+

{% if ungewichtet %}{{ ungewichtet|floatformat:2 }}{% else %}—{% endif %}

+
+
+

Messpunkte / Summe Gewichte

+

{{ ergebnis.anzahl }} / {{ ergebnis.summe_gewichte }}

+
+
+

Minimum

+

{{ ergebnis.minimum|floatformat:2 }}

+
+
+

Maximum

+

{{ ergebnis.maximum|floatformat:2 }}

+
+
+{% elif leistungstyp %} +
Keine Daten für "{{ leistungstyp }}".
+{% else %} +
Leistungstyp eingeben, um Auswertung zu starten.
+{% endif %} + +{% if preispunkte %} +
+

Einzelmesspunkte

+ + + + + + + + + + + + {% for pp in preispunkte %} + + + + + + + + {% endfor %} + +
AusschreibungLeistungEinzelpreisGewichtGewonnen
{{ pp.ausschreibung.titel|truncatechars:30 }}{{ pp.konkrete_leistung }}{{ pp.einzelpreis|floatformat:2 }} {{ pp.waehrung }}{{ pp.vergleichsgewicht }} + {% if pp.ausschreibung_gewonnen == True %} + {% elif pp.ausschreibung_gewonnen == False %} + {% else %}?{% endif %} +
+
+{% endif %} + +{% endblock %} diff --git a/vergabe_teilnahme/templates/preise/detail.html b/vergabe_teilnahme/templates/preise/detail.html new file mode 100644 index 0000000..2783582 --- /dev/null +++ b/vergabe_teilnahme/templates/preise/detail.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}{{ preispunkt.konkrete_leistung }}{% endblock %} +{% block content %} + +
+

{{ preispunkt.konkrete_leistung }}

+ ← Übersicht +
+ +
+
+
+

{{ preispunkt.leistungstyp }}

+
+
+

Einzelpreis

+

{% if preispunkt.einzelpreis %}{{ preispunkt.einzelpreis|floatformat:2 }} {{ preispunkt.waehrung }}{% else %}—{% endif %}

+
+
+

Gesamtpreis

+

{% if preispunkt.gesamtpreis %}{{ preispunkt.gesamtpreis|floatformat:2 }} {{ preispunkt.waehrung }}{% else %}—{% endif %}

+
+
+

Menge

+

{% if preispunkt.menge %}{{ preispunkt.menge|floatformat:2 }} {{ preispunkt.mengeneinheit }}{% else %}—{% endif %}

+
+
+ {% if preispunkt.kommentar %} +

{{ preispunkt.kommentar }}

+ {% endif %} +
+ + {% if freigaben %} +
+

Freigaben

+
    + {% for fg in freigaben %} +
  • + {% status_badge fg.status fg.get_status_display %} + {{ fg.freigebender }} + {% if fg.kommentar %}— {{ fg.kommentar }}{% endif %} +
  • + {% endfor %} +
+
+ {% endif %} +
+ +
+
+

Details

+

Gewicht: {{ preispunkt.vergleichsgewicht }}

+ {% if preispunkt.preisstand %}

Preisstand: {{ preispunkt.preisstand|date:"d.m.Y" }}

{% endif %} + {% if preispunkt.los %}

Los: {{ preispunkt.los }}

{% endif %} + {% if preispunkt.subunternehmer %}

Subunternehmer: {{ preispunkt.subunternehmer }}

{% endif %} + {% if preispunkt.laufzeitbezug %}

Laufzeit: {{ preispunkt.laufzeitbezug }}

{% endif %} + {% if preispunkt.gewichtungsbegruendung %}

Begründung: {{ preispunkt.gewichtungsbegruendung }}

{% endif %} +
+
+

Aktionen

+ Bearbeiten +
+ {% csrf_token %} + +
+
+
+
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/preise/form.html b/vergabe_teilnahme/templates/preise/form.html new file mode 100644 index 0000000..45826d0 --- /dev/null +++ b/vergabe_teilnahme/templates/preise/form.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} +{% block title %}{{ titel }}{% endblock %} +{% block content %} +
+
+

{{ titel }}

+ ← Zurück +
+ +
+ {% csrf_token %} + + + {% for lt in leistungstypen %} + +
+

Leistung

+
+ + {{ form.leistungstyp }} + {% if form.leistungstyp.errors %}

{{ form.leistungstyp.errors.0 }}

{% endif %} +
+
+ + {{ form.konkrete_leistung }} + {% if form.konkrete_leistung.errors %}

{{ form.konkrete_leistung.errors.0 }}

{% endif %} +
+
+
+ + {{ form.menge }} +
+
+ + {{ form.mengeneinheit }} +
+
+
+
+ + {{ form.wiederkehrend }} +
+
+ + {{ form.laufzeitbezug }} +
+
+
+ +
+

Preis

+
+
+ + + {% if form.einzelpreis.errors %}

{{ form.einzelpreis.errors.0 }}

{% endif %} +
+
+ + +
+
+ + {{ form.waehrung }} +
+
+
+ + {{ form.preisstand }} +
+
+ +
+

Vergleichsgewicht

+
+ + {{ form.vergleichsgewicht }} + {% if form.vergleichsgewicht.errors %}

{{ form.vergleichsgewicht.errors.0 }}

{% endif %} +

0,0 = nicht gewertet · 1,0 = Standard · 2,0 = doppelt gewichtet

+
+
+ + {{ form.gewichtungsbegruendung }} +
+
+ +
+

Zuordnung

+
+ + {{ form.los }} +
+
+ +
+
+ + {{ form.subunternehmer }} +
+
+ +
+ + {{ form.kommentar }} +
+ +
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/preise/globaler_vergleich.html b/vergabe_teilnahme/templates/preise/globaler_vergleich.html new file mode 100644 index 0000000..9e39211 --- /dev/null +++ b/vergabe_teilnahme/templates/preise/globaler_vergleich.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% block title %}Globaler Preisvergleich{% endblock %} +{% block content %} + +
+

Globaler Preisvergleich

+
+ +
+
+ + + {% for lt in alle_leistungstypen %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+{% if ergebnis %} +
+
+

Gewichteter Durchschnitt

+

{{ ergebnis.wert|floatformat:2 }}

+
+
+

Ungewichteter Durchschnitt

+

{% if ungewichtet %}{{ ungewichtet|floatformat:2 }}{% else %}—{% endif %}

+
+
+

Messpunkte

+

{{ ergebnis.anzahl }}

+
+
+

Minimum

+

{{ ergebnis.minimum|floatformat:2 }}

+
+
+

Maximum

+

{{ ergebnis.maximum|floatformat:2 }}

+
+
+ +{% if ergebnis_gewonnen or ergebnis_verloren %} +
+ {% if ergebnis_gewonnen %} +
+

Ø bei Gewinn

+

{{ ergebnis_gewonnen.wert|floatformat:2 }}

+

{{ ergebnis_gewonnen.anzahl }} Messpunkte

+
+ {% endif %} + {% if ergebnis_verloren %} +
+

Ø bei Verlust

+

{{ ergebnis_verloren.wert|floatformat:2 }}

+

{{ ergebnis_verloren.anzahl }} Messpunkte

+
+ {% endif %} +
+{% endif %} + +{% elif leistungstyp %} +
Keine Daten für "{{ leistungstyp }}".
+{% else %} +
Leistungstyp eingeben, um Vergleich zu starten.
+{% endif %} + +{% if preispunkte %} +
+

Einzelmesspunkte ({{ preispunkte.count }})

+ + + + + + + + + + + + {% for pp in preispunkte %} + + + + + + + + {% endfor %} + +
AusschreibungLeistungEinzelpreisGewichtGew.
{{ pp.ausschreibung.titel|truncatechars:30 }}{{ pp.konkrete_leistung }}{{ pp.einzelpreis|floatformat:2 }} {{ pp.waehrung }}{{ pp.vergleichsgewicht }} + {% if pp.ausschreibung_gewonnen == True %} + {% elif pp.ausschreibung_gewonnen == False %} + {% else %}?{% endif %} +
+
+{% endif %} +
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/preise/liste.html b/vergabe_teilnahme/templates/preise/liste.html new file mode 100644 index 0000000..a8a8cf2 --- /dev/null +++ b/vergabe_teilnahme/templates/preise/liste.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% block title %}Preise — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
+

Preise

+ +
+ +
+
+ + + {% for lt in leistungstypen %} +
+
+ + +
+
+ + +
+ + {% if filter.leistungstyp or filter.los or filter.subunternehmer %} + Zurücksetzen + {% endif %} +
+ +{% if preispunkte %} +
+ + + + + + + + + + + + + + + {% for pp in preispunkte %} + + + + + + + + + + + {% endfor %} + + + + + + + + +
LeistungstypKonkrete LeistungMenge/Einh.EinzelpreisGesamtpreisGewichtLos
{{ pp.leistungstyp }} + {{ pp.konkrete_leistung }} + + {% if pp.menge %}{{ pp.menge|floatformat:2 }} {{ pp.mengeneinheit }}{% else %}—{% endif %} + + {% if pp.einzelpreis %} + + {{ pp.einzelpreis|floatformat:2 }} {{ pp.waehrung }} + + {% else %}—{% endif %} + + {% if pp.gesamtpreis %} + + {{ pp.gesamtpreis|floatformat:2 }} {{ pp.waehrung }} + + {% else %}—{% endif %} + + + {{ pp.vergleichsgewicht }} + + {{ pp.los.bezeichnung|default:"—" }} + Bearb. +
Summe Gesamtpreise (ungewichtet) + {% if summe_gesamt %}{{ summe_gesamt|floatformat:2 }} EUR{% else %}—{% endif %} +
+
+{% else %} +
+ Noch keine Preispunkte erfasst. + + Ersten Preispunkt anlegen +
+{% endif %} + +{% endblock %} diff --git a/vergabe_teilnahme/templates/preise/loeschen_bestaetigen.html b/vergabe_teilnahme/templates/preise/loeschen_bestaetigen.html new file mode 100644 index 0000000..9ad69ad --- /dev/null +++ b/vergabe_teilnahme/templates/preise/loeschen_bestaetigen.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Preispunkt löschen{% endblock %} +{% block content %} +
+
+

Preispunkt {{ preispunkt.konkrete_leistung }} wirklich löschen?

+
+
+ {% csrf_token %} + +
+ Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/urls.py b/vergabe_teilnahme/urls.py index 1e3876e..e9699b3 100644 --- a/vergabe_teilnahme/urls.py +++ b/vergabe_teilnahme/urls.py @@ -6,6 +6,7 @@ from django.shortcuts import redirect from django.urls import include, path from vergabe_teilnahme.apps.core import views as core_views +from vergabe_teilnahme.apps.preise import views as preise_views def health(request): @@ -28,6 +29,7 @@ urlpatterns = [ path('aufgaben/', include('vergabe_teilnahme.apps.aufgaben.global_urls')), path('dokumente/', include('vergabe_teilnahme.apps.dokumente.urls')), path('preise/', include('vergabe_teilnahme.apps.preise.urls')), + path('preise/vergleich/', preise_views.globaler_preisvergleich, name='preisvergleich'), path('partner/', include('vergabe_teilnahme.apps.partner.urls')), path('bibliothek/', include('vergabe_teilnahme.apps.bibliothek.urls')), path('marktbegleiter/', include('vergabe_teilnahme.apps.marktbegleiter.urls')), diff --git a/workplans/WP-0008-preise.md b/workplans/WP-0008-preise.md index b50f7e7..50352b6 100644 --- a/workplans/WP-0008-preise.md +++ b/workplans/WP-0008-preise.md @@ -1,7 +1,7 @@ --- id: WP-0008 title: Preise und Marktpreisauswertung -status: todo +status: done phase: 8-of-12 created: "2026-05-08" depends_on: WP-0007 @@ -17,7 +17,7 @@ und Leistungstyp-Auswertung. Referenz: UC-PR-01 bis UC-PR-04, FR-25 bis FR-36. ```task id: WP-0008-T01 title: Preispunkt anlegen mit Vergleichsgewicht-Validierung (UC-PR-01, UC-PR-02) -status: todo +status: done `preise/views.py` — preispunkt_neu: @@ -43,7 +43,7 @@ Template `preise/form.html`: ```task id: WP-0008-T02 title: Preispunkt-Liste pro Ausschreibung -status: todo +status: done `preise/views.py` — preispunkte_liste: @@ -66,7 +66,7 @@ Gesamtpreis-Auto-Berechnung: ```task id: WP-0008-T03 title: Leistungstyp-Auswertung mit gewichtetem Durchschnitt (UC-PR-03) -status: todo +status: done `preise/views.py` — leistungstyp_auswertung: @@ -108,7 +108,7 @@ Darunter: Tabelle aller Einzelmesspunkte mit Ausschreibungstitel, Datum, Gewicht ```task id: WP-0008-T04 title: Globaler Preisvergleich (cross-Ausschreibung, UC-PR-03) -status: todo +status: done URL: `/preise/vergleich/` (globaler Endpunkt, kein ausschreibung_id Präfix) @@ -130,7 +130,7 @@ in Haupt-URLs einbinden. ```task id: WP-0008-T05 title: Preisfreigabe und URL-Verkabelung (UC-PR-04) -status: todo +status: done `preise/views.py` — preisfreigabe: Button "Preisfreigabe erteilen" auf der Preisliste öffnet Freigabe-Modal (aus WP-0012).