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

36 KiB
Raw Permalink Blame History

Architecture Blueprint — Vergabe Teilnahme

Version: 1.0
Datum: 7. Mai 2026
Grundlage: ProductRequirementsDocument.md


1. Leitprinzipien

Prinzip Konsequenz
Phasengeführt, nie phasengesperrt Die 8 Phasen sind Navigation und Orientierung, keine Zugangssperren. Jedes Element ist jederzeit erreichbar.
Kein Zwang zur Vollständigkeit Pflichtfelder gibt es nur an echten Systemgrenzen (z. B. Abgabenachweis). Alle anderen Felder können leer bleiben.
Anpassbare Datenstruktur Jede Entität erlaubt das globale Ausblenden ungenutzter Felder und das Hinzufügen beliebiger Schlüssel-Wert-Attribute.
Manuelle Erfassung first Keine Automatisierung in v1. Alle Felder werden manuell gepflegt.
Server-first, progressive Enhancement Seitenrendering auf dem Server. HTMX für partielle Aktualisierungen. Alpine.js für reine UI-Zustände.

2. Technology Stack

Schicht Technologie Begründung
Sprache Python 3.12+ .gitignore-Konvention; erprobtes Ökosystem
Paketmanager uv Geschwindigkeit, lockfile, Python-Versions-Management
Web-Framework Django 5.x ORM, Admin, Auth, ContentType-Framework — alle benötigt
Template-Rendering Django Templates Server-side, kein Build-Step für Templates
Reaktivität HTMX 2.x Partielle DOM-Updates ohne SPA-Overhead
UI-Zustandsverwaltung Alpine.js 3.x Accordions, Dropdowns, Modals, Inline-Toggles
CSS Tailwind CSS 4.x Utility-first; Design-Token-System (s. Abschnitt 8)
CSS-Build PostCSS + Vite Tailwind JIT, Minification
Datenbank PostgreSQL 16+ JSONB, volltextsuche, referentielle Integrität
Datei-Storage Django FileField (lokal) Einfach; abstrakt genug für S3 via django-storages later
Background-Tasks Celery + Redis Fristenprüfung, Benachrichtigungen (optional in v1)
Testing pytest-django Fixture-basiert; kein Mock der DB

3. Django-App-Struktur

Das Projekt ist in fachliche Apps aufgeteilt. Technische Querschnittsthemen liegen in core.

vergabe_teilnahme/          # Django-Projekt-Root
├── core/                   # Querschnittslogik
│   ├── models.py           # FlexibleModel-Mixin, CustomAttribute, EntityFieldConfig, Freigabe
│   ├── mixins.py           # FlexFieldMixin für Views
│   ├── templatetags/       # render_field, flex_fields, phase_badge, status_badge
│   └── services.py         # weighted_average(), deadline_warnings()
├── ausschreibungen/        # Ausschreibung, Teilnahmeentscheidung
├── lose/                   # Los, Anforderung, Auflage
├── aufgaben/               # Aufgabe (Offener Punkt), Bieterfrage
├── dokumente/              # Dokument, Datei-Upload, Versionierung
├── preise/                 # Preispunkt, Preismodell, Auswertungen
├── partner/                # Subunternehmer, Dienstleistertyp
├── bibliothek/             # Nachweis, Referenz, Leistungsblatt, Entscheidungsregel
├── marktbegleiter/         # Marktbegleiter, Ausschreibungspassage
├── nachbetrachtung/        # Nachbetrachtung (Phase 8)
├── feedback/               # Feedbackeintrag
└── accounts/               # Mitarbeiter (erweitertes Django-User-Modell)

App-Abhängigkeiten (vereinfacht)

core  ←── alle Apps
accounts  ←── ausschreibungen, lose, aufgaben, dokumente, preise, partner
ausschreibungen  ←── lose, aufgaben, dokumente, preise, marktbegleiter, nachbetrachtung
bibliothek  ←── lose (Anforderungs-Nachweise), dokumente (Standarddokumente)
partner  ←── lose, aufgaben, preise

4. Flexible Felder — Das Anpassungssystem

Jede Entität unterstützt zwei Anpassungsmechanismen:

4.1 Felder ausblenden: EntityFieldConfig

Definiert global (nicht pro Instanz), welche eingebauten Felder einer Entität ausgeblendet werden. Einmal konfiguriert — wirkt für alle Instanzen dieses Typs.

# core/models.py

class EntityFieldConfig(Model):
    entity_type   = CharField(max_length=100)   # z. B. "subunternehmer", "anforderung"
    field_name    = CharField(max_length=100)   # interner Feldname
    is_hidden     = BooleanField(default=False)
    display_label = CharField(max_length=200, blank=True)  # Umbenennungsoption
    sort_order    = PositiveSmallIntegerField(default=0)

    class Meta:
        unique_together = ('entity_type', 'field_name')

Template-Tag {% render_field obj "mobilnummer" %} prüft EntityFieldConfig und rendert das Feld nur, wenn is_hidden=False.

Verwaltungsoberfläche: Unter /admin/felder/ können Administratoren für jeden Entitätstyp in einer Tabelle alle Felder sehen, ein-/ausblenden und umbenennen. Keine Code-Änderung erforderlich.

4.2 Benutzerdefinierte Schlüssel-Wert-Attribute: CustomAttribute

Für jede Instanz beliebig erweiterbar. Verwendet Django's ContentType-Framework (Generic FK).

# core/models.py

class CustomAttribute(Model):
    DATA_TYPES = [
        ('text',    'Text'),
        ('number',  'Zahl'),
        ('date',    'Datum'),
        ('boolean', 'Ja / Nein'),
        ('url',     'Link'),
        ('email',   'E-Mail'),
    ]

    content_type  = ForeignKey(ContentType, on_delete=CASCADE)
    object_id     = PositiveIntegerField()
    content_object = GenericForeignKey('content_type', 'object_id')

    key        = CharField(max_length=100)   # interner Schlüssel (slug)
    label      = CharField(max_length=200)   # Anzeigebezeichnung
    value      = TextField(blank=True)
    data_type  = CharField(max_length=20, choices=DATA_TYPES, default='text')
    sort_order = PositiveSmallIntegerField(default=0)
    created_at = DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ['sort_order', 'created_at']
        indexes  = [Index(fields=['content_type', 'object_id'])]

UX: Jede Detailseite zeigt am Ende einen Abschnitt "Weitere Attribute" mit einem "+ Attribut hinzufügen"-Button (HTMX-Inline-Formular). Bestehende Attribute können bearbeitet, umsortiert und gelöscht werden.

4.3 Mixin für alle Entitäts-Modelle

# core/models.py

class FlexibleModel(Model):
    """Abstract base für alle Fachentitäten."""

    custom_attributes = GenericRelation(CustomAttribute)

    def get_visible_fields(self):
        hidden = EntityFieldConfig.objects.filter(
            entity_type=self._meta.model_name,
            is_hidden=True
        ).values_list('field_name', flat=True)
        return [f for f in self._meta.get_fields() if f.name not in hidden]

    class Meta:
        abstract = True

5. Datenmodelle

5.1 Ausschreibung

class Ausschreibung(FlexibleModel):
    STATUS_CHOICES = [
        (1,  'Recherchiert / angelegt'),
        (2,  'In Erstprüfung'),
        (3,  'Teilnahme entschieden'),
        (4,  'Detailanalyse läuft'),
        (5,  'Klärung / Bieterfragen läuft'),
        (6,  'Preisgestaltung läuft'),
        (7,  'Unterlagenfinalisierung läuft'),
        (8,  'Bereit zur Abgabe'),
        (9,  'Abgegeben'),
        (10, 'Zuschlag gewonnen'),
        (11, 'Zuschlag verloren'),
        (12, 'Aufgehoben / zurückgezogen'),
        (13, 'Archiviert'),
    ]
    TEILNAHME_CHOICES = [
        ('offen',       'Offen'),
        ('teilnahme',   'Teilnahme'),
        ('ablehnung',   'Nichtteilnahme'),
        ('pruefung',    'Weitere Prüfung erforderlich'),
    ]

    titel                = CharField(max_length=400)
    ausschreiber         = CharField(max_length=300)
    plattform            = CharField(max_length=200, blank=True)
    plattform_link       = URLField(blank=True)
    ansprechpartner      = CharField(max_length=300, blank=True)
    hauptverantwortung   = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
                                      on_delete=SET_NULL, related_name='verantwortete_ausschreibungen')
    status               = PositiveSmallIntegerField(choices=STATUS_CHOICES, default=1)
    teilnahmeentscheidung = CharField(max_length=20, choices=TEILNAHME_CHOICES, default='offen')
    beschreibung         = TextField(blank=True)
    strategische_relevanz = TextField(blank=True)
    ergebnis             = CharField(max_length=20, blank=True)  # gewonnen, verloren, aufgehoben, offen
    archiviert           = BooleanField(default=False)

    # Fristen
    bieterfragen_bis  = DateField(null=True, blank=True)
    abgabe_bis        = DateTimeField(null=True, blank=True)
    zuschlag_bis      = DateField(null=True, blank=True)
    produktiv_bis     = DateField(null=True, blank=True)

    erstellt_am    = DateTimeField(auto_now_add=True)
    geaendert_am   = DateTimeField(auto_now=True)

5.2 Los

class Los(FlexibleModel):
    ausschreibung  = ForeignKey(Ausschreibung, on_delete=CASCADE, related_name='lose')
    losnummer      = CharField(max_length=50)
    lostitel       = CharField(max_length=300)
    beschreibung   = TextField(blank=True)
    abgrenzung     = TextField(blank=True)
    zustaendiger   = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
    teilnahme      = BooleanField(null=True)   # None = noch nicht entschieden
    status         = CharField(max_length=50, blank=True)

    class Meta:
        ordering = ['losnummer']

5.3 Anforderung / Auflage

class Anforderung(FlexibleModel):
    VERBINDLICHKEIT = [('muss', 'Muss'), ('soll', 'Soll'), ('kann', 'Kann')]
    ERFUELLUNGSSTATUS = [
        ('offen',              'Offen'),
        ('in_pruefung',        'In Prüfung'),
        ('erfuellbar',         'Erfüllbar'),
        ('mit_subunternehmer', 'Erfüllbar mit Subunternehmer'),
        ('nicht_erfuellbar',   'Nicht erfüllbar'),
        ('klaerung',           'Klärung erforderlich'),
    ]

    ausschreibung      = ForeignKey(Ausschreibung, on_delete=CASCADE)
    los                = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
    titel              = CharField(max_length=400)
    beschreibung       = TextField(blank=True)
    quelle_im_dokument = CharField(max_length=300, blank=True)
    kategorie          = CharField(max_length=100, blank=True)
    verbindlichkeit    = CharField(max_length=10, choices=VERBINDLICHKEIT, default='muss')
    ausschlusskriterium = BooleanField(default=False)
    bewertungskriterium = BooleanField(default=False)
    zustaendiger       = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
    erfuellungsstatus  = CharField(max_length=30, choices=ERFUELLUNGSSTATUS, default='offen')
    nachweis_erforderlich = BooleanField(default=False)
    dokumente          = ManyToManyField('dokumente.Dokument', blank=True)
    nachweise          = ManyToManyField('bibliothek.Nachweis', blank=True)

5.4 Aufgabe (Offener Punkt)

class Aufgabe(FlexibleModel):
    TYP_CHOICES = [
        ('fachlich',           'Fachlich'),
        ('rechtlich',          'Rechtlich'),
        ('kaufmaennisch',      'Kaufmännisch'),
        ('technisch',          'Technisch'),
        ('subunternehmer',     'Subunternehmerklärung'),
        ('dokument',           'Dokumentenaufgabe'),
        ('preis',              'Preisaufgabe'),
    ]
    STATUS_CHOICES = [
        ('offen',              'Offen'),
        ('in_bearbeitung',     'In Bearbeitung'),
        ('wartend_intern',     'Wartend auf intern'),
        ('wartend_sub',        'Wartend auf Subunternehmer'),
        ('wartend_ausschreiber', 'Wartend auf Ausschreiber'),
        ('erledigt',           'Erledigt'),
        ('verworfen',          'Verworfen'),
        ('ueberfaellig',       'Überfällig'),
    ]

    ausschreibung  = ForeignKey(Ausschreibung, on_delete=CASCADE)
    los            = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
    anforderung    = ForeignKey(Anforderung, null=True, blank=True, on_delete=SET_NULL)
    bieterfrage    = ForeignKey('aufgaben.Bieterfrage', null=True, blank=True, on_delete=SET_NULL)
    dokument       = ForeignKey('dokumente.Dokument', null=True, blank=True, on_delete=SET_NULL)

    titel          = CharField(max_length=400)
    beschreibung   = TextField(blank=True)
    typ            = CharField(max_length=20, choices=TYP_CHOICES)
    prioritaet     = PositiveSmallIntegerField(default=2)   # 1=hoch, 2=mittel, 3=niedrig
    frist          = DateField(null=True, blank=True)
    verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
    status         = CharField(max_length=30, choices=STATUS_CHOICES, default='offen')
    ergebnis       = TextField(blank=True)

5.5 Bieterfrage

class Bieterfrage(FlexibleModel):
    STATUS_CHOICES = [
        ('entwurf',    'Entwurf'),
        ('abgestimmt', 'Intern abgestimmt'),
        ('eingereicht','Eingereicht'),
        ('beantwortet','Beantwortet'),
        ('eingearbeitet', 'Eingearbeitet'),
    ]

    ausschreibung      = ForeignKey(Ausschreibung, on_delete=CASCADE)
    anforderung        = ForeignKey(Anforderung, null=True, blank=True, on_delete=SET_NULL)
    dokument           = ForeignKey('dokumente.Dokument', null=True, blank=True, on_delete=SET_NULL)
    frage              = TextField()
    hintergrund        = TextField(blank=True)
    verantwortlicher   = ForeignKey('accounts.Mitarbeiter', null=True, blank=True, on_delete=SET_NULL)
    status             = CharField(max_length=20, choices=STATUS_CHOICES, default='entwurf')
    prioritaet         = PositiveSmallIntegerField(default=2)
    einreichungsdatum  = DateField(null=True, blank=True)
    antwort            = TextField(blank=True)
    auswirkung_angebot = TextField(blank=True)
    eingearbeitet      = BooleanField(default=False)

5.6 Dokument

class Dokument(FlexibleModel):
    STATUS_CHOICES = [
        ('hochgeladen',    'Hochgeladen'),
        ('zu_pruefen',     'Zu prüfen'),
        ('in_bearbeitung', 'In Bearbeitung'),
        ('geprueft',       'Geprüft'),
        ('freigegeben',    'Freigegeben'),
        ('final_abgegeben','Final abgegeben'),
        ('ersetzt',        'Ersetzt / veraltet'),
        ('archiviert',     'Archiviert'),
    ]
    KATEGORIE_CHOICES = [
        ('leistungsverzeichnis', 'Leistungsverzeichnis'),
        ('vertragsunterlagen',   'Vertragsunterlagen'),
        ('preisblatt',           'Preisblatt'),
        ('formblatt',            'Formblatt'),
        ('eignungsnachweis',     'Eignungsnachweis'),
        ('technische_anlage',    'Technische Anlage'),
        ('bieterinformation',    'Bieterinformation'),
        ('sonstiges',            'Sonstiges'),
    ]

    ausschreibung  = ForeignKey(Ausschreibung, null=True, blank=True, on_delete=SET_NULL)
    los            = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
    datei          = FileField(upload_to='dokumente/%Y/%m/')
    dateiname      = CharField(max_length=300)
    kategorie      = CharField(max_length=50, choices=KATEGORIE_CHOICES)
    version        = CharField(max_length=50, default='1.0')
    quelle         = CharField(max_length=200, blank=True)
    status         = CharField(max_length=20, choices=STATUS_CHOICES, default='hochgeladen')
    verantwortlicher = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
                                   on_delete=SET_NULL, related_name='verantwortete_dokumente')
    pruefer        = ForeignKey('accounts.Mitarbeiter', null=True, blank=True,
                                on_delete=SET_NULL, related_name='zu_pruefende_dokumente')
    finale_abgabeversion = BooleanField(default=False)
    upload_datum   = DateTimeField(auto_now_add=True)

5.7 Preispunkt

from decimal import Decimal

class Preispunkt(FlexibleModel):
    ausschreibung     = ForeignKey(Ausschreibung, on_delete=CASCADE)
    los               = ForeignKey(Los, null=True, blank=True, on_delete=SET_NULL)
    leistungstyp      = CharField(max_length=200)
    konkrete_leistung = CharField(max_length=400)
    mengeneinheit     = CharField(max_length=50, blank=True)
    menge             = DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
    einzelpreis       = DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    gesamtpreis       = DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    waehrung          = CharField(max_length=3, default='EUR')
    preisstand        = DateField(null=True, blank=True)
    wiederkehrend     = BooleanField(default=False)
    laufzeitbezug     = CharField(max_length=100, blank=True)
    subunternehmeranteil = BooleanField(default=False)
    subunternehmer    = ForeignKey('partner.Subunternehmer', null=True, blank=True, on_delete=SET_NULL)

    # Vergleichsgewicht: 0.0  2.0, Default 1.0
    vergleichsgewicht       = DecimalField(max_digits=3, decimal_places=1, default=Decimal('1.0'))
    gewichtungsbegruendung  = TextField(blank=True)
    kommentar               = TextField(blank=True)

    # Kontextmarkierungen für Auswertungen
    ausschreibung_gewonnen  = BooleanField(null=True)

Gewichteter Durchschnitt (Service-Funktion in preise/services.py):

from decimal import Decimal

def gewichteter_durchschnitt(preispunkte, feld='einzelpreis'):
    relevante = [p for p in preispunkte
                 if getattr(p, feld) is not None and p.vergleichsgewicht > 0]
    summe_gewichte = sum(p.vergleichsgewicht for p in relevante)
    if summe_gewichte == 0:
        return None
    summe = sum(getattr(p, feld) * p.vergleichsgewicht for p in relevante)
    return {
        'wert':             summe / summe_gewichte,
        'summe_gewichte':   summe_gewichte,
        'anzahl':           len(relevante),
        'minimum':          min(getattr(p, feld) for p in relevante),
        'maximum':          max(getattr(p, feld) for p in relevante),
    }

5.8 Freigabe (generic)

class Freigabe(Model):
    TYP_CHOICES = [
        ('teilnahme',      'Teilnahmefreigabe'),
        ('ablehnung',      'Nichtteilnahmefreigabe'),
        ('recht',          'Rechts-/Compliance-Freigabe'),
        ('preis',          'Preisfreigabe'),
        ('abgabe',         'Finale Abgabefreigabe'),
        ('standarddokument','Freigabe Standarddokument'),
        ('referenz',       'Freigabe Referenzunterlage'),
    ]
    STATUS_CHOICES = [
        ('erteilt',    'Erteilt'),
        ('abgelehnt',  'Abgelehnt'),
        ('ausstehend', 'Ausstehend'),
    ]

    content_type      = ForeignKey(ContentType, on_delete=CASCADE)
    object_id         = PositiveIntegerField()
    content_object    = GenericForeignKey('content_type', 'object_id')

    freigabe_typ      = CharField(max_length=30, choices=TYP_CHOICES)
    freigebende_person = ForeignKey(settings.AUTH_USER_MODEL, on_delete=PROTECT)
    status            = CharField(max_length=20, choices=STATUS_CHOICES, default='erteilt')
    kommentar         = TextField(blank=True)
    timestamp         = DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ['-timestamp']

5.9 Subunternehmer

class Subunternehmer(FlexibleModel):
    PRAEFERENZ = [('bevorzugt', 'Bevorzugt'), ('zugelassen', 'Zugelassen'), ('gesperrt', 'Gesperrt')]

    name              = CharField(max_length=300)
    dienstleistertyp  = ForeignKey('partner.Dienstleistertyp', null=True, blank=True, on_delete=SET_NULL)
    leistungsbereiche = TextField(blank=True)
    ansprechpartner   = CharField(max_length=200, blank=True)
    email             = EmailField(blank=True)
    mobilnummer       = CharField(max_length=50, blank=True)
    adresse           = TextField(blank=True)
    webseite          = URLField(blank=True)
    zertifizierungen  = TextField(blank=True)
    praeferenz        = CharField(max_length=20, choices=PRAEFERENZ, default='zugelassen')
    bewertung         = TextField(blank=True)
    typische_preislogik = TextField(blank=True)
    bemerkungen       = TextField(blank=True)

5.10 Weitere Entitäten (Kurzform)

Entität App Besonderheiten
Dienstleistertyp partner Katalog; kein FK auf Ausschreibung
Nachweis bibliothek gueltig_ab, gueltig_bis → Ablaufwarnung
Referenz bibliothek whitepaper FileField; Freigabestatus via Freigabe
Leistungsblatt bibliothek Versioniert; zugehörige Referenzen M2M
Entscheidungsregel bibliothek Kein Automat; liefert Empfehlung + Score
Marktbegleiter marktbegleiter Globaler Katalog
Ausschreibungspassage marktbegleiter FK auf Ausschreibung, Dokument, Marktbegleiter; Verlässlichkeitsscore 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)

export default {
  content: ['./templates/**/*.html', './*/templates/**/*.html'],
  theme: {
    extend: {
      colors: {
        brand: {
          50:  '#f0f4ff',
          100: '#dce7ff',
          500: '#3b5bdb',   // Primär
          600: '#2f4ac7',
          700: '#2541b2',
          900: '#152d99',
        },
        phase: {
          todo:    '#94a3b8',   // slate-400
          active:  '#3b82f6',   // blue-500
          done:    '#22c55e',   // green-500
          warning: '#f59e0b',   // amber-500
        },
      },
      fontFamily: {
        sans: ['Inter', 'system-ui', 'sans-serif'],
      },
    },
  },
}

8.2 Status-Badges

Alle Status-Badges werden via Template-Tag {% status_badge value entity_type %} gerendert. Die Farben:

Kategorie Farbe
offen / ausstehend bg-slate-100 text-slate-700
in Bearbeitung bg-blue-100 text-blue-700
erledigt / gewonnen / freigegeben bg-green-100 text-green-700
überfällig / nicht erfüllbar / verloren bg-red-100 text-red-700
wartend / weitere Prüfung bg-amber-100 text-amber-700
archiviert / ersetzt bg-gray-100 text-gray-500

8.3 Komponenten-Klassen (@layer components)

@layer components {
  .card          { @apply bg-white rounded-xl border border-slate-200 shadow-sm p-6; }
  .btn-primary   { @apply bg-brand-500 text-white px-4 py-2 rounded-lg hover:bg-brand-600 transition-colors; }
  .btn-secondary { @apply bg-white text-slate-700 border border-slate-300 px-4 py-2 rounded-lg hover:bg-slate-50; }
  .btn-danger    { @apply bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700; }
  .btn-ghost     { @apply text-slate-600 px-3 py-2 rounded-lg hover:bg-slate-100; }

  .field-row     { @apply grid grid-cols-3 gap-4 py-3 border-b border-slate-100 last:border-0; }
  .field-label   { @apply text-sm font-medium text-slate-500 col-span-1; }
  .field-value   { @apply text-sm text-slate-900 col-span-2; }

  .phase-badge   { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
  .phase-todo    { @apply phase-badge bg-slate-200 text-slate-500; }
  .phase-active  { @apply phase-badge bg-brand-500 text-white; }
  .phase-done    { @apply phase-badge bg-green-500 text-white; }
  .phase-warn    { @apply phase-badge bg-amber-400 text-amber-900; }

  .section-title { @apply text-base font-semibold text-slate-900 mb-4; }
  .page-title    { @apply text-2xl font-bold text-slate-900; }

  .form-input    { @apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm
                          focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent; }
  .form-label    { @apply block text-sm font-medium text-slate-700 mb-1; }

  .table-base    { @apply w-full text-sm text-left; }
  .table-header  { @apply bg-slate-50 text-slate-500 font-medium text-xs uppercase tracking-wide; }
  .table-row     { @apply border-t border-slate-100 hover:bg-slate-50 transition-colors; }
}

8.4 Typographie

  • Überschriften: font-bold + 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.

<div id="field-titel" hx-target="#field-titel" hx-swap="outerHTML">
  <span class="field-value">{{ obj.titel }}</span>
  <button hx-get="{% url 'ausschreibung-edit-field' obj.pk %}?field=titel"
          class="btn-ghost ml-2 opacity-0 group-hover:opacity-100"></button>
</div>

9.2 Listen mit Lazy-Loading

Lange Listen (Aufgaben, Anforderungen, Preispunkte) laden initial nur die ersten 25 Einträge. Ein "Mehr laden"-Button am Ende nutzt hx-get + hx-swap="beforeend" für paginiertes Nachladen.

9.3 Status-Wechsel ohne Reload

Status-Dropdowns in Listenzeilen nutzen hx-post + hx-target="closest tr" um nur die Zeile neu zu rendern.

9.4 Custom-Attribute-Panel

Jede Detailseite endet mit:

<section id="custom-attrs"
         hx-get="{% url 'custom-attributes' content_type_id obj.pk %}"
         hx-trigger="load"
         hx-swap="innerHTML">
  Lade...
</section>

Hinzufügen: Button öffnet Inline-Formular (HTMX). Speichern: Neues Attribut erscheint sofort in der Liste.

9.5 Feedback-Modal

<!-- Auf jeder Seite im base template -->
<button id="feedback-trigger"
        hx-get="{% url 'feedback-modal' %}"
        hx-target="#modal-container"
        class="fixed bottom-6 right-6 btn-secondary shadow-lg rounded-full p-3">
  💬
</button>
<div id="modal-container"></div>

Das Modal schreibt seite={{ request.path }} und ausschreibung={{ current_ausschreibung.id|default:'' }} als Hidden-Felder automatisch in den Feedbackeintrag.


10. Alpine.js UI-Zustände

Alpine.js (keine Server-Kommunikation) für:

  • Sidebar Collapse: x-data="{ open: true }" @click="open = !open"
  • Phasen-Accordion: Aufklappen einzelner Phasen-Abschnitte im Phasen-Navigator
  • Bestätigungsdialoge: x-show="confirm" bei Löschen-Aktionen
  • Dropdown-Menüs: Kontextmenüs in Listentabellen
  • Feld-Ausblenden im Formular: Checkbox "Feld ausblenden" toggelt via Alpine das zugehörige Input sofort

11. Dokument-Storage

Verzeichnisstruktur

media/
└── dokumente/
    └── <ausschreibung_id>/
        ├── leistungsverzeichnis/
        ├── vertragsunterlagen/
        ├── preisblatt/
        └── ...

Upload-Validierung

Erlaubte MIME-Typen: application/pdf, application/msword, application/vnd.openxmlformats-officedocument.*, application/vnd.ms-excel, application/zip, image/png, image/jpeg.

Maximale Dateigröße: 50 MB (konfigurierbar via settings.MAX_UPLOAD_SIZE).

Versionierung

Neue Version = neues Dokument-Objekt. Das alte erhält status='ersetzt'. Beide bleiben in der DB und im Dateisystem erhalten. Die Detailansicht zeigt eine Versionshistorie-Liste.

Produktions-Abstraktion

DEFAULT_FILE_STORAGE in settings.py wechseln auf storages.backends.s3boto3.S3Boto3Storage — kein Code in den Apps muss geändert werden.


12. Phasen-Zustandsmaschine

Der Status der Ausschreibung ist ein Integer 113. Übergänge sind nicht erzwungen. Ein Utility gibt den "empfohlenen nächsten Status" zurück:

# ausschreibungen/services.py

NAECHSTER_STATUS = {
    1:  2,   # Angelegt → In Erstprüfung
    2:  3,   # Erstprüfung → Entscheidung
    3:  4,   # Entschieden → Detailanalyse
    4:  5,   # Detailanalyse → Bieterfragen
    5:  6,   # Bieterfragen → Preisgestaltung
    6:  7,   # Preisgestaltung → Unterlagenfinalisierung
    7:  8,   # Finalisierung → Bereit zur Abgabe
    8:  9,   # Bereit → Abgegeben
    9:  10,  # Abgegeben → (manuell: 10 gewonnen / 11 verloren / 12 aufgehoben)
}

ABSCHLUSS_STATUS = {10, 11, 12, 13}

def naechster_empfohlener_status(ausschreibung):
    return NAECHSTER_STATUS.get(ausschreibung.status)

13. Sucharchitektur

Für v1: Django ORM icontains-Suche über Titel, Ausschreiber, Beschreibung. Globale Suchleiste gibt einen unified result set zurück:

# search/views.py

def global_search(request):
    q = request.GET.get('q', '')
    return {
        'ausschreibungen': Ausschreibung.objects.filter(titel__icontains=q)[:5],
        'aufgaben':        Aufgabe.objects.filter(titel__icontains=q)[:5],
        'dokumente':       Dokument.objects.filter(dateiname__icontains=q)[:5],
        'subunternehmer':  Subunternehmer.objects.filter(name__icontains=q)[:5],
        'marktbegleiter':  Marktbegleiter.objects.filter(name__icontains=q)[:5],
    }

PostgreSQL-Volltext (SearchVector) kann später ohne URL-Änderung eingebaut werden.


14. Fristenüberwachung

core/services.py stellt get_deadline_warnings(ausschreibung) bereit. In v1 wird dies bei jedem Seitenaufruf der Ausschreibungsdetailseite berechnet (kein Background-Job erforderlich):

def get_deadline_warnings(ausschreibung):
    warnings = []
    heute = date.today()
    if ausschreibung.bieterfragen_bis:
        delta = (ausschreibung.bieterfragen_bis - heute).days
        if delta <= 3:
            warnings.append({'typ': 'bieterfragen', 'tage': delta})
    if ausschreibung.abgabe_bis:
        delta = (ausschreibung.abgabe_bis.date() - heute).days
        if delta <= 14:
            warnings.append({'typ': 'abgabe', 'tage': delta})
    for aufgabe in ausschreibung.aufgabe_set.filter(status__in=['offen', 'in_bearbeitung']):
        if aufgabe.frist and aufgabe.frist < heute:
            warnings.append({'typ': 'aufgabe', 'aufgabe': aufgabe})
    return warnings

15. Erweiterungspunkte (spätere Ausbaustufen)

Bereich Erweiterungsweg
Volltext-Dokumentenanalyse Apache Tika / pdfplumber auf Upload-Signal; extrahierter Text in Dokument.volltext speichern
KI-Assistenz Claude API via Celery-Task; gibt strukturierten JSON-Vorschlag zurück; Nutzer bestätigt manuell
E-Mail-Benachrichtigungen Django Signals + Celery; Template pro Benachrichtigungstyp
SharePoint-Integration bibliothek-App kann externe Dokument-URLs speichern (bereits als quelle-Feld angelegt)
Partnerportal Separates Django-Projekt mit beschränkten API-Endpoints; Authentifizierung via Token
Mehrsprachigkeit Django i18n; alle Template-Strings in {% trans %}
Mandantenfähigkeit Ausschreibung bekommt FK auf Mandant; alle Querys filtern auf request.user.mandant
SSO / SAML django-allauth oder python-saml; kein Auth-Code in den Fachapps