36 KiB
Architecture Blueprint — Vergabe Teilnahme
Version: 1.0
Datum: 7. Mai 2026
Grundlage: ProductRequirementsDocument.md
1. Leitprinzipien
| Prinzip | Konsequenz |
|---|---|
| Phasengeführt, nie phasengesperrt | Die 8 Phasen sind Navigation und Orientierung, keine Zugangssperren. Jedes Element ist jederzeit erreichbar. |
| Kein Zwang zur Vollständigkeit | Pflichtfelder gibt es nur an echten Systemgrenzen (z. B. Abgabenachweis). Alle anderen Felder können leer bleiben. |
| Anpassbare Datenstruktur | Jede Entität erlaubt das globale Ausblenden ungenutzter Felder und das Hinzufügen beliebiger Schlüssel-Wert-Attribute. |
| Manuelle Erfassung first | Keine Automatisierung in v1. Alle Felder werden manuell gepflegt. |
| Server-first, progressive Enhancement | Seitenrendering auf dem Server. HTMX für partielle Aktualisierungen. Alpine.js für reine UI-Zustände. |
2. Technology Stack
| Schicht | Technologie | Begründung |
|---|---|---|
| Sprache | Python 3.12+ | .gitignore-Konvention; erprobtes Ökosystem |
| Paketmanager | uv | Geschwindigkeit, lockfile, Python-Versions-Management |
| Web-Framework | Django 5.x | ORM, Admin, Auth, ContentType-Framework — alle benötigt |
| Template-Rendering | Django Templates | Server-side, kein Build-Step für Templates |
| Reaktivität | HTMX 2.x | Partielle DOM-Updates ohne SPA-Overhead |
| UI-Zustandsverwaltung | Alpine.js 3.x | Accordions, Dropdowns, Modals, Inline-Toggles |
| CSS | Tailwind CSS 4.x | Utility-first; Design-Token-System (s. Abschnitt 8) |
| CSS-Build | PostCSS + Vite | Tailwind JIT, Minification |
| Datenbank | PostgreSQL 16+ | JSONB, volltextsuche, referentielle Integrität |
| Datei-Storage | Django FileField (lokal) | Einfach; abstrakt genug für S3 via django-storages later |
| Background-Tasks | Celery + Redis | Fristenprüfung, Benachrichtigungen (optional in v1) |
| Testing | pytest-django | Fixture-basiert; kein Mock der DB |
3. Django-App-Struktur
Das Projekt ist in fachliche Apps aufgeteilt. Technische Querschnittsthemen liegen in core.
vergabe_teilnahme/ # Django-Projekt-Root
├── core/ # Querschnittslogik
│ ├── models.py # FlexibleModel-Mixin, CustomAttribute, EntityFieldConfig, Freigabe
│ ├── mixins.py # FlexFieldMixin für Views
│ ├── templatetags/ # render_field, flex_fields, phase_badge, status_badge
│ └── services.py # weighted_average(), deadline_warnings()
├── ausschreibungen/ # Ausschreibung, Teilnahmeentscheidung
├── lose/ # Los, Anforderung, Auflage
├── aufgaben/ # Aufgabe (Offener Punkt), Bieterfrage
├── dokumente/ # Dokument, Datei-Upload, Versionierung
├── preise/ # Preispunkt, Preismodell, Auswertungen
├── partner/ # Subunternehmer, Dienstleistertyp
├── bibliothek/ # Nachweis, Referenz, Leistungsblatt, Entscheidungsregel
├── marktbegleiter/ # Marktbegleiter, Ausschreibungspassage
├── nachbetrachtung/ # Nachbetrachtung (Phase 8)
├── feedback/ # Feedbackeintrag
└── accounts/ # Mitarbeiter (erweitertes Django-User-Modell)
App-Abhängigkeiten (vereinfacht)
core ←── alle Apps
accounts ←── ausschreibungen, lose, aufgaben, dokumente, preise, partner
ausschreibungen ←── lose, aufgaben, dokumente, preise, marktbegleiter, nachbetrachtung
bibliothek ←── lose (Anforderungs-Nachweise), dokumente (Standarddokumente)
partner ←── lose, aufgaben, preise
4. Flexible Felder — Das Anpassungssystem
Jede Entität unterstützt zwei Anpassungsmechanismen:
4.1 Felder ausblenden: EntityFieldConfig
Definiert global (nicht pro Instanz), welche eingebauten Felder einer Entität ausgeblendet werden. Einmal konfiguriert — wirkt für alle Instanzen dieses Typs.
# core/models.py
class EntityFieldConfig(Model):
entity_type = CharField(max_length=100) # z. B. "subunternehmer", "anforderung"
field_name = CharField(max_length=100) # interner Feldname
is_hidden = BooleanField(default=False)
display_label = CharField(max_length=200, blank=True) # Umbenennungsoption
sort_order = PositiveSmallIntegerField(default=0)
class Meta:
unique_together = ('entity_type', 'field_name')
Template-Tag {% render_field obj "mobilnummer" %} prüft EntityFieldConfig und rendert das Feld nur, wenn is_hidden=False.
Verwaltungsoberfläche: Unter /admin/felder/ können Administratoren für jeden Entitätstyp in einer Tabelle alle Felder sehen, ein-/ausblenden und umbenennen. Keine Code-Änderung erforderlich.
4.2 Benutzerdefinierte Schlüssel-Wert-Attribute: CustomAttribute
Für jede Instanz beliebig erweiterbar. Verwendet Django's ContentType-Framework (Generic FK).
# core/models.py
class CustomAttribute(Model):
DATA_TYPES = [
('text', 'Text'),
('number', 'Zahl'),
('date', 'Datum'),
('boolean', 'Ja / Nein'),
('url', 'Link'),
('email', 'E-Mail'),
]
content_type = ForeignKey(ContentType, on_delete=CASCADE)
object_id = PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
key = CharField(max_length=100) # interner Schlüssel (slug)
label = CharField(max_length=200) # Anzeigebezeichnung
value = TextField(blank=True)
data_type = CharField(max_length=20, choices=DATA_TYPES, default='text')
sort_order = PositiveSmallIntegerField(default=0)
created_at = DateTimeField(auto_now_add=True)
class Meta:
ordering = ['sort_order', 'created_at']
indexes = [Index(fields=['content_type', 'object_id'])]
UX: Jede Detailseite zeigt am Ende einen Abschnitt "Weitere Attribute" mit einem "+ Attribut hinzufügen"-Button (HTMX-Inline-Formular). Bestehende Attribute können bearbeitet, umsortiert und gelöscht werden.
4.3 Mixin für alle Entitäts-Modelle
# core/models.py
class FlexibleModel(Model):
"""Abstract base für alle Fachentitäten."""
custom_attributes = GenericRelation(CustomAttribute)
def get_visible_fields(self):
hidden = EntityFieldConfig.objects.filter(
entity_type=self._meta.model_name,
is_hidden=True
).values_list('field_name', flat=True)
return [f for f in self._meta.get_fields() if f.name not in hidden]
class Meta:
abstract = True
5. Datenmodelle
5.1 Ausschreibung
class Ausschreibung(FlexibleModel):
STATUS_CHOICES = [
(1, 'Recherchiert / angelegt'),
(2, 'In Erstprüfung'),
(3, 'Teilnahme entschieden'),
(4, 'Detailanalyse läuft'),
(5, 'Klärung / Bieterfragen läuft'),
(6, 'Preisgestaltung läuft'),
(7, 'Unterlagenfinalisierung läuft'),
(8, 'Bereit zur Abgabe'),
(9, 'Abgegeben'),
(10, 'Zuschlag gewonnen'),
(11, 'Zuschlag verloren'),
(12, 'Aufgehoben / zurückgezogen'),
(13, 'Archiviert'),
]
TEILNAHME_CHOICES = [
('offen', 'Offen'),
('teilnahme', 'Teilnahme'),
('ablehnung', 'Nichtteilnahme'),
('pruefung', 'Weitere Prüfung erforderlich'),
]
titel = CharField(max_length=400)
ausschreiber = CharField(max_length=300)
plattform = CharField(max_length=200, blank=True)
plattform_link = URLField(blank=True)
ansprechpartner = CharField(max_length=300, blank=True)
hauptverantwortung = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
on_delete=SET_NULL, related_name='verantwortete_ausschreibungen')
status = PositiveSmallIntegerField(choices=STATUS_CHOICES, default=1)
teilnahmeentscheidung = CharField(max_length=20, choices=TEILNAHME_CHOICES, default='offen')
beschreibung = TextField(blank=True)
strategische_relevanz = TextField(blank=True)
ergebnis = CharField(max_length=20, blank=True) # gewonnen, verloren, aufgehoben, offen
archiviert = BooleanField(default=False)
# Fristen
bieterfragen_bis = DateField(null=True, blank=True)
abgabe_bis = DateTimeField(null=True, blank=True)
zuschlag_bis = DateField(null=True, blank=True)
produktiv_bis = DateField(null=True, blank=True)
erstellt_am = DateTimeField(auto_now_add=True)
geaendert_am = DateTimeField(auto_now=True)
5.2 Los
class Los(FlexibleModel):
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE, related_name='lose')
losnummer = CharField(max_length=50)
lostitel = CharField(max_length=300)
beschreibung = TextField(blank=True)
abgrenzung = TextField(blank=True)
zustaendiger = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
teilnahme = BooleanField(null=True) # None = noch nicht entschieden
status = CharField(max_length=50, blank=True)
class Meta:
ordering = ['losnummer']
5.3 Anforderung / Auflage
class Anforderung(FlexibleModel):
VERBINDLICHKEIT = [('muss', 'Muss'), ('soll', 'Soll'), ('kann', 'Kann')]
ERFUELLUNGSSTATUS = [
('offen', 'Offen'),
('in_pruefung', 'In Prüfung'),
('erfuellbar', 'Erfüllbar'),
('mit_subunternehmer', 'Erfüllbar mit Subunternehmer'),
('nicht_erfuellbar', 'Nicht erfüllbar'),
('klaerung', 'Klärung erforderlich'),
]
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
titel = CharField(max_length=400)
beschreibung = TextField(blank=True)
quelle_im_dokument = CharField(max_length=300, blank=True)
kategorie = CharField(max_length=100, blank=True)
verbindlichkeit = CharField(max_length=10, choices=VERBINDLICHKEIT, default='muss')
ausschlusskriterium = BooleanField(default=False)
bewertungskriterium = BooleanField(default=False)
zustaendiger = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
erfuellungsstatus = CharField(max_length=30, choices=ERFUELLUNGSSTATUS, default='offen')
nachweis_erforderlich = BooleanField(default=False)
dokumente = ManyToManyField('dokumente.Dokument', blank=True)
nachweise = ManyToManyField('bibliothek.Nachweis', blank=True)
5.4 Aufgabe (Offener Punkt)
class Aufgabe(FlexibleModel):
TYP_CHOICES = [
('fachlich', 'Fachlich'),
('rechtlich', 'Rechtlich'),
('kaufmaennisch', 'Kaufmännisch'),
('technisch', 'Technisch'),
('subunternehmer', 'Subunternehmerklärung'),
('dokument', 'Dokumentenaufgabe'),
('preis', 'Preisaufgabe'),
]
STATUS_CHOICES = [
('offen', 'Offen'),
('in_bearbeitung', 'In Bearbeitung'),
('wartend_intern', 'Wartend auf intern'),
('wartend_sub', 'Wartend auf Subunternehmer'),
('wartend_ausschreiber', 'Wartend auf Ausschreiber'),
('erledigt', 'Erledigt'),
('verworfen', 'Verworfen'),
('ueberfaellig', 'Überfällig'),
]
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
anforderung = ForeignKey(Anforderung, null=True, blank=True, on_delete=SET_NULL)
bieterfrage = ForeignKey('aufgaben.Bieterfrage', null=True, blank=True, on_delete=SET_NULL)
dokument = ForeignKey('dokumente.Dokument', null=True, blank=True, on_delete=SET_NULL)
titel = CharField(max_length=400)
beschreibung = TextField(blank=True)
typ = CharField(max_length=20, choices=TYP_CHOICES)
prioritaet = PositiveSmallIntegerField(default=2) # 1=hoch, 2=mittel, 3=niedrig
frist = DateField(null=True, blank=True)
verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
status = CharField(max_length=30, choices=STATUS_CHOICES, default='offen')
ergebnis = TextField(blank=True)
5.5 Bieterfrage
class Bieterfrage(FlexibleModel):
STATUS_CHOICES = [
('entwurf', 'Entwurf'),
('abgestimmt', 'Intern abgestimmt'),
('eingereicht','Eingereicht'),
('beantwortet','Beantwortet'),
('eingearbeitet', 'Eingearbeitet'),
]
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
anforderung = ForeignKey(Anforderung, null=True, blank=True, on_delete=SET_NULL)
dokument = ForeignKey('dokumente.Dokument', null=True, blank=True, on_delete=SET_NULL)
frage = TextField()
hintergrund = TextField(blank=True)
verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
status = CharField(max_length=20, choices=STATUS_CHOICES, default='entwurf')
prioritaet = PositiveSmallIntegerField(default=2)
einreichungsdatum = DateField(null=True, blank=True)
antwort = TextField(blank=True)
auswirkung_angebot = TextField(blank=True)
eingearbeitet = BooleanField(default=False)
5.6 Dokument
class Dokument(FlexibleModel):
STATUS_CHOICES = [
('hochgeladen', 'Hochgeladen'),
('zu_pruefen', 'Zu prüfen'),
('in_bearbeitung', 'In Bearbeitung'),
('geprueft', 'Geprüft'),
('freigegeben', 'Freigegeben'),
('final_abgegeben','Final abgegeben'),
('ersetzt', 'Ersetzt / veraltet'),
('archiviert', 'Archiviert'),
]
KATEGORIE_CHOICES = [
('leistungsverzeichnis', 'Leistungsverzeichnis'),
('vertragsunterlagen', 'Vertragsunterlagen'),
('preisblatt', 'Preisblatt'),
('formblatt', 'Formblatt'),
('eignungsnachweis', 'Eignungsnachweis'),
('technische_anlage', 'Technische Anlage'),
('bieterinformation', 'Bieterinformation'),
('sonstiges', 'Sonstiges'),
]
ausschreibung = ForeignKey(Ausschreibung, null=True, blank=True, on_delete=SET_NULL)
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
datei = FileField(upload_to='dokumente/%Y/%m/')
dateiname = CharField(max_length=300)
kategorie = CharField(max_length=50, choices=KATEGORIE_CHOICES)
version = CharField(max_length=50, default='1.0')
quelle = CharField(max_length=200, blank=True)
status = CharField(max_length=20, choices=STATUS_CHOICES, default='hochgeladen')
verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
on_delete=SET_NULL, related_name='verantwortete_dokumente')
pruefer = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
on_delete=SET_NULL, related_name='zu_pruefende_dokumente')
finale_abgabeversion = BooleanField(default=False)
upload_datum = DateTimeField(auto_now_add=True)
5.7 Preispunkt
from decimal import Decimal
class Preispunkt(FlexibleModel):
ausschreibung = ForeignKey(Ausschreibung, on_delete=CASCADE)
los = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
leistungstyp = CharField(max_length=200)
konkrete_leistung = CharField(max_length=400)
mengeneinheit = CharField(max_length=50, blank=True)
menge = DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
einzelpreis = DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
gesamtpreis = DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
waehrung = CharField(max_length=3, default='EUR')
preisstand = DateField(null=True, blank=True)
wiederkehrend = BooleanField(default=False)
laufzeitbezug = CharField(max_length=100, blank=True)
subunternehmeranteil = BooleanField(default=False)
subunternehmer = ForeignKey('partner.Subunternehmer', null=True, blank=True, on_delete=SET_NULL)
# Vergleichsgewicht: 0.0 – 2.0, Default 1.0
vergleichsgewicht = DecimalField(max_digits=3, decimal_places=1, default=Decimal('1.0'))
gewichtungsbegruendung = TextField(blank=True)
kommentar = TextField(blank=True)
# Kontextmarkierungen für Auswertungen
ausschreibung_gewonnen = BooleanField(null=True)
Gewichteter Durchschnitt (Service-Funktion in preise/services.py):
from decimal import Decimal
def gewichteter_durchschnitt(preispunkte, feld='einzelpreis'):
relevante = [p for p in preispunkte
if getattr(p, feld) is not None and p.vergleichsgewicht > 0]
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)
return {
'wert': summe / summe_gewichte,
'summe_gewichte': summe_gewichte,
'anzahl': len(relevante),
'minimum': min(getattr(p, feld) for p in relevante),
'maximum': max(getattr(p, feld) for p in relevante),
}
5.8 Freigabe (generic)
class Freigabe(Model):
TYP_CHOICES = [
('teilnahme', 'Teilnahmefreigabe'),
('ablehnung', 'Nichtteilnahmefreigabe'),
('recht', 'Rechts-/Compliance-Freigabe'),
('preis', 'Preisfreigabe'),
('abgabe', 'Finale Abgabefreigabe'),
('standarddokument','Freigabe Standarddokument'),
('referenz', 'Freigabe Referenzunterlage'),
]
STATUS_CHOICES = [
('erteilt', 'Erteilt'),
('abgelehnt', 'Abgelehnt'),
('ausstehend', 'Ausstehend'),
]
content_type = ForeignKey(ContentType, on_delete=CASCADE)
object_id = PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
freigabe_typ = CharField(max_length=30, choices=TYP_CHOICES)
freigebende_person = ForeignKey(settings.AUTH_USER_MODEL, on_delete=PROTECT)
status = CharField(max_length=20, choices=STATUS_CHOICES, default='erteilt')
kommentar = TextField(blank=True)
timestamp = DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-timestamp']
5.9 Subunternehmer
class Subunternehmer(FlexibleModel):
PRAEFERENZ = [('bevorzugt', 'Bevorzugt'), ('zugelassen', 'Zugelassen'), ('gesperrt', 'Gesperrt')]
name = CharField(max_length=300)
dienstleistertyp = ForeignKey('partner.Dienstleistertyp', null=True, blank=True, on_delete=SET_NULL)
leistungsbereiche = TextField(blank=True)
ansprechpartner = CharField(max_length=200, blank=True)
email = EmailField(blank=True)
mobilnummer = CharField(max_length=50, blank=True)
adresse = TextField(blank=True)
webseite = URLField(blank=True)
zertifizierungen = TextField(blank=True)
praeferenz = CharField(max_length=20, choices=PRAEFERENZ, default='zugelassen')
bewertung = TextField(blank=True)
typische_preislogik = TextField(blank=True)
bemerkungen = TextField(blank=True)
5.10 Weitere Entitäten (Kurzform)
| Entität | App | Besonderheiten |
|---|---|---|
Dienstleistertyp |
partner |
Katalog; kein FK auf Ausschreibung |
Nachweis |
bibliothek |
gueltig_ab, gueltig_bis → Ablaufwarnung |
Referenz |
bibliothek |
whitepaper FileField; Freigabestatus via Freigabe |
Leistungsblatt |
bibliothek |
Versioniert; zugehörige Referenzen M2M |
Entscheidungsregel |
bibliothek |
Kein Automat; liefert Empfehlung + Score |
Marktbegleiter |
marktbegleiter |
Globaler Katalog |
Ausschreibungspassage |
marktbegleiter |
FK auf Ausschreibung, Dokument, Marktbegleiter; Verlässlichkeitsscore 0–10 |
Nachbetrachtung |
nachbetrachtung |
1:1 mit Ausschreibung; enthält Verlustgründe als JSON-Array |
Mitarbeiter |
accounts |
Erweitert AbstractUser; Rolle, Org-Einheit |
Feedbackeintrag |
feedback |
Seite/Kontext als CharField; optional FK auf Ausschreibung |
6. Navigationsarchitektur
6.1 Shell-Layout
┌──────────────────────────────────────────────────────────────────┐
│ TOPBAR │
│ [Logo] [Globale Suche ] [🔔] [Avatar ▾] │
├──────────────┬───────────────────────────────────────────────────┤
│ │ BREADCRUMB │
│ SIDEBAR ├───────────────────────────────────────────────────┤
│ (240 px) │ │
│ │ HAUPTINHALT │
│ Globale Nav │ │
│ │ │
│ ───────── │ │
│ │ │
│ Phasen-Nav │ │
│ (wenn in │ │
│ Ausschrei- │ │
│ bung) │ │
│ │ │
└──────────────┴───────────────────────────────────────────────────┘
[💬 Feedback-Button]
Der Feedback-Button ist auf jeder Seite unten rechts persistent sichtbar und öffnet ein HTMX-Modal.
6.2 Sidebar — Globale Abschnitte
▸ Übersicht (Dashboard)
▸ Ausschreibungen
└─ + Neue Ausschreibung
▸ Bibliothek
├─ Dokumente
├─ Nachweise & Compliance
├─ Referenzen
├─ Leistungsblätter
└─ Entscheidungsregeln
▸ Partner
├─ Subunternehmer
└─ Dienstleistertypen
▸ Marktbegleiter
▸ Feedback-Backlog
▸ Administration
└─ Felder & Stammdaten ← EntityFieldConfig-Verwaltung
6.3 Sidebar — Phasen-Navigator (kontextuell)
Erscheint unterhalb der globalen Nav, wenn der Nutzer in einer Ausschreibung navigiert:
▾ [Ausschreibung: Titel]
┌─ ① Recherche & Unterlagen (aktuell: Phase 4)
├─ ② Teilnahmeentscheidung ✓
├─ ③ Detailanalyse ● ← aktive Phase hervorgehoben
├─ ④ Bieterfragen & Klärung ●
├─ ⑤ Preisgestaltung
├─ ⑥ Unterlagen finalisieren
├─ ⑦ Abgabe
└─ ⑧ Nachbetrachtung
Jede Phasennummer ist ein direkter Link. Die Phasenzahl im Status-Feld der Ausschreibung steuert nur die visuelle Hervorhebung — sie sperrt keinen Zugang.
Phasenindikator-Zustände:
| Symbol | Bedeutung |
|---|---|
○ |
Noch nicht begonnen |
● |
In Bearbeitung |
✓ |
Abgeschlossen |
⚠ |
Aktion erforderlich (überfällige Aufgaben o. Ä.) |
6.4 Dashboard
Das Dashboard zeigt aggregierte Kacheln und Listen:
- Kritische Fristen — Ausschreibungen mit
abgabe_bis≤ 14 Tage - Ohne Entscheidung — Status 1–2, älter als 3 Tage
- Überfällige Aufgaben — Aufgaben mit
frist < heuteund Status ≠ erledigt/verworfen - Laufende Ausschreibungen — Status 3–8
- Gewonnen / Verloren — Letzte 30 Tage
- Ablaufende Nachweise — Bibliothek-Nachweise mit
gueltig_bis≤ 60 Tage
7. URL-Struktur
/ # Dashboard
/ausschreibungen/ # Liste + Filter
/ausschreibungen/neu/ # Erstellen
/ausschreibungen/<id>/ # Detail / Phase 1 (Stammdaten + Dokumente)
/ausschreibungen/<id>/entscheidung/ # Phase 2 (Teilnahmeentscheidung)
/ausschreibungen/<id>/lose/ # Phase 3a (Lose)
/ausschreibungen/<id>/lose/<lot_id>/ # Los-Detail
/ausschreibungen/<id>/anforderungen/ # Phase 3b (Anforderungen)
/ausschreibungen/<id>/aufgaben/ # Phase 3c + 4 (Aufgaben)
/ausschreibungen/<id>/bieterfragen/ # Phase 4 (Bieterfragen)
/ausschreibungen/<id>/preise/ # Phase 5 (Preispunkte + Auswertung)
/ausschreibungen/<id>/abgabe/ # Phase 6 + 7 (Checkliste + Nachweis)
/ausschreibungen/<id>/nachbetrachtung/ # Phase 8 (Ergebnis + Lessons Learned)
/ausschreibungen/<id>/marktbegleiter/ # Passagen dieser Ausschreibung
/ausschreibungen/<id>/freigaben/ # Alle Freigaben dieser Ausschreibung
/bibliothek/dokumente/ # Standarddokumentbibliothek
/bibliothek/nachweise/ # Compliance-/QM-Nachweise
/bibliothek/nachweise/<id>/ # Nachweis-Detail
/bibliothek/referenzen/ # Referenzdatenbank
/bibliothek/referenzen/<id>/ # Referenz-Detail
/bibliothek/leistungsblaetter/ # Leistungsblätter
/bibliothek/entscheidungsregeln/ # Entscheidungsregelkatalog
/partner/subunternehmer/ # Subunternehmer-Katalog
/partner/subunternehmer/<id>/ # Subunternehmer-Detail
/partner/dienstleistertypen/ # Dienstleistertypen
/marktbegleiter/ # Marktbegleiter-Liste
/marktbegleiter/<id>/ # Profil + Passagen
/feedback/ # Feedback-Backlog (Admin-Sicht)
/admin/felder/ # EntityFieldConfig-Verwaltung
/admin/nutzer/ # Mitarbeiterverwaltung
8. Tailwind Design System
8.1 Konfiguration (tailwind.config.js)
export default {
content: ['./templates/**/*.html', './*/templates/**/*.html'],
theme: {
extend: {
colors: {
brand: {
50: '#f0f4ff',
100: '#dce7ff',
500: '#3b5bdb', // Primär
600: '#2f4ac7',
700: '#2541b2',
900: '#152d99',
},
phase: {
todo: '#94a3b8', // slate-400
active: '#3b82f6', // blue-500
done: '#22c55e', // green-500
warning: '#f59e0b', // amber-500
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
}
8.2 Status-Badges
Alle Status-Badges werden via Template-Tag {% status_badge value entity_type %} gerendert. Die Farben:
| Kategorie | Farbe |
|---|---|
| offen / ausstehend | bg-slate-100 text-slate-700 |
| in Bearbeitung | bg-blue-100 text-blue-700 |
| erledigt / gewonnen / freigegeben | bg-green-100 text-green-700 |
| überfällig / nicht erfüllbar / verloren | bg-red-100 text-red-700 |
| wartend / weitere Prüfung | bg-amber-100 text-amber-700 |
| archiviert / ersetzt | bg-gray-100 text-gray-500 |
8.3 Komponenten-Klassen (@layer components)
@layer components {
.card { @apply bg-white rounded-xl border border-slate-200 shadow-sm p-6; }
.btn-primary { @apply bg-brand-500 text-white px-4 py-2 rounded-lg hover:bg-brand-600 transition-colors; }
.btn-secondary { @apply bg-white text-slate-700 border border-slate-300 px-4 py-2 rounded-lg hover:bg-slate-50; }
.btn-danger { @apply bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700; }
.btn-ghost { @apply text-slate-600 px-3 py-2 rounded-lg hover:bg-slate-100; }
.field-row { @apply grid grid-cols-3 gap-4 py-3 border-b border-slate-100 last:border-0; }
.field-label { @apply text-sm font-medium text-slate-500 col-span-1; }
.field-value { @apply text-sm text-slate-900 col-span-2; }
.phase-badge { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
.phase-todo { @apply phase-badge bg-slate-200 text-slate-500; }
.phase-active { @apply phase-badge bg-brand-500 text-white; }
.phase-done { @apply phase-badge bg-green-500 text-white; }
.phase-warn { @apply phase-badge bg-amber-400 text-amber-900; }
.section-title { @apply text-base font-semibold text-slate-900 mb-4; }
.page-title { @apply text-2xl font-bold text-slate-900; }
.form-input { @apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent; }
.form-label { @apply block text-sm font-medium text-slate-700 mb-1; }
.table-base { @apply w-full text-sm text-left; }
.table-header { @apply bg-slate-50 text-slate-500 font-medium text-xs uppercase tracking-wide; }
.table-row { @apply border-t border-slate-100 hover:bg-slate-50 transition-colors; }
}
8.4 Typographie
- Überschriften:
font-bold+ entsprechendetext-*Größen (H1 =text-2xl, H2 =text-xl, H3 =text-base) - Beschreibungstexte:
text-sm text-slate-600 - Pflicht-Markierung: roter Stern
<span class="text-red-500">*</span>via Template-Tag
9. HTMX-Interaktionsmuster
9.1 Inline-Bearbeitung
Detailfelder rendern als <span> mit einem Edit-Icon. Klick → HTMX GET /…/edit/?field=titel → Inline-Formular ersetzt den <span>. Speichern → HTMX POST → aktualisierter <span> wird zurückgegeben.
<div id="field-titel" hx-target="#field-titel" hx-swap="outerHTML">
<span class="field-value">{{ obj.titel }}</span>
<button hx-get="{% url 'ausschreibung-edit-field' obj.pk %}?field=titel"
class="btn-ghost ml-2 opacity-0 group-hover:opacity-100">✎</button>
</div>
9.2 Listen mit Lazy-Loading
Lange Listen (Aufgaben, Anforderungen, Preispunkte) laden initial nur die ersten 25 Einträge. Ein "Mehr laden"-Button am Ende nutzt hx-get + hx-swap="beforeend" für paginiertes Nachladen.
9.3 Status-Wechsel ohne Reload
Status-Dropdowns in Listenzeilen nutzen hx-post + hx-target="closest tr" um nur die Zeile neu zu rendern.
9.4 Custom-Attribute-Panel
Jede Detailseite endet mit:
<section id="custom-attrs"
hx-get="{% url 'custom-attributes' content_type_id obj.pk %}"
hx-trigger="load"
hx-swap="innerHTML">
Lade...
</section>
Hinzufügen: Button öffnet Inline-Formular (HTMX). Speichern: Neues Attribut erscheint sofort in der Liste.
9.5 Feedback-Modal
<!-- Auf jeder Seite im base template -->
<button id="feedback-trigger"
hx-get="{% url 'feedback-modal' %}"
hx-target="#modal-container"
class="fixed bottom-6 right-6 btn-secondary shadow-lg rounded-full p-3">
💬
</button>
<div id="modal-container"></div>
Das Modal schreibt seite={{ request.path }} und ausschreibung={{ current_ausschreibung.id|default:'' }} als Hidden-Felder automatisch in den Feedbackeintrag.
10. Alpine.js UI-Zustände
Alpine.js (keine Server-Kommunikation) für:
- Sidebar Collapse:
x-data="{ open: true }" @click="open = !open" - Phasen-Accordion: Aufklappen einzelner Phasen-Abschnitte im Phasen-Navigator
- Bestätigungsdialoge:
x-show="confirm"bei Löschen-Aktionen - Dropdown-Menüs: Kontextmenüs in Listentabellen
- Feld-Ausblenden im Formular: Checkbox "Feld ausblenden" toggelt via Alpine das zugehörige Input sofort
11. Dokument-Storage
Verzeichnisstruktur
media/
└── dokumente/
└── <ausschreibung_id>/
├── leistungsverzeichnis/
├── vertragsunterlagen/
├── preisblatt/
└── ...
Upload-Validierung
Erlaubte MIME-Typen: application/pdf, application/msword, application/vnd.openxmlformats-officedocument.*, application/vnd.ms-excel, application/zip, image/png, image/jpeg.
Maximale Dateigröße: 50 MB (konfigurierbar via settings.MAX_UPLOAD_SIZE).
Versionierung
Neue Version = neues Dokument-Objekt. Das alte erhält status='ersetzt'. Beide bleiben in der DB und im Dateisystem erhalten. Die Detailansicht zeigt eine Versionshistorie-Liste.
Produktions-Abstraktion
DEFAULT_FILE_STORAGE in settings.py wechseln auf storages.backends.s3boto3.S3Boto3Storage — kein Code in den Apps muss geändert werden.
12. Phasen-Zustandsmaschine
Der Status der Ausschreibung ist ein Integer 1–13. Übergänge sind nicht erzwungen. Ein Utility gibt den "empfohlenen nächsten Status" zurück:
# ausschreibungen/services.py
NAECHSTER_STATUS = {
1: 2, # Angelegt → In Erstprüfung
2: 3, # Erstprüfung → Entscheidung
3: 4, # Entschieden → Detailanalyse
4: 5, # Detailanalyse → Bieterfragen
5: 6, # Bieterfragen → Preisgestaltung
6: 7, # Preisgestaltung → Unterlagenfinalisierung
7: 8, # Finalisierung → Bereit zur Abgabe
8: 9, # Bereit → Abgegeben
9: 10, # Abgegeben → (manuell: 10 gewonnen / 11 verloren / 12 aufgehoben)
}
ABSCHLUSS_STATUS = {10, 11, 12, 13}
def naechster_empfohlener_status(ausschreibung):
return NAECHSTER_STATUS.get(ausschreibung.status)
13. Sucharchitektur
Für v1: Django ORM icontains-Suche über Titel, Ausschreiber, Beschreibung. Globale Suchleiste gibt einen unified result set zurück:
# search/views.py
def global_search(request):
q = request.GET.get('q', '')
return {
'ausschreibungen': Ausschreibung.objects.filter(titel__icontains=q)[:5],
'aufgaben': Aufgabe.objects.filter(titel__icontains=q)[:5],
'dokumente': Dokument.objects.filter(dateiname__icontains=q)[:5],
'subunternehmer': Subunternehmer.objects.filter(name__icontains=q)[:5],
'marktbegleiter': Marktbegleiter.objects.filter(name__icontains=q)[:5],
}
PostgreSQL-Volltext (SearchVector) kann später ohne URL-Änderung eingebaut werden.
14. Fristenüberwachung
core/services.py stellt get_deadline_warnings(ausschreibung) bereit. In v1 wird dies bei jedem Seitenaufruf der Ausschreibungsdetailseite berechnet (kein Background-Job erforderlich):
def get_deadline_warnings(ausschreibung):
warnings = []
heute = date.today()
if ausschreibung.bieterfragen_bis:
delta = (ausschreibung.bieterfragen_bis - heute).days
if delta <= 3:
warnings.append({'typ': 'bieterfragen', 'tage': delta})
if ausschreibung.abgabe_bis:
delta = (ausschreibung.abgabe_bis.date() - heute).days
if delta <= 14:
warnings.append({'typ': 'abgabe', 'tage': delta})
for aufgabe in ausschreibung.aufgabe_set.filter(status__in=['offen', 'in_bearbeitung']):
if aufgabe.frist and aufgabe.frist < heute:
warnings.append({'typ': 'aufgabe', 'aufgabe': aufgabe})
return warnings
15. Erweiterungspunkte (spätere Ausbaustufen)
| Bereich | Erweiterungsweg |
|---|---|
| Volltext-Dokumentenanalyse | Apache Tika / pdfplumber auf Upload-Signal; extrahierter Text in Dokument.volltext speichern |
| KI-Assistenz | Claude API via Celery-Task; gibt strukturierten JSON-Vorschlag zurück; Nutzer bestätigt manuell |
| E-Mail-Benachrichtigungen | Django Signals + Celery; Template pro Benachrichtigungstyp |
| SharePoint-Integration | bibliothek-App kann externe Dokument-URLs speichern (bereits als quelle-Feld angelegt) |
| Partnerportal | Separates Django-Projekt mit beschränkten API-Endpoints; Authentifizierung via Token |
| Mehrsprachigkeit | Django i18n; alle Template-Strings in {% trans %} |
| Mandantenfähigkeit | Ausschreibung bekommt FK auf Mandant; alle Querys filtern auf request.user.mandant |
| SSO / SAML | django-allauth oder python-saml; kein Auth-Code in den Fachapps |