# 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// # Detail / Phase 1 (Stammdaten + Dokumente) /ausschreibungen//entscheidung/ # Phase 2 (Teilnahmeentscheidung) /ausschreibungen//lose/ # Phase 3a (Lose) /ausschreibungen//lose// # Los-Detail /ausschreibungen//anforderungen/ # Phase 3b (Anforderungen) /ausschreibungen//aufgaben/ # Phase 3c + 4 (Aufgaben) /ausschreibungen//bieterfragen/ # Phase 4 (Bieterfragen) /ausschreibungen//preise/ # Phase 5 (Preispunkte + Auswertung) /ausschreibungen//abgabe/ # Phase 6 + 7 (Checkliste + Nachweis) /ausschreibungen//nachbetrachtung/ # Phase 8 (Ergebnis + Lessons Learned) /ausschreibungen//marktbegleiter/ # Passagen dieser Ausschreibung /ausschreibungen//freigaben/ # Alle Freigaben dieser Ausschreibung /bibliothek/dokumente/ # Standarddokumentbibliothek /bibliothek/nachweise/ # Compliance-/QM-Nachweise /bibliothek/nachweise// # Nachweis-Detail /bibliothek/referenzen/ # Referenzdatenbank /bibliothek/referenzen// # Referenz-Detail /bibliothek/leistungsblaetter/ # Leistungsblätter /bibliothek/entscheidungsregeln/ # Entscheidungsregelkatalog /partner/subunternehmer/ # Subunternehmer-Katalog /partner/subunternehmer// # Subunternehmer-Detail /partner/dienstleistertypen/ # Dienstleistertypen /marktbegleiter/ # Marktbegleiter-Liste /marktbegleiter// # 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 `*` via Template-Tag --- ## 9. HTMX-Interaktionsmuster ### 9.1 Inline-Bearbeitung Detailfelder rendern als `` mit einem Edit-Icon. Klick → HTMX GET `/…/edit/?field=titel` → Inline-Formular ersetzt den ``. Speichern → HTMX POST → aktualisierter `` wird zurückgegeben. ```html
{{ obj.titel }}
``` ### 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
Lade...
``` Hinzufügen: Button öffnet Inline-Formular (HTMX). Speichern: Neues Attribut erscheint sofort in der Liste. ### 9.5 Feedback-Modal ```html ``` 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/ └── / ├── 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 |