Established workplans

This commit is contained in:
tegwick 2026-05-08 07:54:06 +02:00
parent 026f7c0db1
commit 315143a6fc
16 changed files with 5492 additions and 0 deletions

View file

@ -0,0 +1,892 @@
# 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 |