generated from coulomb/repo-seed
893 lines
36 KiB
Markdown
893 lines
36 KiB
Markdown
# 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 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 < heute` und 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`)
|
||
|
||
```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 1–13. Ü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 |
|