Files
vergabe-teilnahme/wiki/ArchitectureBlueprint.md
2026-05-08 07:54:06 +02:00

893 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```python
# 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).
```python
# 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
```python
# 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
```python
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
```python
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
```python
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)
```python
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
```python
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
```python
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
```python
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`):
```python
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)
```python
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
```python
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 010 |
| `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 12, älter als 3 Tage
- **Überfällige Aufgaben** — Aufgaben mit `frist < heute` und Status ≠ erledigt/verworfen
- **Laufende Ausschreibungen** — Status 38
- **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`)
```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`)
```css
@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` + entsprechende `text-*` 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.
```html
<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:
```html
<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
```html
<!-- 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 113. Übergänge sind nicht erzwungen. Ein Utility gibt den "empfohlenen nächsten Status" zurück:
```python
# 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:
```python
# 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):
```python
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 |