Skip to content

Feature: Pakete (Leistungspakete für Angebote)

Normativer Rahmen

Normativ für Pakete als eigene Stammdatenseite, gewerksneutrale Mengengrößen, Angebotsgruppen (Titel) und die Mengenbrücke Aufmaß → Paket → Angebot. Die Kostenrechnung je Leistung bleibt normativ in der bestehenden Kalkulations-Engine (kalkulation.ts); Alternativ-/Eventual-Semantik bleibt normativ in spec-phase-3-alternativ-eventualpositionen-implementierung. Diese Spec dupliziert weder Rezeptur- noch Positionsartenmodell.

Summary

  • Pakete werden eine eigene Hauptseite /pakete — gleichrangig neben /material und /dienstleistung, mit eigenem Navigationseintrag, Liste und Detaileditor.
  • Ein Paket bündelt Titelgruppen und Positionen. Jede Position zeigt auf eine Leistung, ein Material oder ist Text und trägt eine Mengenregel und eine Preisquelle.
  • Beim Angebot wählt der Nutzer ein Paket, verknüpft es optional mit einem Aufmaß und bekommt alle Positionen mit vorbefüllten Mengen und Preisen, die er vor der Übernahme zeilenweise ändern oder abwählen kann.
  • Gewerksneutralität ist Architekturprinzip, nicht Konfigurationsdetail: Mengen kommen aus einem mandantenspezifischen Mengengrößen-Katalog, der pro Gewerk vorbelegt wird. Kein Paket-, Angebots- oder Kalkulationscode kennt „Wand", „Decke" oder „Raum". Ein Malerpaket, ein Gartenbaupaket, ein Poolbaupaket und ein Installateurpaket durchlaufen exakt dieselben Code- und Datenpfade.
  • Erfolgssignal: Vier Referenzpakete aus vier Gewerken werden aus jeweils einem Aufmaß in einem Vorgang zu vollständigen Angebotsentwürfen — ohne eine einzige gewerksspezifische Verzweigung im Domaincode.

⚠️ NACHGEZOGEN 2026-08-25. Diese Spec entstand am 25.07. — drei Tage bevor die LV-Phasen 0–2 landeten (Migrationen 100, 103, 105). Mehrere Annahmen darunter waren damals korrekt und sind es heute nicht mehr. Der Abschnitt unten ist auf den Stand vom 2026-08-25 gebracht; die überholten Kernentscheidungen sind bei D6/D8 markiert. Siehe auch den neuen Abschnitt Ausbaustufen.

Ist-Stand (codeverifiziert 2026-08-25)

Angebote und Kalkulation

  • Gruppen/Titel existieren seit 103_offer_sections.sql: Tabelle offer_sections (parent_id self-FK, max. 2 Ebenen, code, title, sort_order, vorbemerkung aus 105). offer_items trägt section_id + oz. Die OZ wird gespeichert, nicht berechnetrenumberOffer() (offer-sections.ts:47) vergibt position und oz (Format 01.02.0010) nach jeder Strukturmutation gemeinsam. ⇒ Ein Paket bildet seine Gruppen auf offer_sections ab; offer_item_groups entfällt.
  • offer_items.item_type (standard|alternativ|eventual) existiert seit 100_offer_items_item_type.sql. ⇒ D8 ist gegenstandslos.
  • Langtexte und Vorbemerkungen existieren seit 105_lv_texte.sql: offer_items.long_text, services.long_text, offers.vorbemerkung, offer_sections.vorbemerkung, plus Textbaustein-Katalog text_blocks (category IN ('vorbemerkung','intro','outro','position')). Diese Spec kannte sie noch nicht — Pakete müssen sie mitführen (siehe FR-P1 ff.).
  • Kalkulation ist Engine v1 (kalkulation.ts): ein Hauptmaterial, ein Lohnblock, cost_price ?? price. Die n:m-Rezeptur ist spezifiziert, aber nicht gebaut. Materialpositionen werden nie kalkuliert, sie übernehmen materials.price (VK).
  • ⚠️ Positionen entstehen einzeln über addOfferItem (index.ts:4018). Jeder Aufruf öffnet eine eigene Transaktion und ruft darin renumberOffer() und recalculateOfferTotals(). Ein Paket mit 15 Positionen = 15 Renumber-Läufe, 15 Summen-Updates und kein gemeinsames Rollback — ein Fehler in der Mitte hinterlässt ein halb eingefügtes Paket. ⇒ Ein addOfferItems-Batch (eine Transaktion, ein Renumber, ein Totals-Update) ist Voraussetzung für D7. createOfferFromAufmass (aufmass.ts:506) hat dasselbe Problem und profitiert mit.
  • Preise werden am Angebot eingefroren: addOfferItem schreibt unit_price, calculated_unit_price und den cost_breakdown-Snapshot. Neuberechnung nur explizit (recalculateOfferItem) oder wenn sich overhead_percent/risk_profit_percent ändern, und dann nur für Positionen mit price_overridden = false.
  • offer_items hat kein company_id — Mandanz läuft über offer_id → offers.company_id. Für tenantsichere Composite-FKs auf offers fehlt ein uq_offers_company_id_id; materials/services haben ihre Pendants seit 084_catalog_foundations.sql.
  • Kein Positions-Steuersatz: USt. ist ausschließlich offers.tax_rate. resolveCatalogLineItem liest tax_rate aus dem Katalog, verwirft ihn aber beim Insert.
  • /material und /dienstleistung sind Top-Level-Routen mit Navigationseintrag (navigation.ts:180-195); /settings/material und /settings/dienstleistung sind nur Redirects. Dieses Muster gilt auch für /pakete.
  • Achtung Namenskollision: Materialien haben bereits Einkaufsgebinde (PurchasePackagesEditor.tsx, intern „purchase packages"). Das ist etwas anderes als ein Leistungspaket und behält seinen Namen.

Gewerksneutralität — konkrete Blocker im Code

#BlockerFundstelleWirkung
B-1Messzeilentypen fest auf 7 Werte (wand, decke, boden, oeffnung, sonstig, lfm, stueck)076_aufmass_measurement_types.sqlGartenbau kann keine „Hecke", Poolbau kein „Becken" erfassen
B-2aufmass_positionen.flaechen_filter fest auf wand|decke|boden072_aufmass.sqlBerechnete Mengen nur für Innenraumflächen
B-3AUFMASS_EINHEITEN fix ["m²","lfm","Stk","Std","pausch"]aufmass.ts:23Kein m³ (Aushub, Wasservolumen), kein t/kg/Ltr
B-4berechneRaumbuch rechnet ausschließlich Innenraumgeometrie (Wand/Decke/Umfang)aufmass.ts:71Freiflächen, Becken, Trassen fallen durch
B-5Bodenfläche fehlt im Raumbuch; flaechen_filter='boden' liefert 0aufmass.ts:277-283Auch für Maler falsch
B-6lfm- und stueck-Messzeilen werden nirgends aggregiertaufmass.ts:107Stückzahlen (Türen, Pflanzen, Steckdosen) nicht abgreifbar
B-7Berechnungsarten ohne volumetrade-profiles.ts:46Volumenleistungen nicht abbildbar
B-8Navigationslabel „Raumbücher" hart codiertnavigation.ts:169Gartenbauer hat keine Räume
B-9TRADE_PROFILE_IDS fest auf 7 Gewerke ohne Garten-/Landschaftsbau und Pool-/Schwimmbadbautrade-profiles.ts:4Zielgewerke nicht wählbar
B-10/aufmass fehlt in APP_ROUTE_PREFIXESsite-surface.ts:5Bestehender Nebenbefund, bei P1 mitkorrigieren

Positiv: Gewerkprofile existieren bereits als Mandantenkonfiguration (companies.trade_profile, companies.trade_config, 058_trade_profiles.sql) und werden über getEffectiveTradeConfig gemerged. Diese Spec baut darauf auf, statt einen zweiten Konfigurationsweg zu erfinden.

Ziele und Nicht-Ziele

  • Ziele
    • Pakete als eigenständige, gleichrangige Stammdatenseite /pakete.
    • Wiederverwendbare Pakete mit Titelgruppen, Mengenregeln und Preisquellen.
    • Gewerksneutrales Mengenmodell: ein Mengengrößen-Katalog je Mandant, vorbelegt aus dem Gewerkprofil, frei erweiterbar.
    • Zweistufige Nummerierung (01.01) in App und PDF.
    • Vollständig editierbare Vorschau vor der Übernahme.
    • Nachvollziehbare Herkunft je Position (Paket, Paketposition, Aufmaß, Mengengröße).
    • Ein einziger Preis- und Mengenpfad für Web, Agent und Aufmaßbrücke.
  • Nicht-Ziele
    • Kein zweites Rezeptur-/Kostenmodell neben service_recipe_components.
    • Keine automatische Neuberechnung bestehender Angebote bei Stammdaten-/Aufmaßänderung.
    • Keine freie Formelsprache (nur gewichtete Summen und Faktoren).
    • Keine Lagerbuchung, kein Materialbedarf.
    • Keine Einheitenumrechnung (m ↔ cm, l ↔ m³) — Einheiten müssen zusammenpassen.
  • Optional / später
    • „Angebot als Paket speichern", Paketvarianten je Qualitätsstufe, Paketexport/-import.

Kernentscheidungen

#EntscheidungBegründung
D1Pakete sind eine eigene Top-Level-Seite /pakete mit Nav-Eintrag; /settings/pakete ist nur RedirectSymmetrie zu /material und /dienstleistung; Pakete sind Arbeitsmittel, keine Einstellung
D2Paket ist eigene Stammdatenentität, nicht „Angebot als Vorlage kopieren"Pakete tragen Mengenregeln, Preisquellen und Versionierung
D3Mengen kommen aus einem mandantenspezifischen Mengengrößen-Katalog, nicht aus fest benannten VariablenEinzige Möglichkeit, Maler, Gartenbau, Poolbau, Installateur identisch zu bedienen
D4Gewerksunterschiede sind Daten (Seeds im Gewerkprofil), niemals Code-VerzweigungenNeues Gewerk = neuer Seed, kein Release
D5Paketposition referenziert genau eine Katalogzeile (service_id oder material_id) oder ist TextMaterial-in-Leistung ist Aufgabe der Rezeptur
D6Angebot erhält echte Gruppen; Nummern werden berechnet, nicht gespeichertÜBERHOLT (2026-08-25). LV-Phase 1 hat Gruppen als offer_sections gebaut und sich bewusst fürs Speichern der OZ entschieden (offer_items.oz, offer_sections.code), zentral vergeben durch renumberOffer(). Die ausgelieferte Entscheidung gilt. Pakete nutzen offer_sections; offer_item_groups wird NICHT gebaut — zwei Gliederungsmodelle im selben Angebot wären der teuerste Fehler dieses Features.
D7Übernahme läuft über Vorschau → Bestätigung → eine TransaktionNutzer sieht alle Zeilen, bevor sie entstehen
D8offer_items.item_type wird von Migration 095 angelegtERLEDIGT (2026-08-25). item_type existiert seit 100_offer_items_item_type.sql. Pakete führen den Wert nur noch mit.
D13 (neu 2026-08-25)Paketgruppen bilden auf offer_sections ab; ein Paket darf höchstens die dort erlaubten 2 Ebenen erzeugenoffer_sections.parent_id ist auf 2 Ebenen begrenzt; tiefere Pakete müssten das LV-Modell aufbohren
D14 (neu 2026-08-25)Übernahme läuft über ein addOfferItems-Batch: eine Transaktion, ein renumberOffer(), ein recalculateOfferTotals()Ohne Batch ist D7 („eine Transaktion") technisch nicht einlösbar; heute wäre ein 15-Positionen-Paket 15 Transaktionen ohne gemeinsames Rollback
D15 (neu 2026-08-25)Paketpositionen führen long_text, Paketgruppen vorbemerkung mit; Textbausteine (text_blocks) sind beim Paketanlegen nutzbarLV-Phase 2 kam nach dieser Spec; ohne Langtexte wäre ein Paket im LV-Kontext wertlos
D16 (neu 2026-08-25)Migrationsnummern beginnen bei 110; die in dieser Spec genannten 094/095/096 sind fremdvergeben094 = Materialimport v2, 095/096 = Katalog-Meta bzw. Sale-Markup
D9Paketfixpreise sind eine eigene Preisquelle, kein „manueller Override"Sonst gilt jede Paketzeile als vom Nutzer geändert
D10Aufmaßänderungen wirken nie rückwirkend; es gibt eine explizite Aktion mit DiffSnapshot-Doktrin aus FR-8 der Kalkulations-Spec
D11Pakete funktionieren ohne Rezeptur v2 (Preisquellen katalog/paket_fix)Feature ist nicht durch C0–C2 blockiert
D12Bereichs-, Einheiten- und Messzeilenbegriffe kommen aus dem Gewerkprofil, inklusive Navigationslabel„Raumbuch" ist ein Malerbegriff, kein Produktbegriff

Begriffe

  • Paket (leistungspakete): benannte, wiederverwendbare Leistungssammlung.
  • Gruppe / Titel (leistungspaket_gruppen): Abschnitt eines Pakets.
  • Paketposition (leistungspaket_positionen): eine Zeile mit Mengenregel, Einheit, Preisquelle, Positionsart.
  • Mengengröße (mengen_groessen): benannte Bezugsgröße eines Mandanten, z. B. „Wandfläche netto (m²)", „Heckenlänge (lfm)", „Wasservolumen (m³)", „Steckdosen (Stk)".
  • Mengenbasis: die konkreten Werte aller Mengengrößen zu einem Aufmaß (gesamt oder je Bereich).
  • Bereich (aufmass_raeume): gewerksneutrale Bezeichnung für Raum, Fläche, Becken, Strang, Abschnitt. Das Anzeigelabel kommt aus dem Gewerkprofil.

Gewerksneutralität — Architekturprinzip

Verbindliche Regeln, prüfbar in Review und Test:

  1. Kein Domain-, API- oder UI-Code enthält gewerksspezifische Bezeichner. Zeichenketten wie wand, decke, boden, raum, hecke, becken dürfen ausschließlich in Seed-Daten und Migrations-Backfills vorkommen, nicht in Logik.
  2. Alles Gewerksspezifische ist Konfiguration in trade_profiles / companies.trade_config oder in den Mandantendaten (mengen_groessen).
  3. Aggregationsarten sind generisch: flaeche, laenge, stueck, volumen, pauschal. Jede Mengengröße gehört genau einer Art an; die Rechenregel hängt an der Art, nicht am Namen.
  4. Ein neues Gewerk ist ein Datensatz, kein Release: Profil anlegen, Messzeilentypen, Einheiten, Mengengrößen und optionale Startpakete seeden.
  5. Sprachliche Neutralität in der UI: Labels für Bereich, Aufmaß und Messzeilen kommen aus dem Profil; die Defaults sind neutral („Aufmaß", „Bereich", „Messzeile").
  6. Testpflicht: Jede Änderung an Mengen- oder Paketlogik muss den Vier-Gewerke-Test (Maler, Garten-/Landschaftsbau, Pool-/Schwimmbadbau, Installateur) bestehen.

Wie dieselbe Mechanik vier Gewerke bedient

GewerkBereich heißtTypische MengengrößenBeispiel-Paketposition
MalerRaumwand_netto m², decke m², boden m², tuerzarge Stk„Wände 2× streichen" × wand_netto
Garten-/LandschaftsbauFläche / Beetrasenflaeche m², heckenlaenge lfm, pflanzen Stk, aushub„Rollrasen verlegen" × rasenflaeche
Pool-/SchwimmbadbauBeckenbeckenflaeche m², beckenumfang lfm, wasservolumen m³, technikanschluss Stk„Folienauskleidung" × folienflaeche
Installateur (SHK)Strang / Etageleitungslaenge lfm, anschluss Stk, heizkoerper Stk, sanitaerobjekt Stk„Heizkörper montieren" × heizkoerper
ElektroStromkreis / Etagekabellaenge lfm, steckdose Stk, schalter Stk, leuchte Stk„Steckdose setzen" × steckdose

Dieselbe Paketposition-Struktur, dieselbe Auflösung, dieselbe Vorschau. Der Unterschied liegt ausschließlich im Katalog der Mengengrößen und im Aufmaß.

Anforderungen

Pakete und Seite

  • FR-1 Pakete sind unter /pakete erreichbar (Liste) und /pakete/[id] (Detaileditor), mit Navigationseintrag „Pakete" in derselben Gruppe wie Material und Dienstleistungen, sichtbar für Rollen 1/2. /settings/pakete leitet auf /pakete um; /pakete wird in APP_ROUTE_PREFIXES ergänzt.
  • FR-2 Ein Paket gehört genau einem Mandanten; alle Pfade sind auf company_id begrenzt, fremde IDs liefern 404.
  • FR-3 Ein Paket hat 0..50 Gruppen und 1..300 Positionen. Positionen ohne Gruppe sind erlaubt und werden als letzte Sammelgruppe angezeigt.
  • FR-4 Eine Position ist kind = leistung | material | text; leistung erfordert service_id, material erfordert material_id, text erfordert beschreibung ohne Katalogbezug.
  • FR-5 Positionstext ist standardmäßig der Katalogtext; ein in der Position gepflegter Text hat Vorrang und wird als mehrzeiliger Langtext übernommen.
  • FR-6 Mengenregel ist menge_basis = fix | mengengroesse | manuell:
    • fix: default_menge.
    • mengengroesse: round3(menge_faktor × Mengenbasis[mengen_groesse_key]).
    • manuell: default_menge als Vorschlag, Zeile wird als „Menge prüfen" markiert.
  • FR-7 mengen_groesse_key muss beim Speichern im aktiven Mengengrößen-Katalog des Mandanten existieren und seine Einheit muss der Positionseinheit entsprechen (Ausnahme: Positionseinheit ist eine Pauschal-Einheit). Verstoß = 400 validation_error mit Positionsnummer.
  • FR-8 Fehlt die Mengengröße beim Anwenden im Aufmaß, gilt default_menge plus Warnung MENGENGROESSE_FEHLT; die Übernahme bleibt möglich.
  • FR-9 Preisquelle ist preis_quelle = kalkulation | katalog | paket_fix; Auflösung und Fallbacks siehe Preisauflösung. paket_fix erfordert default_ep.
  • FR-10 kind = material darf preis_quelle = kalkulation nicht verwenden; kind = text verwendet immer paket_fix (EP darf 0 sein).
  • FR-11 Jede Position trägt item_type = standard | alternativ | eventual und vorausgewaehlt.
  • FR-12 Die Paketstruktur wird ausschließlich atomar ersetzt (PUT …/struktur) mit optimistischer Prüfung über expected_struktur_version; veraltete Versionen = 409.
  • FR-13 Pakete sind duplizierbar und deaktivierbar. Löschen nur ohne Angebotsverweise, sonst 409 in_use.
  • FR-14 Paketpflege ist Rolle 1/2 vorbehalten; Rolle 3 erhält 403, fremde Mandanten 404.

Mengengrößen (gewerksneutrales Fundament)

  • FR-15 Ein Mandant besitzt einen Katalog von Mengengrößen mit key (Slug), label, einheit, art (flaeche|laenge|stueck|volumen|pauschal) und herkunft (messzeilen|geometrie|formel|manuell).
  • FR-16 Beim ersten Zugriff wird der Katalog idempotent aus dem Gewerkprofil des Mandanten vorbelegt (is_system = true). Systemgrößen sind editierbar und deaktivierbar, aber nicht löschbar, solange Pakete sie verwenden.
  • FR-17 herkunft = messzeilen: Wert ist die Summe über alle Messzeilen mit passendem typ bzw. mengen_key, aggregiert gemäß art: flaeche = laenge × hoehe_breite × anzahl, laenge = laenge × anzahl, stueck = anzahl, volumen = laenge × hoehe_breite × tiefe × anzahl.
  • FR-18 herkunft = geometrie: Wert stammt aus der Bereichsgeometrie (Länge, Breite, Höhe, optional Tiefe) über einen benannten Ableitungsschlüssel (wandflaeche_brutto, wandflaeche_netto, grundflaeche, deckenflaeche, umfang, volumen). Diese Ableitungen sind generische Geometrieformeln, keine Gewerksbegriffe; ein Gewerk verwendet sie nur, wenn seine Bereiche Geometrie führen.
  • FR-19 herkunft = formel: Wert ist eine gewichtete Summe anderer Mengengrößen ([{key, faktor}]), maximal 10 Summanden, maximale Auflösungstiefe 3, Zyklen sind unzulässig (400 validation_error). Damit wird z. B. „Wand + Decke" konfiguriert statt hartcodiert.
  • FR-20 herkunft = manuell: der Wert wird beim Anwenden eines Pakets abgefragt.
  • FR-21 Alle Summanden einer Formel müssen dieselbe Einheit haben wie das Ergebnis.
  • FR-22 Eine Mengengröße gilt als fehlend, wenn keine Quelle existiert (keine Messzeile, keine Geometrie, kein Summand). Ein vorhandener Wert 0 ist nicht fehlend.

Aufmaß (gewerksneutral machen)

  • FR-23 Messzeilentypen kommen aus dem Gewerkprofil; der DB-CHECK wird von der festen 7er-Liste auf ein Slug-Muster umgestellt, die fachliche Prüfung erfolgt gegen getEffectiveTradeConfig. Bestehende Typen bleiben gültig (B-1).
  • FR-24 Berechnungsarten werden um volume erweitert; Messzeilen erhalten tiefe (optional). Volumenzeilen ohne tiefe sind unvollständig und zählen nicht (B-7).
  • FR-25 Einheiten kommen aus dem Gewerkprofil; der Default-Satz wird um , kg, t, Ltr erweitert. Kein fixes Enum mehr im Domaincode (B-3).
  • FR-26 Messzeilen erhalten mengen_key (Slug) zur Adressierung benannter Größen (Türzarge, Hecke, Steckdose). Ohne mengen_key zählt eine Zeile nur in die Typsummen (B-6).
  • FR-27 aufmass_positionen erhalten mengen_groesse_key als Nachfolger von flaechen_filter; der Altwert wird migriert (wand → wandflaeche_netto, decke → deckenflaeche, boden → grundflaeche) und bleibt lesbar (B-2).
  • FR-28 Die Bereichsgeometrie liefert zusätzlich grundflaeche (Länge × Breite) und volumen; die heutige 0-Menge für Bodenflächen entfällt (B-5).
  • FR-29 Anzeigelabels für Aufmaß, Bereich und Messzeile kommen aus dem Gewerkprofil (Defaults: „Aufmaß", „Bereich", „Messzeile"); das Navigationslabel „Raumbücher" wird entsprechend dynamisch (B-8).
  • FR-30 GET /api/aufmass/{id}/mengenbasis liefert die Mengenbasis gesamt und je Bereich, inklusive Einheiten und fehlender Größen.
  • FR-31 Die Mengenbasis wird ausschließlich aus Bereichsgeometrie und Messzeilen abgeleitet — genau eine Rechenstelle, gemeinsam genutzt von Aufmaß-UI, Paketvorschau und Angebot.

Angebot und Übernahme

  • FR-32 Die Vorschau schreibt nichts und liefert je Zeile Nummer, Text, Einheit, Menge, Mengenherkunft, EP, Preisquelle, GP, Warnungen.
  • FR-33 Die Übernahme schreibt Gruppen und Positionen in einer Transaktion und aktualisiert die Angebotssummen genau einmal. Teilerfolge sind unzulässig.
  • FR-34 Die Übernahme akzeptiert je Zeile aktiv, menge, unit_price, beschreibung, einheit, item_type. Ein übergebener unit_price gilt als manueller Override; alle übrigen Werte werden serverseitig neu aufgelöst.
  • FR-35 Übernahme nur im Status draft (403 sonst), Rollen 1/2.
  • FR-36 Jede erzeugte Position speichert Herkunft: paket_id, paket_position_id, menge_quelle, mengen_groesse_key, aufmass_id.
  • FR-37 Ein Angebot kann mehrere Pakete und freie Positionen enthalten; neue Gruppen werden angehängt, bestehende Nummern bleiben stabil.
  • FR-38 Anwendungsmodus gesamt erzeugt die Gruppen einmal; je_bereich erzeugt je ausgewähltem Aufmaßbereich einen Gruppensatz mit Bereichsnamen im Titel.
  • FR-39 POST /api/aufmass/{id}/create-offer akzeptiert optional paket_id, anwendungsmodus, bereich_ids; ohne paket_id bleibt das Verhalten unverändert.
  • FR-40 POST /api/offers/{id}/mengen-refresh berechnet Mengen mit menge_quelle = 'aufmass' neu, liefert zuerst einen Diff und schreibt erst nach Bestätigung; manuell geänderte Mengen bleiben unberührt.
  • FR-41 Angebote mit mindestens einer Gruppe werden zweistufig nummeriert (GG.PP); Angebote ohne Gruppe behalten die heutige einstufige Nummerierung.
  • FR-42 recalculateOfferTotals summiert nur item_type = 'standard'; Alternativ- und Eventualsummen werden getrennt ausgewiesen. Rabatt und USt wirken nur auf die Standardsumme.
  • FR-43 Das PDF rendert Gruppentitel, zweistufige Nummern, optionale Gruppensummen und die Abschnitte „Alternativpositionen"/„Eventualpositionen" nach dem Summenblock.
  • FR-44 Der Agent kann Pakete auflisten und anwenden (offers.apply_package) über dieselbe Domainfunktion; keine Kostendetails im Kundenkanal.
  • FR-45 Domain-Events: pakete.*, mengen_groessen.*, offers.paket_applied, offers.mengen_refreshed — ohne Einkaufspreise oder Margen.

Nicht-funktional

  • NFR-1 Vorschau mit 50 Positionen: p95 ≤ 400 ms, p99 ≤ 900 ms, maximal fünf SQL-Roundtrips, keine Query in einer Positionsschleife.
  • NFR-2 Übernahme von 50 Positionen: eine Transaktion, ein Totals-Update, p95 ≤ 1,5 s.
  • NFR-3 Kapazität: ≤ 50 Gruppen und ≤ 300 Positionen je Paket, ≤ 200 aktive Mengengrößen je Mandant, Body ≤ 256 KiB.
  • NFR-4 Mengen auf 3, Geldbeträge auf 2 Nachkommastellen kaufmännisch gerundet; Zwischenwerte ungerundet; Abweichung im Total ≤ 0,01 EUR.
  • NFR-5 WCAG 2.2 AA für Paketseite, Editor, Übernahmedialog und Mengengrößen-Pflege; axe meldet 0 kritische/ernste Verstöße.
  • NFR-6 100 % der neuen Queries tragen Tenant-Prädikat oder Composite-FK; Negativtests liefern 404.
  • NFR-7 Bestandsangebote ohne Gruppen liefern identische Summen und ein optisch unverändertes PDF.
  • NFR-8 Je Vorschau/Übernahme genau ein strukturierter Logeintrag mit Dauer, Paket-ID, Zeilenanzahl, Warncodes, Modus — ohne Preise.
  • NFR-9 Neutralitätsnachweis: Ein automatisierter Test scannt packages/domain/src und apps/app/src (ohne Seed- und Migrationsdateien) auf gewerksspezifische Bezeichner aus einer Sperrliste und schlägt bei Treffern fehl.

Mengenbasis

Aufbau

Aufmaß
 ├─ Bereiche (optional mit Geometrie L×B×H, T)
 └─ Messzeilen (typ, mengen_key, laenge, hoehe_breite, tiefe, anzahl, abzug)


  berechneMengenbasis(mengengroessen, bereiche, messzeilen, scope)


  { key → wert }  +  { key → einheit }  +  fehlend[]

Aggregationsregeln nach art:

artRechenregel je Messzeiletypische Einheit
flaechelaenge × hoehe_breite × anzahl
laengelaenge × anzahllfm
stueckanzahlStk
volumenlaenge × hoehe_breite × tiefe × anzahl
pauschalkonstant 1 (je Bereich bei je_bereich)psch

Abzugszeilen (abzug = true oder Berechnungsart deduction) mindern die Mengengröße, die ihr abzug_von benennt; abzug_von wird von wand|decke|boden auf einen Mengengrößen-Schlüssel verallgemeinert (Backfill wie FR-27).

Geometrieableitungen (nur wenn der Bereich Geometrie führt):

AbleitungsschlüsselFormel
grundflaechelaenge × breite
deckenflaechelaenge × breite
umfang2 × (laenge + breite)
wandflaeche_bruttoumfang × hoehe
wandflaeche_nettowandflaeche_brutto − Abzüge
volumenlaenge × breite × hoehe

Diese Schlüssel sind reine Geometrie und damit gewerksneutral: der Poolbauer nutzt volumen als Wasservolumen, der Gartenbauer grundflaeche als Beetfläche, der Maler wandflaeche_netto.

Beispiel Maler — das Referenzpaket

Pos.Leistungstext (gekürzt)EinheitMengeMengenregel
01.01Baustelleneinrichtung, An-/Abfahrtpsch1fix
01.02Böden abdecken, Bauteile abkleben85mengengroesse boden
01.03Möbel verrücken und abdeckenpsch1fix
02.01Flächen prüfen, Altanstrich entfernen220mengengroesse wand_decke (Formel)
02.02Risse/Dübellöcher schließen, Q2220mengengroesse wand_decke
02.03Tiefgrund auftragen220mengengroesse wand_decke
02.04Flächenspachtelung Q3, nur bei Bedarf40manuell, eventual, nicht vorausgewählt
03.01Wände 2× Innenfarbe170mengengroesse wand_netto
03.02Decken 2× Innenfarbe50mengengroesse decke
03.03Akzentwand 2× farbig18manuell
04.01Türzargen lackierenStk5mengengroesse tuerzarge
04.02Heizkörper lackierenStk3mengengroesse heizkoerper
05.01Abdeckungen entfernen, Reinigung, Entsorgungpsch1fix
05.02Abnahme und Übergabepsch1fix

wand_decke ist eine Formelgröße (1×wand_netto + 1×decke) aus dem Maler-Seed — kein Sonderfall im Code.

Beispiel Garten-/Landschaftsbau

Pos.LeistungEinheitMengenregel
01.01Baustelleneinrichtung, Geräteanfuhrpschfix
01.02Oberboden abtragen und lagernmengengroesse aushub
02.01Pflasterfläche herstellenmengengroesse pflasterflaeche
02.02Randsteine setzenlfmmengengroesse randsteinlaenge
03.01Rollrasen verlegenmengengroesse rasenflaeche
03.02Hecke pflanzenlfmmengengroesse heckenlaenge
03.03Solitärgehölze pflanzenStkmengengroesse baum
04.01Baustelle räumen, Entsorgungpschfix

Beispiel Pool-/Schwimmbadbau

Pos.LeistungEinheitMengenregel
01.01Baustelleneinrichtungpschfix
01.02Baugrubenaushubmengengroesse aushub
02.01Beckenwände herstellenmengengroesse beckenwandflaeche
02.02Beckensohle herstellenmengengroesse beckengrundflaeche
03.01Folienauskleidungmengengroesse folienflaeche (Formel: Wand + Sohle)
03.02Randsteine setzenlfmmengengroesse beckenumfang
04.01Technikanschlüsse herstellenStkmengengroesse technikanschluss
04.02Erstbefüllung und Inbetriebnahmemengengroesse wasservolumen

Beispiel Installateur (SHK)

Pos.LeistungEinheitMengenregel
01.01Baustelleneinrichtung, Absperrungpschfix
02.01Rohrleitung verlegenlfmmengengroesse leitungslaenge
02.02Dämmung Rohrleitunglfmmengengroesse leitungslaenge
03.01Heizkörper montieren und anschließenStkmengengroesse heizkoerper
03.02Sanitärobjekte montierenStkmengengroesse sanitaerobjekt
04.01Dichtheitsprüfung und Protokollpschfix

Alle vier Pakete verwenden dieselben Felder, dieselbe Auflösung und denselben Dialog.

Snapshots

  • Angebotspositionen sind Snapshots; Aufmaßänderungen wirken nie automatisch (D10).
  • „Mengen aus Aufmaß aktualisieren" zeigt alt → neu je Zeile und schreibt erst nach Bestätigung; manuell geänderte Mengen werden übersprungen und ausgewiesen.

Preisauflösung

Erste zutreffende Regel gewinnt:

  1. Eingabe des Nutzers in der Vorschau → preis_herkunft = 'manuell', price_overridden = true.
  2. preis_quelle = 'paket_fix'default_ep, preis_herkunft = 'paket'.
  3. preis_quelle = 'kalkulation' (nur kind = leistung) → eine Kalkulationsfunktion: heute calculateServicePrice, nach C2 calculateServicePriceV2. Vollständig → 'kalkulation'; unvollständig → services.default_price, preis_herkunft = 'katalog', Warnung KALKULATION_UNVOLLSTAENDIG.
  4. preis_quelle = 'katalog'services.default_price bzw. materials.price; fehlt der Preis → 0 plus KATALOGPREIS_FEHLT.

Zuschläge kommen wie heute aus Angebot vor Firma. GP = round2(menge × EP). Sobald pricing_source aus der Kalkulations-Spec existiert, wird sie um 'paket' erweitert (A-2).

Szenarien

S-1 Paket anlegen (eigene Seite)

  • Gegeben ein Betrieb mit gepflegten Leistungen.
  • Wenn ein Manager über den Navigationseintrag „Pakete" ein Paket mit 5 Gruppen und 14 Positionen speichert.
  • Dann entsteht struktur_version = 1, die Vorschau nummeriert 01.01 … 05.02, und die Seite verhält sich in Liste, Suche und Detailmuster wie /material und /dienstleistung.

S-2 Maler: Übernahme mit Aufmaß

  • Gegeben ein Aufmaß mit 85 m² Grundfläche, 170 m² Nettowand, 50 m² Decke, 5 Türzargen, 3 Heizkörpern.
  • Wenn das Referenzpaket im Modus gesamt angewendet wird.
  • Dann zeigt die Vorschau exakt die Beispielmengen; 02.04 und 03.03 sind „Menge prüfen"; nach Bestätigung entstehen 5 Gruppen und 12 aktive Positionen in einer Transaktion.

S-3 Gartenbau: dieselbe Mechanik, anderes Gewerk

  • Gegeben ein Mandant mit Profil Garten-/Landschaftsbau, Bereiche „Vorgarten", „Terrasse" mit Flächen-, Längen- und Volumenmesszeilen.
  • Wenn das Gartenbaupaket angewendet wird.
  • Dann werden Rasenfläche (m²), Heckenlänge (lfm), Baumanzahl (Stk) und Aushub (m³) korrekt aufgelöst, ohne dass eine Codeverzweigung je Gewerk existiert.

S-4 Poolbau: Volumen

  • Gegeben ein Becken 8,0 × 4,0 m mit Tiefe 1,5 m als Geometriebereich.
  • Wenn die Mengenbasis berechnet wird.
  • Dann liefert volumen 48 m³, grundflaeche 32 m², umfang 24 lfm und die Formelgröße folienflaeche = Wandfläche + Sohle den korrekten Wert.

S-5 Installateur: reine Stückgrößen

  • Gegeben ein Aufmaß ohne Geometrie, nur Stück- und Längenmesszeilen mit mengen_key.
  • Wenn das SHK-Paket angewendet wird.
  • Dann werden alle Positionen aufgelöst; geometrieabhängige Größen sind schlicht nicht im Katalog und erzeugen keine Warnung.

S-6 Fehlende Mengengröße

  • Gegeben das Aufmaß hat keine Zeile mit mengen_key = 'heizkoerper'.
  • Dann erhält die Zeile default_menge, Warnung MENGENGROESSE_FEHLT, Hervorhebung in der Vorschau; Übernahme bleibt möglich.

S-7 Übernahme ohne Aufmaß

  • Wenn kein Aufmaß existiert.
  • Dann fragt der Dialog nur die im Paket verwendeten Mengengrößen ab und rechnet alle abhängigen Zeilen daraus.

S-8 Anwendung je Bereich

  • Gegeben 3 Bereiche, Modus je_bereich, 2 ausgewählt.
  • Dann entstehen 2 Gruppensätze mit Bereichsnamen im Titel; pauschal-Positionen entstehen je Bereich genau einmal.

S-9 Eventualposition

  • Gegeben 02.04 ist eventual, nicht vorausgewählt.
  • Wenn der Nutzer sie aktiviert und 40 einträgt.
  • Dann erscheint sie im PDF im Abschnitt „Eventualpositionen" und zählt nicht in subtotal/total.

S-10 Manueller Preis

  • Gegeben die Kalkulation ergibt 12,80 EUR/m².
  • Wenn der Nutzer 11,50 EUR einträgt.
  • Dann wird 11,50 gespeichert, price_overridden = true, Snapshot bleibt erhalten.

S-11 Zweites Paket im selben Angebot

  • Dann entstehen Gruppen 06 ff.; bestehende Nummern und Preise ändern sich nicht.

S-12 Formelgröße mit Zyklus

  • Wenn eine Mengengröße direkt oder indirekt auf sich selbst verweist.
  • Dann 400 validation_error mit Angabe der Kette; nichts wird gespeichert.

S-13 Mengen-Refresh nach Aufmaßänderung

  • Dann zeigt der Diff nur betroffene Zeilen; manuell geänderte Mengen bleiben unberührt und werden als übersprungen gemeldet.

S-14 Angebot nicht mehr Entwurf

  • Dann 403 forbidden, keine Änderung.

S-15 Konkurrierende Paketpflege

  • Dann 409 version_conflict mit aktueller Version, keine Teilschreibung.

S-16 Mandantenfremdes Paket / fremde Mengengröße

  • Dann 404 not_found, keine Existenzinformation, keine Änderung.

S-17 Paket löschen, das verwendet wurde

  • Dann 409 in_use mit Angebotsanzahl und Hinweis „deaktivieren".

S-18 Agent wendet ein Paket an

  • Dann derselbe Domainpfad; Antwort nennt Zeilenanzahl, Standardsumme und offene „Menge prüfen"-Zeilen, keine Kostenaufschlüsselung.

S-19 Neues Gewerk ohne Release

  • Gegeben ein Betrieb, dessen Gewerk noch kein Profil hat.
  • Wenn Admin ein Profil wählt und den Mengengrößen-Katalog anpasst (umbenennen, ergänzen, deaktivieren).
  • Dann funktionieren Aufmaß, Pakete und Angebot vollständig, ohne Codeänderung.

Ausbaustufen (nachgezogen 2026-08-25)

Entscheidung des Users am 2026-08-25: schlank zuerst, damit im Betrieb schnell echte Pakete entstehen; die Aufmaß-Brücke folgt additiv.

StufeInhaltNicht enthalten
P1 — Pakete purleistungspakete + leistungspaket_gruppen + leistungspaket_positionen; Seite /pakete (D1); Positionen mit Katalogbezug, item_type, long_text, Gruppen-vorbemerkung (D15); eine Leitmenge je Paket, Positionen fix oder skaliert (menge_pro_einheit); Übernahme ins Angebot als offer_sections + Positionen über addOfferItems (D13, D14); „Angebot als Paket speichern" mit Faktor-RückrechnungMengengrößen-Katalog, Aufmaß-Bindung, Anwendung je Bereich, Mengen-Refresh
P2 — Mengengrößen & Aufmaßmengen_groessen-Katalog (D3), Mengenbasis aus dem Aufmaß, Anwendung gesamt|je_bereich, Refresh mit Diff (D10), Gewerks-Seeds (D4); Szenarien S-2 … S-5, S-8, S-13

Additiver Übergang P1 → P2 (verbindlich): In P1 trägt das Paket die Leitmenge als leitmenge_bezeichnung TEXT + leitmenge_einheit TEXT, die Position ihren Faktor als menge_pro_einheit NUMERIC. P2 ergänzt mengen_groessen und je eine nullablemengen_groesse_id an Paket und Position und backfillt sie aus dem Textpaar. Kein Umschreiben bestehender Pakete, keine Semantikänderung an menge_pro_einheit — nur die Herkunft der Leitmenge wechselt von „Nutzer tippt sie ein" zu „kommt aus der Mengenbasis".

Neue Anforderungen aus LV-Phase 2 (in dieser Spec ursprünglich nicht enthalten):

  • FR-P1 Paketposition trägt long_text; beim Übernehmen landet er in offer_items.long_text.
  • FR-P2 Paketgruppe trägt vorbemerkung; beim Übernehmen landet sie in offer_sections.vorbemerkung.
  • FR-P3 Im Paketeditor sind Textbausteine (text_blocks, category='position' bzw. 'vorbemerkung') einfügbar — dieselbe TextBlockInsert-Komponente wie im Angebot.
  • FR-P4 Übernahme nutzt ein addOfferItems-Batch; bei einem Fehler entsteht keine Teilmenge (D14).
  • FR-P5 „Angebot als Paket speichern": aus einem Angebot entsteht ein Paket; gibt der Nutzer die zugrunde liegende Leitmenge an, werden die Faktoren zurückgerechnet (menge ÷ Leitmenge). Vorschlag-Default: Positionen mit Einheit pauschal/Stk und Menge 1 → fix, alles andere → skaliert.
  • FR-P6 Rundung der skalierten Menge auf 3 Nachkommastellen (offer_items.quantity ist NUMERIC(10,3)) — Gebinde-Aufrundung bleibt Sache des Materialplans, nicht des Pakets.

Datenmodell

⚠️ Die drei folgenden Migrations-Entwürfe sind vom 2026-07-25 und so NICHT mehr gültig (nachgezogen 2026-08-25). Verbindlich ist: Nummern ab 110 statt 094/095/096 (D16); offer_item_groups entfällt ersatzlos zugunsten von offer_sections (D6/D13); offer_items.item_type wird nicht neu angelegt (D8); uq_offers_company_id_id bleibt nötig und wandert nach 110; die Gewerksneutralisierung des Aufmaßes (096) gehört vollständig in Stufe P2. Das SQL darunter bleibt als Denkgrundlage stehen.

Migration 094_leistungspakete_mengengroessen.sql

sql
-- Tenant-sichere Composite-FKs (idempotent; ggf. schon durch die Rezeptur-Migration da)
CREATE UNIQUE INDEX IF NOT EXISTS uq_services_company_id_id  ON public.services(company_id, id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_materials_company_id_id ON public.materials(company_id, id);

-- 1) Gewerksneutraler Mengengrößen-Katalog
CREATE TABLE IF NOT EXISTS public.mengen_groessen (
  id SERIAL PRIMARY KEY,
  company_id VARCHAR(100) NOT NULL REFERENCES public.companies(company_id) ON DELETE CASCADE,
  key VARCHAR(60) NOT NULL CHECK (key ~ '^[a-z0-9][a-z0-9_]{0,59}$'),
  label VARCHAR(120) NOT NULL CHECK (length(btrim(label)) > 0),
  einheit VARCHAR(20) NOT NULL CHECK (length(btrim(einheit)) > 0),
  art TEXT NOT NULL CHECK (art IN ('flaeche','laenge','stueck','volumen','pauschal')),
  herkunft TEXT NOT NULL CHECK (herkunft IN ('messzeilen','geometrie','formel','manuell')),
  quelle_typ VARCHAR(60),        -- herkunft='messzeilen': Messzeilentyp
  quelle_mengen_key VARCHAR(60), -- herkunft='messzeilen': optionaler benannter Key
  geometrie_feld TEXT,           -- herkunft='geometrie'
  formel_teile JSONB,            -- herkunft='formel': [{ "key": "...", "faktor": 1 }]
  beschreibung TEXT,
  is_system BOOLEAN NOT NULL DEFAULT false,
  is_active BOOLEAN NOT NULL DEFAULT true,
  sort_order INTEGER NOT NULL DEFAULT 100,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT uq_mengen_groessen_company_key UNIQUE (company_id, key),
  CONSTRAINT ck_mengen_groessen_quelle CHECK (
    (herkunft = 'messzeilen' AND quelle_typ IS NOT NULL
       AND geometrie_feld IS NULL AND formel_teile IS NULL)
    OR (herkunft = 'geometrie' AND geometrie_feld IN
         ('grundflaeche','deckenflaeche','umfang','wandflaeche_brutto',
          'wandflaeche_netto','volumen')
       AND quelle_typ IS NULL AND formel_teile IS NULL)
    OR (herkunft = 'formel' AND jsonb_typeof(formel_teile) = 'array'
       AND quelle_typ IS NULL AND geometrie_feld IS NULL)
    OR (herkunft = 'manuell'
       AND quelle_typ IS NULL AND geometrie_feld IS NULL AND formel_teile IS NULL)
  )
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_mengen_groessen_company_id_id
  ON public.mengen_groessen(company_id, id);
CREATE INDEX IF NOT EXISTS idx_mengen_groessen_company_active
  ON public.mengen_groessen(company_id, is_active, sort_order);

-- 2) Pakete
CREATE TABLE IF NOT EXISTS public.leistungspakete (
  id SERIAL PRIMARY KEY,
  company_id VARCHAR(100) NOT NULL REFERENCES public.companies(company_id) ON DELETE CASCADE,
  paket_number VARCHAR(50),
  name VARCHAR(255) NOT NULL CHECK (length(btrim(name)) > 0),
  beschreibung TEXT,
  trade_profile_id VARCHAR(50),
  standard_anwendungsmodus TEXT NOT NULL DEFAULT 'gesamt'
    CHECK (standard_anwendungsmodus IN ('gesamt','je_bereich')),
  struktur_version INTEGER NOT NULL DEFAULT 0 CHECK (struktur_version >= 0),
  is_active BOOLEAN NOT NULL DEFAULT true,
  created_by_user_id INTEGER REFERENCES public.users(id) ON DELETE SET NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_leistungspakete_company_id_id
  ON public.leistungspakete(company_id, id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_leistungspakete_company_number
  ON public.leistungspakete(company_id, paket_number) WHERE paket_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_leistungspakete_company_active
  ON public.leistungspakete(company_id, is_active) WHERE is_active = true;

CREATE TABLE IF NOT EXISTS public.leistungspaket_gruppen (
  id SERIAL PRIMARY KEY,
  company_id VARCHAR(100) NOT NULL,
  paket_id INTEGER NOT NULL,
  sort_order INTEGER NOT NULL CHECK (sort_order > 0),
  titel VARCHAR(255) NOT NULL CHECK (length(btrim(titel)) > 0),
  beschreibung TEXT,
  summe_anzeigen BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT fk_paket_gruppe_tenant FOREIGN KEY (company_id, paket_id)
    REFERENCES public.leistungspakete(company_id, id) ON DELETE CASCADE,
  CONSTRAINT uq_paket_gruppe_sort UNIQUE (paket_id, sort_order)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_leistungspaket_gruppen_company_id_id
  ON public.leistungspaket_gruppen(company_id, id);

CREATE TABLE IF NOT EXISTS public.leistungspaket_positionen (
  id SERIAL PRIMARY KEY,
  company_id VARCHAR(100) NOT NULL,
  paket_id INTEGER NOT NULL,
  gruppe_id INTEGER,
  sort_order INTEGER NOT NULL CHECK (sort_order > 0),
  kind TEXT NOT NULL CHECK (kind IN ('leistung','material','text')),
  service_id INTEGER,
  material_id INTEGER,
  beschreibung TEXT,
  einheit VARCHAR(20) NOT NULL CHECK (length(btrim(einheit)) > 0),
  menge_basis TEXT NOT NULL DEFAULT 'fix'
    CHECK (menge_basis IN ('fix','mengengroesse','manuell')),
  mengen_groesse_key VARCHAR(60),
  menge_faktor NUMERIC(10,4) NOT NULL DEFAULT 1
    CHECK (menge_faktor > 0 AND menge_faktor <= 10000),
  default_menge NUMERIC(12,3) NOT NULL DEFAULT 1 CHECK (default_menge >= 0),
  preis_quelle TEXT NOT NULL DEFAULT 'kalkulation'
    CHECK (preis_quelle IN ('kalkulation','katalog','paket_fix')),
  default_ep NUMERIC(12,2) CHECK (default_ep IS NULL OR default_ep >= 0),
  item_type TEXT NOT NULL DEFAULT 'standard'
    CHECK (item_type IN ('standard','alternativ','eventual')),
  vorausgewaehlt BOOLEAN NOT NULL DEFAULT true,
  notiz TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT fk_paket_position_tenant FOREIGN KEY (company_id, paket_id)
    REFERENCES public.leistungspakete(company_id, id) ON DELETE CASCADE,
  CONSTRAINT fk_paket_position_gruppe FOREIGN KEY (company_id, gruppe_id)
    REFERENCES public.leistungspaket_gruppen(company_id, id) ON DELETE CASCADE,
  CONSTRAINT fk_paket_position_service FOREIGN KEY (company_id, service_id)
    REFERENCES public.services(company_id, id) ON DELETE RESTRICT,
  CONSTRAINT fk_paket_position_material FOREIGN KEY (company_id, material_id)
    REFERENCES public.materials(company_id, id) ON DELETE RESTRICT,
  CONSTRAINT uq_paket_position_sort UNIQUE (paket_id, sort_order),
  CONSTRAINT ck_paket_position_ref CHECK (
    (kind = 'leistung' AND service_id IS NOT NULL AND material_id IS NULL)
    OR (kind = 'material' AND material_id IS NOT NULL AND service_id IS NULL)
    OR (kind = 'text' AND service_id IS NULL AND material_id IS NULL
        AND beschreibung IS NOT NULL)
  ),
  CONSTRAINT ck_paket_position_menge CHECK (
    (menge_basis = 'mengengroesse' AND mengen_groesse_key IS NOT NULL)
    OR (menge_basis <> 'mengengroesse' AND mengen_groesse_key IS NULL)
  ),
  CONSTRAINT ck_paket_position_preis CHECK (
    (preis_quelle <> 'paket_fix' OR default_ep IS NOT NULL)
    AND (kind <> 'material' OR preis_quelle <> 'kalkulation')
    AND (kind <> 'text' OR preis_quelle = 'paket_fix')
  )
);
CREATE INDEX IF NOT EXISTS idx_paket_positionen_paket
  ON public.leistungspaket_positionen(company_id, paket_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_paket_positionen_mengengroesse
  ON public.leistungspaket_positionen(company_id, mengen_groesse_key)
  WHERE mengen_groesse_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_paket_positionen_service
  ON public.leistungspaket_positionen(service_id) WHERE service_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_paket_positionen_material
  ON public.leistungspaket_positionen(material_id) WHERE material_id IS NOT NULL;

-- updated_at-Trigger analog zu bestehenden Tabellen für alle vier neuen Tabellen.

Referenz auf mengen_groessen.key ist bewusst kein FK: Mengengrößen dürfen umbenannt oder deaktiviert werden, ohne Pakete zu zerstören. Die Prüfung erfolgt beim Speichern (FR-7) und beim Anwenden (Warnung statt Fehler).

Migration 095_offer_item_groups_paket_herkunft.sql — ENTFÄLLT

Ersetzt durch offer_sections (Migration 103). Aus diesem Entwurf bleibt nur: uq_offers_company_id_id (wandert nach 110) sowie die Herkunftsspalten an offer_items (paket_id, paket_position_id) und an offer_sections (paket_id). offer_item_groups wird nicht gebaut. Das SQL darunter ist historisch.

sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_offers_company_id_id ON public.offers(company_id, id);

CREATE TABLE IF NOT EXISTS public.offer_item_groups (
  id SERIAL PRIMARY KEY,
  company_id VARCHAR(100) NOT NULL,
  offer_id INTEGER NOT NULL,
  position INTEGER NOT NULL CHECK (position > 0),
  title VARCHAR(255) NOT NULL CHECK (length(btrim(title)) > 0),
  description TEXT,
  show_subtotal BOOLEAN NOT NULL DEFAULT false,
  paket_id INTEGER REFERENCES public.leistungspakete(id) ON DELETE SET NULL,
  paket_gruppe_id INTEGER REFERENCES public.leistungspaket_gruppen(id) ON DELETE SET NULL,
  aufmass_raum_id INTEGER REFERENCES public.aufmass_raeume(id) ON DELETE SET NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT fk_offer_item_groups_offer FOREIGN KEY (company_id, offer_id)
    REFERENCES public.offers(company_id, id) ON DELETE CASCADE,
  CONSTRAINT uq_offer_item_groups_offer_position UNIQUE (offer_id, position)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_offer_item_groups_company_id_id
  ON public.offer_item_groups(company_id, id);

ALTER TABLE public.offer_items
  ADD COLUMN IF NOT EXISTS group_id INTEGER
    REFERENCES public.offer_item_groups(id) ON DELETE SET NULL,
  ADD COLUMN IF NOT EXISTS item_type TEXT NOT NULL DEFAULT 'standard',
  ADD COLUMN IF NOT EXISTS paket_id INTEGER
    REFERENCES public.leistungspakete(id) ON DELETE SET NULL,
  ADD COLUMN IF NOT EXISTS paket_position_id INTEGER
    REFERENCES public.leistungspaket_positionen(id) ON DELETE SET NULL,
  ADD COLUMN IF NOT EXISTS menge_quelle TEXT NOT NULL DEFAULT 'manuell',
  ADD COLUMN IF NOT EXISTS mengen_groesse_key VARCHAR(60),
  ADD COLUMN IF NOT EXISTS aufmass_id INTEGER
    REFERENCES public.aufmasse(id) ON DELETE SET NULL;

-- benannte CHECKs vorab über pg_constraint prüfen:
--   ck_offer_items_item_type    CHECK (item_type IN ('standard','alternativ','eventual'))
--   ck_offer_items_menge_quelle CHECK (menge_quelle IN ('manuell','paket','aufmass'))

CREATE INDEX IF NOT EXISTS idx_offer_items_group ON public.offer_items(group_id)
  WHERE group_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_offer_items_offer_item_type
  ON public.offer_items(offer_id, item_type);
CREATE INDEX IF NOT EXISTS idx_offer_items_paket ON public.offer_items(paket_id)
  WHERE paket_id IS NOT NULL;

offer_items besitzt kein company_id; Mandantensicherheit läuft über offer_id → offers.company_id. Die Invariante „group_id gehört zum selben Angebot" wird in der Domain erzwungen und per Negativtest abgesichert.

Migration 096_aufmass_gewerksneutral.sql — verschoben nach Stufe P2

sql
-- B-1: Messzeilentypen freigeben, fachliche Prüfung wandert in die Domain
ALTER TABLE public.aufmass_messzeilen DROP CONSTRAINT IF EXISTS aufmass_messzeilen_typ_check;
ALTER TABLE public.aufmass_messzeilen
  ADD CONSTRAINT ck_aufmass_messzeilen_typ CHECK (typ ~ '^[a-z0-9][a-z0-9_-]{0,39}$');

-- B-7: Volumen
ALTER TABLE public.aufmass_messzeilen
  ADD COLUMN IF NOT EXISTS tiefe NUMERIC(10,3) CHECK (tiefe IS NULL OR tiefe > 0);
ALTER TABLE public.aufmass_raeume
  ADD COLUMN IF NOT EXISTS tiefe_m NUMERIC(10,3) CHECK (tiefe_m IS NULL OR tiefe_m > 0);

-- B-6: benannte Größen an Messzeilen
ALTER TABLE public.aufmass_messzeilen ADD COLUMN IF NOT EXISTS mengen_key VARCHAR(60);
CREATE INDEX IF NOT EXISTS idx_aufmass_messzeilen_mengen_key
  ON public.aufmass_messzeilen(raum_id, mengen_key) WHERE mengen_key IS NOT NULL;
-- CHECK ck_aufmass_messzeilen_mengen_key (Slug) über pg_constraint idempotent anlegen.

-- B-2: Positionsmengen an Mengengrößen binden (flaechen_filter bleibt lesbar)
ALTER TABLE public.aufmass_positionen
  ADD COLUMN IF NOT EXISTS mengen_groesse_key VARCHAR(60);
UPDATE public.aufmass_positionen SET mengen_groesse_key = CASE flaechen_filter
    WHEN 'wand'  THEN 'wand_netto'
    WHEN 'decke' THEN 'decke'
    WHEN 'boden' THEN 'boden'
  END
 WHERE mengen_groesse_key IS NULL AND flaechen_filter IS NOT NULL;

-- Abzugsziel verallgemeinern (Altwerte bleiben gültig)
ALTER TABLE public.aufmass_messzeilen DROP CONSTRAINT IF EXISTS aufmass_messzeilen_abzug_von_check;
ALTER TABLE public.aufmass_messzeilen
  ALTER COLUMN abzug_von TYPE VARCHAR(60),
  ADD CONSTRAINT ck_aufmass_messzeilen_abzug_von
    CHECK (abzug_von ~ '^[a-z0-9][a-z0-9_]{0,59}$');

Der Backfill setzt voraus, dass die Maler-Seedgrößen wand_netto, decke, boden für Bestandsmandanten angelegt sind; der Seed läuft in derselben Migration bzw. beim ersten Domainzugriff (FR-16) und ist idempotent.

Public Interfaces

Types (Domain)

ts
export const MENGEN_ARTEN = ['flaeche', 'laenge', 'stueck', 'volumen', 'pauschal'] as const
export const MENGEN_HERKUNFT = ['messzeilen', 'geometrie', 'formel', 'manuell'] as const
export const GEOMETRIE_FELDER = [
  'grundflaeche', 'deckenflaeche', 'umfang',
  'wandflaeche_brutto', 'wandflaeche_netto', 'volumen',
] as const

export const PAKET_POSITION_KINDS = ['leistung', 'material', 'text'] as const
export const PAKET_MENGE_BASIS = ['fix', 'mengengroesse', 'manuell'] as const
export const PAKET_PREIS_QUELLEN = ['kalkulation', 'katalog', 'paket_fix'] as const
export const PAKET_ANWENDUNGSMODI = ['gesamt', 'je_bereich'] as const
export const OFFER_ITEM_TYPES = ['standard', 'alternativ', 'eventual'] as const

export type PaketWarnCode =
  | 'MENGENGROESSE_FEHLT'
  | 'MENGE_MANUELL_PRUEFEN'
  | 'KALKULATION_UNVOLLSTAENDIG'
  | 'KATALOGPREIS_FEHLT'
  | 'KATALOG_INAKTIV'
  | 'EINHEIT_ABWEICHEND'

export interface MengenGroesse {
  id: number
  key: string
  label: string
  einheit: string
  art: (typeof MENGEN_ARTEN)[number]
  herkunft: (typeof MENGEN_HERKUNFT)[number]
  quelle_typ: string | null
  quelle_mengen_key: string | null
  geometrie_feld: (typeof GEOMETRIE_FELDER)[number] | null
  formel_teile: Array<{ key: string; faktor: number }> | null
  is_system: boolean
  is_active: boolean
  sort_order: number
}

export interface Mengenbasis {
  aufmass_id: number | null
  scope: 'gesamt' | 'bereich'
  bereich_id: number | null
  bereich_name: string | null
  werte: Record<string, number>
  einheiten: Record<string, string>
  fehlend: string[]
  berechnet_am: string
}

export interface PaketVorschauZeile {
  paket_position_id: number
  nummer: string                     // "01.02" bzw. "3" ohne Gruppen
  aktiv: boolean
  kind: (typeof PAKET_POSITION_KINDS)[number]
  service_id: number | null
  material_id: number | null
  beschreibung: string
  einheit: string
  menge: number
  menge_quelle: 'paket' | 'aufmass' | 'manuell'
  mengen_groesse_key: string | null
  unit_price: number
  preis_herkunft: 'kalkulation' | 'katalog' | 'paket' | 'manuell'
  gesamt: number
  item_type: (typeof OFFER_ITEM_TYPES)[number]
  warnungen: PaketWarnCode[]
}

// Reine Funktionen — gewerksneutral, ohne DB, direkt testbar
export function berechneMengenbasis(
  groessen: readonly MengenGroesse[],
  bereiche: readonly AufmassBereichGeometrie[],
  zeilen: readonly AufmassMesszeile[],
  scope: { bereich_id: number | null }
): Mengenbasis

export function loesePaketMenge(
  position: PaketPosition,
  basis: Mengenbasis | null
): { menge: number; quelle: 'paket' | 'aufmass' | 'manuell'; warnungen: PaketWarnCode[] }

export function berechnePositionsnummern(
  gruppen: readonly { sort_order: number }[],
  positionen: readonly { gruppe_id: number | null; sort_order: number }[]
): Map<number, string>

export function validiereMengenGroessen(
  groessen: readonly MengenGroesse[]
): Array<{ key: string; fehler: 'ZYKLUS' | 'EINHEIT' | 'UNBEKANNT' | 'TIEFE' }>

// DB-gebundene Funktionen
export async function listMengenGroessen(ctx: DomainContext, input: { include_inactive?: boolean })
export async function replaceMengenGroessen(ctx: DomainContext, input: ReplaceMengenGroessenInput)
export async function ensureMengenGroessenSeed(ctx: DomainContext)  // idempotent, FR-16
export async function listPakete(ctx: DomainContext, input: ListPaketeInput)
export async function getPaket(ctx: DomainContext, input: { id: number })
export async function createPaket(ctx: DomainContext, input: CreatePaketInput)
export async function updatePaket(ctx: DomainContext, input: UpdatePaketInput)
export async function replacePaketStruktur(ctx: DomainContext, input: ReplaceStrukturInput)
export async function duplicatePaket(ctx: DomainContext, input: { id: number; name?: string })
export async function deletePaket(ctx: DomainContext, input: { id: number })
export async function getAufmassMengenbasis(ctx: DomainContext, input: { aufmass_id: number })
export async function previewPaketAnwendung(ctx: DomainContext, input: ApplyPaketInput)
export async function applyPaketToOffer(ctx: DomainContext, input: ApplyPaketInput)
export async function refreshOfferMengen(ctx: DomainContext, input: { offer_id: number; apply: boolean })

ApplyPaketInput:

ts
interface ApplyPaketInput {
  offer_id: number
  paket_id: number
  anwendungsmodus?: 'gesamt' | 'je_bereich'
  aufmass_id?: number
  bereich_ids?: number[]
  bezugsgroessen?: Record<string, number>   // ohne Aufmaß bzw. für herkunft='manuell'
  gruppen_uebernehmen?: boolean
  zeilen?: Array<{
    paket_position_id: number
    aktiv?: boolean
    menge?: number
    unit_price?: number
    beschreibung?: string
    einheit?: string
    item_type?: 'standard' | 'alternativ' | 'eventual'
  }>
}

Endpoints

MethodePfadRollenCodes
GET/api/pakete1,2200, 401, 403
POST/api/pakete1,2201, 400, 403
GET/api/pakete/{id}1,2200, 403, 404
PUT/api/pakete/{id}1,2200, 400, 404, 409
PUT/api/pakete/{id}/struktur1,2200, 400, 404, 409, 413
POST/api/pakete/{id}/duplicate1,2201, 404
DELETE/api/pakete/{id}1200, 404, 409 (in_use)
GET/api/settings/mengengroessen1,2200, 403
PUT/api/settings/mengengroessen1,2200, 400 (ZYKLUS/EINHEIT), 409
POST/api/offers/{id}/pakete/preview1,2200, 400, 403, 404
POST/api/offers/{id}/pakete1,2201, 400, 403, 404, 409
POST/api/offers/{id}/mengen-refresh1,2200, 403, 404
GET/api/offers/{id}/items1,2 (3 ohne Kosten)200 + groups[], Nebensummen
GET/api/aufmass/{id}/mengenbasis1,2200, 404
POST/api/aufmass/{id}/create-offer1,2201 + paket_id, anwendungsmodus, bereich_ids

Domain-Tools: pakete.list, pakete.get, offers.apply_package, mengengroessen.list.

Events: pakete.created|updated|deleted, mengen_groessen.updated, offers.paket_applied, offers.mengen_refreshed.

Validierung

  • name 1..255; paket_number je Mandant eindeutig.
  • Gruppen 0..50, Positionen 1..300, sort_order positiv und je Paket eindeutig.
  • menge_faktor > 0 und ≤ 10 000 (≤ 4 Nachkommastellen); default_menge ≥ 0 (≤ 3 Nachkommastellen); default_ep 0..99 999 999,99.
  • mengen_groesse_key existiert und ist aktiv; Einheitengleichheit gemäß FR-7.
  • Mengengrößen: Slug-Muster, eindeutiger key, Formelprüfung (Zyklus, Tiefe ≤ 3, ≤ 10 Summanden, Einheitengleichheit), volumen nur mit vorhandener Tiefenquelle.
  • Referenzierte service_id/material_id müssen im Mandanten existieren; inaktive Einträge erzeugen bei der Anwendung KATALOG_INAKTIV.
  • Unbekannte Felder werden am API-Rand abgewiesen.

Verhalten und UX

Seite „Pakete" (/pakete)

  • Navigationseintrag „Pakete" direkt nach „Dienstleistungen", Icon Layers, requiredPermissions: ['projects:read'], showForRoles: [1, 2].
  • Liste im Muster von /material und /dienstleistung: Suche, Aktiv-Filter, Spalten Name, Nummer, Gewerk, Gruppen, Positionen, Status; Aktionen „Neu", „Duplizieren", „Deaktivieren".
  • Detail /pakete/[id]: links Gruppenliste (verschiebbar), rechts Positionstabelle mit Nr., Art, Katalogbezug, Text, Einheit, Mengenregel, Faktor, Standardmenge, Preisquelle, EP, Positionsart, vorausgewählt.
  • Mengenregel-Feld ist ein Select über den Mengengrößen-Katalog des Mandanten mit Einheitshinweis; direkt daneben ein Link „Mengengrößen verwalten".
  • Live-Spalte mit aufgelöstem EP und Warnhinweis bei unvollständiger Kalkulation.
  • Speichern ersetzt die Struktur atomar mit expected_struktur_version; 409 führt zu Neuladen mit Konfliktmeldung.
  • Leerer Zustand nennt das Gewerk des Mandanten: „Noch keine Pakete — Vorschläge für {Gewerk} übernehmen oder leeres Paket anlegen."

Mengengrößen-Pflege (/settings/mengengroessen)

  • Tabelle mit Key, Label, Einheit, Art, Herkunft, Quelle, aktiv.
  • Beim ersten Öffnen wird der Gewerks-Seed angelegt und als solcher markiert.
  • Formelgrößen werden mit Summandenliste und Live-Ergebnis auf einem Beispielaufmaß angezeigt; Zyklen werden sofort als Fehler markiert.
  • Löschen ist nur ohne Verwendung möglich, sonst Hinweis „deaktivieren".

Paket im Angebot

  • Button „Paket einfügen" im Positions-Editor (nur Draft).
  • Schritt 1 Paket wählen → Schritt 2 Mengenquelle (Aufmaß wählen oder Bezugsgrößen eintragen — es werden nur die im Paket verwendeten Größen abgefragt) → Schritt 3 Vorschau-Tabelle im Angebotslayout, jede Zelle editierbar, Checkbox je Zeile, Badges „aus Aufmaß", „Menge prüfen", „Eventual", „Kalkulation unvollständig", Fußzeile mit Summen → Schritt 4 Übernehmen.
  • Erfolgsmeldung nennt Zeilen, Titel und offene Mengen.

Aufmaß

  • Messzeilen erhalten Feld „Größe" (mengen_key) mit Vorschlagsliste aus dem Mengengrößen-Katalog; Typen und Einheiten kommen aus dem Gewerkprofil.
  • Panel „Mengenbasis" zeigt alle Größen mit Wert, Einheit und Herkunft — dieselbe Quelle wie die Paketvorschau.
  • Bereichs- und Seitentitel verwenden die Profil-Labels; Navigationseintrag heißt standardmäßig „Aufmaße".

PDF

  • Gruppentitel als volle Zeile mit Nummer, Positionen mit GG.PP, optionale Gruppensumme.
  • Abschnitte „Alternativpositionen" und „Eventualpositionen" nach dem Summenblock mit eigener Nettosumme.
  • Angebote ohne Gruppen rendern unverändert.

Änderungsflächen

  • Daten: 094_leistungspakete_mengengroessen.sql, 095_offer_item_groups_paket_herkunft.sql, 096_aufmass_gewerksneutral.sql.
  • Domain:
    • neu packages/domain/src/leistungspakete.ts, packages/domain/src/mengen-groessen.ts
    • packages/domain/src/aufmass.ts (Mengenbasis, Geometrieableitungen inkl. Grundfläche und Volumen, mengen_key, tiefe, Typ-/Einheitenprüfung gegen Profil)
    • packages/domain/src/trade-profiles.ts (Berechnungsart volume, Einheitenliste, Labels für Aufmaß/Bereich/Messzeile, Mengengrößen-Seeds je Profil, neue Profile)
    • packages/domain/src/index.ts (addOfferItems Batch, group_id, Summenfilter, Nebensummen, Re-Exports, Tool-Registry)
  • API: neu /api/pakete/**, /api/settings/mengengroessen, /api/offers/[id]/pakete/**, /api/offers/[id]/mengen-refresh, /api/aufmass/[id]/mengenbasis; Erweiterung create-offer und offers/[id]/items.
  • UI: neu app/pakete/** (Liste + Detail), app/settings/pakete/page.tsx (Redirect), app/settings/mengengroessen/**, components/offers/PaketAnwendenDialog.tsx; Änderungen in OfferDetailClient.tsx, AufmassDetailClient.tsx, navigation.ts, site-surface.ts (/pakete, außerdem fehlendes /aufmass nachziehen), lib/template-engine.ts + templates/offer-template.html, api/pdf/offers/[id]/route.ts.
  • Agent: packages/agent/src/capabilities/sales-offer-create.ts.
  • Doku: docs/features/05-angebote-vertrieb.md, docs/features/README.md, docs/features/21-agent-tools.md.

Umsetzungsplan

P0 (optional, empfohlen) — Kostenwahrheit: Rezeptur/Engine v2

  • Inhalt: n:m-Rezeptur und Engine v2 für die Leistungskalkulation (bislang nicht umgesetzt).
  • Ohne P0 laufen Pakete mit katalog- und paket_fix-Preisen; mit P0 entsteht der EP aus Material + Lohn + Zuschlägen.
  • Auswirkung hier: nur der Aufruf in Preisauflösung Schritt 3 wechselt. Keine Schemaänderung an Paketen.

P1 — Fundament: Mengengrößen + Pakete als eigene Seite

  • Migration 094.
  • Domain mengen-groessen.ts (CRUD, Seed, Validierung inkl. Zyklusprüfung) und leistungspakete.ts (CRUD, replacePaketStruktur, Duplikat, Löschschutz).
  • Seeds für mindestens vier Gewerke (Maler, Garten-/Landschaftsbau, Pool-/Schwimmbadbau, SHK) als Profildaten.
  • Seite /pakete (Liste + Detail), Nav-Eintrag, /settings/pakete-Redirect, site-surface.ts ergänzen; Pflegeseite für Mengengrößen.
  • Neutralitätstest (NFR-9) einrichten.
  • DoD: Paket mit 5 Gruppen/14 Positionen über /pakete anlegbar; Seed-Katalog für alle vier Gewerke vorhanden; 409 bei konkurrierender Änderung nachgewiesen.

P2 — Angebotsstruktur: Gruppen, Herkunft, Batch

  • Migration 095.
  • addOfferItems (Batch, eine Transaktion, ein Totals-Update), group_id in getOffer/addOfferItem/updateOfferItem, Gruppen-CRUD mit Umhängen statt Löschen.
  • Nummerierungsfunktion in der Domain, genutzt von Web und PDF.
  • PDF: Gruppenzeilen, zweistufige Nummern, Snapshot-Test für Angebote ohne Gruppen.
  • DoD: identische Nummerierung in App und PDF; Bestandsangebote unverändert (NFR-7).

P3 — Paket-Übernahme ins Angebot (ohne Aufmaß)

  • previewPaketAnwendung, applyPaketToOffer, Preisauflösung, Warncodes.
  • Bezugsgrößen werden im Dialog eingetragen (bezugsgroessen).
  • APIs und PaketAnwendenDialog, Herkunftschips im Positions-Editor.
  • DoD: Referenzpaket erzeugt 12 aktive Positionen in einer Transaktion; Abbruch schreibt nichts.

P4 — Gewerksneutrales Aufmaß und Mengenbrücke

  • Migration 096 (Typ-Freigabe, tiefe, mengen_key, mengen_groesse_key, Abzugsziel).
  • berechneMengenbasis + Geometrieableitungen inkl. Grundfläche und Volumen (behebt B-4, B-5, B-6); berechnePositionsMenge und calculateRaumPositionMenge auf Mengengrößen umstellen.
  • Einheiten und Messzeilentypen aus dem Profil (B-1, B-3), Berechnungsart volume (B-7), Labels aus dem Profil inklusive Navigation (B-8).
  • GET /api/aufmass/[id]/mengenbasis, Aufmaß-UI (mengen_key, Mengenbasis-Panel).
  • Paketvorschau/-anwendung mit aufmass_id, anwendungsmodus, bereich_ids; createOfferFromAufmass mit paket_id; mengen-refresh mit Diff.
  • DoD: Szenarien S-2 bis S-8 und S-13 grün; Vier-Gewerke-Test grün.

P5 — Alternativ- und Eventualpositionen aktivieren

  • Summenfilter, Nebensummen in der API, UI-Umschalter, Badges, PDF-Abschnitte, sales.offer.add_item mit item_type.
  • DoD: Rabatt wirkt nur auf die Standardsumme; Bestandsangebote unverändert.

P6 — Agent

  • offers.apply_package, pakete.list, pakete.get, mengengroessen.list.
  • Antwort nennt Zeilen, Standardsumme, offene Mengen; keine Kostenanteile.
  • DoD: Agent erzeugt dieselben Zeilen wie das Web.

P7 — Gewerks-Seeds und neue Profile

  • Neue Profile garten-landschaftsbau, pool-schwimmbadbau (B-9), optional weitere; Startpakete je Gewerk als Seed.
  • DoD: Ein neues Gewerk ist ohne Codeänderung vollständig nutzbar (S-19).

P8 — Optional

  • „Angebot als Paket speichern", Paketvarianten, Paketexport/-import.

Testplan

Reine Funktionen (packages/domain/test/leistungspakete.test.ts, packages/domain/test/mengen-groessen.test.ts, Erweiterung aufmass.test.ts):

  1. berechneMengenbasis je Art (flaeche, laenge, stueck, volumen, pauschal).
  2. Geometrieableitungen inkl. Grundfläche und Volumen; Regression: bestehende Wand-/ Deckenwerte unverändert.
  3. Formelgrößen: Summierung, Tiefe 3, Zyklus, Einheitenkonflikt.
  4. loesePaketMenge: alle drei Mengenbasen, Faktor, fehlende Größe → Fallback + Warncode.
  5. berechnePositionsnummern: mit/ohne Gruppen, Sammelgruppe, ≥ 10 Gruppen.
  6. Preisauflösung: alle vier Regeln inklusive Fallbacks.
  7. Validierung: Einheitenkonflikt, material + kalkulation, text ohne paket_fix, Slug-Regeln, Grenzwerte 50/300/200.
  8. Vier-Gewerke-Referenztest: Maler, Garten-/Landschaftsbau, Pool-/Schwimmbadbau, SHK — je ein Aufmaß und ein Paket, erwartete Mengen exakt; alle vier laufen durch denselben Code ohne Verzweigung.
  9. Neutralitätstest (NFR-9): Sperrliste gewerksspezifischer Bezeichner gegen packages/domain/src und apps/app/src, ausgenommen Seed- und Migrationsdateien.

Domain-/Integrationstests:

  1. replacePaketStruktur: atomar, Version + 1, 409, keine Teilschreibung.
  2. applyPaketToOffer: eine Transaktion, ein Totals-Update, Herkunftsfelder korrekt, Rollback bei Fehler in Zeile n.
  3. ensureMengenGroessenSeed: idempotent, überschreibt keine Nutzeränderungen.
  4. Tenant-Negativtests für alle neuen Endpunkte; Rolle 3 erhält 403.
  5. deletePaket/deleteMengenGroesse mit Verwendung → 409 in_use.
  6. refreshOfferMengen: nur menge_quelle='aufmass', Diff ohne apply schreibt nichts.

Regression:

  1. Angebote ohne Gruppen: Summen und PDF-Snapshot identisch vor/nach 095.
  2. createOfferFromAufmass ohne paket_id unverändert.
  3. Bestandsaufmaße: flaechen_filter-Backfill trifft die richtige Mengengröße; Bodenflächen wechseln erst bei der nächsten Neuberechnung von 0 auf den echten Wert.

Nicht-funktional:

  1. axe für /pakete, Paketeditor, Mengengrößen-Pflege, Übernahmedialog.
  2. Lastprobe gemäß NFR-1/NFR-2 mit Query-Zähler.

Kommandos: npm run test:domain, npm run test, npm run type-check, npm run lint, npm run db:migrate:status, npm run build:app.

Rollout und Rollback

  • Reihenfolge: 094 → P1 → 095 → P2 → P3 → 096 → P4 → P5 → P6 → P7.
  • 094 und 095 sind rein additiv; App-Rollback bleibt funktionsfähig (Defaults).
  • 096 lockert Constraints und ergänzt Spalten. Rollback der Typ-Freigabe ist nur möglich, solange keine neuen Typen erfasst wurden — deshalb erst nach P4-Abnahme ausrollen und im Changelog festhalten.
  • Boden-/Grundflächen-Fix ändert berechnete Mengen von 0 auf den echten Wert, sobald der Bereich erneut gespeichert wird; betroffene Aufmaße vor dem Release zählen und nennen. Keine rückwirkende Massenaktualisierung.
  • item_type-Filter ist die einzige verhaltensrelevante Summenänderung und wird gemeinsam mit UI und PDF ausgeliefert; Rollback = Filter entfernen.
  • Kein neues ENV, kein Secret, kein externer Dienst, keine neue Dependency.

Abhängigkeiten und Konflikte mit bestehenden Specs

  • A-1 Migrationsnummern: Vor Umsetzungsstart aktuellen Migrationsstand (npm run db:migrate:status) prüfen und die nächste freie Nummer für diese Spec vergeben — eine Nummer, ein Owner.
  • A-2 pricing_source: ist um 'paket' zu ergänzen, sobald eine versionierte n:m-Rezeptur/Engine v2 für Leistungen umgesetzt wird.
  • A-3 offer_items.item_type: Owner-Migration und Aktivierungssemantik sind bei Umsetzungsstart neu festzulegen (Phase-3-Spec bleibt normativ für das Verhalten).
  • A-4 Aufmaß-Brücke: Die Phase-4-Spec bleibt für den positionsbasierten Weg normativ; diese Spec ergänzt den paket-/größenbasierten Weg. Die Paketanwendung setzt aufmass_position_id nicht — keine doppelte Herkunft.
  • A-5 Materialbedarf: Paketpositionen vom Typ material erzeugen normale Angebotspositionen ohne Sonderweg (kein separates Materialbedarfsmodul vorausgesetzt).
  • A-6 Trade-Profile-Spec: Berechnungsart volume, Einheitenliste, Labels und Mengengrößen-Seeds erweitern das Profilschema; die Änderungen sind additiv und rückwärtskompatibel (fehlende Felder = heutige Defaults).

Constraints und Invarianten

  • Gewerksspezifisches gehört in Daten, nie in Logik (NFR-9 ist die Durchsetzung).
  • Ein Paket ist Stammdatum; Änderungen wirken nie rückwirkend auf Angebote.
  • Eine Angebotsposition hat höchstens eine Herkunft: aufmass_position_id oderpaket_position_id (+ optional aufmass_id).
  • offer_item_groups.offer_id und offer_items.offer_id müssen für verknüpfte Zeilen übereinstimmen (Domain-Invariante).
  • Positionsnummern werden nie gespeichert, immer berechnet.
  • Mengen- und Preisauflösung existieren genau einmal; UI und Agent rechnen nicht selbst.
  • Kosten- und Kalkulationsdetails verlassen nie den internen Kanal.

Risiken

RisikoWirkungGegenmaßnahme
Mengengrößen-Katalog wirkt für Kleinbetriebe überforderndFeature wird nicht genutztGewerks-Seed liefert 8–12 fertige Größen; Pflegeseite ist optional erreichbar
Typ-Freigabe im Aufmaß (096) lässt Tippfehler-Typen zuDatenwildwuchsSlug-Zwang + Prüfung gegen Profil in der Domain; UI bietet nur Profiltypen an
Umstellung flaechen_filtermengen_groesse_keyBestandsaufmaße rechnen falschBackfill + Regressionstest 18 + Beibehaltung der Altspalte
„Paket" kollidiert sprachlich mit EinkaufsgebindeVerwirrung im KatalogUI-Begriffe trennen: „Pakete" (Angebot) vs. „Einkaufsgebinde" (Material)
Große Pakete (300 Positionen)Lange TransaktionKapazitätsgrenze, Batch-Insert, Messung NFR-2
Parallelarbeit mit der Rezeptur-UmsetzungMigrationskollisionA-1 vor Start klären

Offene Punkte

  • O-1 paket_number automatisch (PK-0001) oder frei? Vorschlag: frei mit Autovorschlag.
  • O-2 Übermessung: berechneRaumbuch ignoriert companies.aufmass_uebermessung_m2, messzeileWirksameFlaeche wendet sie an. Vor P4 entscheiden; die Mengenbasis folgt der Entscheidung ohne eigene Variante.
  • O-3 Pakete nach Gewerk vorfiltern oder immer alle zeigen? Vorschlag: Feld pflegen, Filter als Vorauswahl, keine harte Sperre.
  • O-4 Sollen Mengengrößen zusätzlich auf Projekt-/Anfrageebene überschreibbar sein (z. B. „Baustellenzuschlag 5 %")? Vorschlag: nein, erst nach Praxisfeedback.
  • O-5 Reichen fünf Mengenarten, oder braucht es gewicht (t/kg) für Erd- und Entsorgungsleistungen? Vorschlag: gewicht erst bei konkretem Bedarf ergänzen — das Modell ist additiv erweiterbar.

Work7 · Software für Handwerksbetriebe