Darstellung
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/materialund/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: Tabelleoffer_sections(parent_idself-FK, max. 2 Ebenen,code,title,sort_order,vorbemerkungaus 105).offer_itemsträgtsection_id+oz. Die OZ wird gespeichert, nicht berechnet —renumberOffer()(offer-sections.ts:47) vergibtpositionundoz(Format01.02.0010) nach jeder Strukturmutation gemeinsam. ⇒ Ein Paket bildet seine Gruppen aufoffer_sectionsab;offer_item_groupsentfällt. - ✅
offer_items.item_type(standard|alternativ|eventual) existiert seit100_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-Katalogtext_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 übernehmenmaterials.price(VK). - ⚠️ Positionen entstehen einzeln über
addOfferItem(index.ts:4018). Jeder Aufruf öffnet eine eigene Transaktion und ruft darinrenumberOffer()undrecalculateOfferTotals(). 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. ⇒ EinaddOfferItems-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:
addOfferItemschreibtunit_price,calculated_unit_priceund dencost_breakdown-Snapshot. Neuberechnung nur explizit (recalculateOfferItem) oder wenn sichoverhead_percent/risk_profit_percentändern, und dann nur für Positionen mitprice_overridden = false. offer_itemshat keincompany_id— Mandanz läuft überoffer_id → offers.company_id. Für tenantsichere Composite-FKs aufoffersfehlt einuq_offers_company_id_id;materials/serviceshaben ihre Pendants seit084_catalog_foundations.sql.- Kein Positions-Steuersatz: USt. ist ausschließlich
offers.tax_rate.resolveCatalogLineItemliesttax_rateaus dem Katalog, verwirft ihn aber beim Insert. /materialund/dienstleistungsind Top-Level-Routen mit Navigationseintrag (navigation.ts:180-195);/settings/materialund/settings/dienstleistungsind 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
| # | Blocker | Fundstelle | Wirkung |
|---|---|---|---|
| B-1 | Messzeilentypen fest auf 7 Werte (wand, decke, boden, oeffnung, sonstig, lfm, stueck) | 076_aufmass_measurement_types.sql | Gartenbau kann keine „Hecke", Poolbau kein „Becken" erfassen |
| B-2 | aufmass_positionen.flaechen_filter fest auf wand|decke|boden | 072_aufmass.sql | Berechnete Mengen nur für Innenraumflächen |
| B-3 | AUFMASS_EINHEITEN fix ["m²","lfm","Stk","Std","pausch"] | aufmass.ts:23 | Kein m³ (Aushub, Wasservolumen), kein t/kg/Ltr |
| B-4 | berechneRaumbuch rechnet ausschließlich Innenraumgeometrie (Wand/Decke/Umfang) | aufmass.ts:71 | Freiflächen, Becken, Trassen fallen durch |
| B-5 | Bodenfläche fehlt im Raumbuch; flaechen_filter='boden' liefert 0 | aufmass.ts:277-283 | Auch für Maler falsch |
| B-6 | lfm- und stueck-Messzeilen werden nirgends aggregiert | aufmass.ts:107 | Stückzahlen (Türen, Pflanzen, Steckdosen) nicht abgreifbar |
| B-7 | Berechnungsarten ohne volume | trade-profiles.ts:46 | Volumenleistungen nicht abbildbar |
| B-8 | Navigationslabel „Raumbücher" hart codiert | navigation.ts:169 | Gartenbauer hat keine Räume |
| B-9 | TRADE_PROFILE_IDS fest auf 7 Gewerke ohne Garten-/Landschaftsbau und Pool-/Schwimmbadbau | trade-profiles.ts:4 | Zielgewerke nicht wählbar |
| B-10 | /aufmass fehlt in APP_ROUTE_PREFIXES | site-surface.ts:5 | Bestehender 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.
- Pakete als eigenständige, gleichrangige Stammdatenseite
- 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.
- Kein zweites Rezeptur-/Kostenmodell neben
- Optional / später
- „Angebot als Paket speichern", Paketvarianten je Qualitätsstufe, Paketexport/-import.
Kernentscheidungen
| # | Entscheidung | Begründung |
|---|---|---|
| D1 | Pakete sind eine eigene Top-Level-Seite /pakete mit Nav-Eintrag; /settings/pakete ist nur Redirect | Symmetrie zu /material und /dienstleistung; Pakete sind Arbeitsmittel, keine Einstellung |
| D2 | Paket ist eigene Stammdatenentität, nicht „Angebot als Vorlage kopieren" | Pakete tragen Mengenregeln, Preisquellen und Versionierung |
| D3 | Mengen kommen aus einem mandantenspezifischen Mengengrößen-Katalog, nicht aus fest benannten Variablen | Einzige Möglichkeit, Maler, Gartenbau, Poolbau, Installateur identisch zu bedienen |
| D4 | Gewerksunterschiede sind Daten (Seeds im Gewerkprofil), niemals Code-Verzweigungen | Neues Gewerk = neuer Seed, kein Release |
| D5 | Paketposition referenziert genau eine Katalogzeile (service_id oder material_id) oder ist Text | Material-in-Leistung ist Aufgabe der Rezeptur |
Ü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 Transaktion | Nutzer sieht alle Zeilen, bevor sie entstehen |
offer_items.item_type wird von Migration 095 angelegt | ERLEDIGT (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 erzeugen | offer_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 nutzbar | LV-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 fremdvergeben | 094 = Materialimport v2, 095/096 = Katalog-Meta bzw. Sale-Markup |
| D9 | Paketfixpreise sind eine eigene Preisquelle, kein „manueller Override" | Sonst gilt jede Paketzeile als vom Nutzer geändert |
| D10 | Aufmaßänderungen wirken nie rückwirkend; es gibt eine explizite Aktion mit Diff | Snapshot-Doktrin aus FR-8 der Kalkulations-Spec |
| D11 | Pakete funktionieren ohne Rezeptur v2 (Preisquellen katalog/paket_fix) | Feature ist nicht durch C0–C2 blockiert |
| D12 | Bereichs-, 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:
- Kein Domain-, API- oder UI-Code enthält gewerksspezifische Bezeichner. Zeichenketten wie
wand,decke,boden,raum,hecke,beckendürfen ausschließlich in Seed-Daten und Migrations-Backfills vorkommen, nicht in Logik. - Alles Gewerksspezifische ist Konfiguration in
trade_profiles/companies.trade_configoder in den Mandantendaten (mengen_groessen). - 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. - Ein neues Gewerk ist ein Datensatz, kein Release: Profil anlegen, Messzeilentypen, Einheiten, Mengengrößen und optionale Startpakete seeden.
- Sprachliche Neutralität in der UI: Labels für Bereich, Aufmaß und Messzeilen kommen aus dem Profil; die Defaults sind neutral („Aufmaß", „Bereich", „Messzeile").
- 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
| Gewerk | Bereich heißt | Typische Mengengrößen | Beispiel-Paketposition |
|---|---|---|---|
| Maler | Raum | wand_netto m², decke m², boden m², tuerzarge Stk | „Wände 2× streichen" × wand_netto |
| Garten-/Landschaftsbau | Fläche / Beet | rasenflaeche m², heckenlaenge lfm, pflanzen Stk, aushub m³ | „Rollrasen verlegen" × rasenflaeche |
| Pool-/Schwimmbadbau | Becken | beckenflaeche m², beckenumfang lfm, wasservolumen m³, technikanschluss Stk | „Folienauskleidung" × folienflaeche |
| Installateur (SHK) | Strang / Etage | leitungslaenge lfm, anschluss Stk, heizkoerper Stk, sanitaerobjekt Stk | „Heizkörper montieren" × heizkoerper |
| Elektro | Stromkreis / Etage | kabellaenge 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-1Pakete sind unter/paketeerreichbar (Liste) und/pakete/[id](Detaileditor), mit Navigationseintrag „Pakete" in derselben Gruppe wie Material und Dienstleistungen, sichtbar für Rollen 1/2./settings/paketeleitet auf/paketeum;/paketewird inAPP_ROUTE_PREFIXESergänzt.FR-2Ein Paket gehört genau einem Mandanten; alle Pfade sind aufcompany_idbegrenzt, fremde IDs liefern404.FR-3Ein Paket hat 0..50 Gruppen und 1..300 Positionen. Positionen ohne Gruppe sind erlaubt und werden als letzte Sammelgruppe angezeigt.FR-4Eine Position istkind = leistung | material | text;leistungerfordertservice_id,materialerfordertmaterial_id,texterfordertbeschreibungohne Katalogbezug.FR-5Positionstext ist standardmäßig der Katalogtext; ein in der Position gepflegter Text hat Vorrang und wird als mehrzeiliger Langtext übernommen.FR-6Mengenregel istmenge_basis = fix | mengengroesse | manuell:fix:default_menge.mengengroesse:round3(menge_faktor × Mengenbasis[mengen_groesse_key]).manuell:default_mengeals Vorschlag, Zeile wird als „Menge prüfen" markiert.
FR-7mengen_groesse_keymuss 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_errormit Positionsnummer.FR-8Fehlt die Mengengröße beim Anwenden im Aufmaß, giltdefault_mengeplus WarnungMENGENGROESSE_FEHLT; die Übernahme bleibt möglich.FR-9Preisquelle istpreis_quelle = kalkulation | katalog | paket_fix; Auflösung und Fallbacks siehePreisauflösung.paket_fixerfordertdefault_ep.FR-10kind = materialdarfpreis_quelle = kalkulationnicht verwenden;kind = textverwendet immerpaket_fix(EP darf 0 sein).FR-11Jede Position trägtitem_type = standard | alternativ | eventualundvorausgewaehlt.FR-12Die Paketstruktur wird ausschließlich atomar ersetzt (PUT …/struktur) mit optimistischer Prüfung überexpected_struktur_version; veraltete Versionen =409.FR-13Pakete sind duplizierbar und deaktivierbar. Löschen nur ohne Angebotsverweise, sonst409 in_use.FR-14Paketpflege ist Rolle 1/2 vorbehalten; Rolle 3 erhält403, fremde Mandanten404.
Mengengrößen (gewerksneutrales Fundament)
FR-15Ein Mandant besitzt einen Katalog von Mengengrößen mitkey(Slug),label,einheit,art(flaeche|laenge|stueck|volumen|pauschal) undherkunft(messzeilen|geometrie|formel|manuell).FR-16Beim 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-17herkunft = messzeilen: Wert ist die Summe über alle Messzeilen mit passendemtypbzw.mengen_key, aggregiert gemäßart:flaeche = laenge × hoehe_breite × anzahl,laenge = laenge × anzahl,stueck = anzahl,volumen = laenge × hoehe_breite × tiefe × anzahl.FR-18herkunft = 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-19herkunft = 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-20herkunft = manuell: der Wert wird beim Anwenden eines Pakets abgefragt.FR-21Alle Summanden einer Formel müssen dieselbe Einheit haben wie das Ergebnis.FR-22Eine Mengengröße gilt als fehlend, wenn keine Quelle existiert (keine Messzeile, keine Geometrie, kein Summand). Ein vorhandener Wert0ist nicht fehlend.
Aufmaß (gewerksneutral machen)
FR-23Messzeilentypen kommen aus dem Gewerkprofil; der DB-CHECK wird von der festen 7er-Liste auf ein Slug-Muster umgestellt, die fachliche Prüfung erfolgt gegengetEffectiveTradeConfig. Bestehende Typen bleiben gültig (B-1).FR-24Berechnungsarten werden umvolumeerweitert; Messzeilen erhaltentiefe(optional). Volumenzeilen ohnetiefesind unvollständig und zählen nicht (B-7).FR-25Einheiten kommen aus dem Gewerkprofil; der Default-Satz wird umm³,kg,t,Ltrerweitert. Kein fixes Enum mehr im Domaincode (B-3).FR-26Messzeilen erhaltenmengen_key(Slug) zur Adressierung benannter Größen (Türzarge, Hecke, Steckdose). Ohnemengen_keyzählt eine Zeile nur in die Typsummen (B-6).FR-27aufmass_positionenerhaltenmengen_groesse_keyals Nachfolger vonflaechen_filter; der Altwert wird migriert (wand → wandflaeche_netto,decke → deckenflaeche,boden → grundflaeche) und bleibt lesbar (B-2).FR-28Die Bereichsgeometrie liefert zusätzlichgrundflaeche(Länge × Breite) undvolumen; die heutige 0-Menge für Bodenflächen entfällt (B-5).FR-29Anzeigelabels für Aufmaß, Bereich und Messzeile kommen aus dem Gewerkprofil (Defaults: „Aufmaß", „Bereich", „Messzeile"); das Navigationslabel „Raumbücher" wird entsprechend dynamisch (B-8).FR-30GET /api/aufmass/{id}/mengenbasisliefert die Mengenbasis gesamt und je Bereich, inklusive Einheiten und fehlender Größen.FR-31Die Mengenbasis wird ausschließlich aus Bereichsgeometrie und Messzeilen abgeleitet — genau eine Rechenstelle, gemeinsam genutzt von Aufmaß-UI, Paketvorschau und Angebot.
Angebot und Übernahme
FR-32Die Vorschau schreibt nichts und liefert je Zeile Nummer, Text, Einheit, Menge, Mengenherkunft, EP, Preisquelle, GP, Warnungen.FR-33Die Übernahme schreibt Gruppen und Positionen in einer Transaktion und aktualisiert die Angebotssummen genau einmal. Teilerfolge sind unzulässig.FR-34Die Übernahme akzeptiert je Zeileaktiv,menge,unit_price,beschreibung,einheit,item_type. Ein übergebenerunit_pricegilt als manueller Override; alle übrigen Werte werden serverseitig neu aufgelöst.FR-35Übernahme nur im Statusdraft(403sonst), Rollen 1/2.FR-36Jede erzeugte Position speichert Herkunft:paket_id,paket_position_id,menge_quelle,mengen_groesse_key,aufmass_id.FR-37Ein Angebot kann mehrere Pakete und freie Positionen enthalten; neue Gruppen werden angehängt, bestehende Nummern bleiben stabil.FR-38Anwendungsmodusgesamterzeugt die Gruppen einmal;je_bereicherzeugt je ausgewähltem Aufmaßbereich einen Gruppensatz mit Bereichsnamen im Titel.FR-39POST /api/aufmass/{id}/create-offerakzeptiert optionalpaket_id,anwendungsmodus,bereich_ids; ohnepaket_idbleibt das Verhalten unverändert.FR-40POST /api/offers/{id}/mengen-refreshberechnet Mengen mitmenge_quelle = 'aufmass'neu, liefert zuerst einen Diff und schreibt erst nach Bestätigung; manuell geänderte Mengen bleiben unberührt.FR-41Angebote mit mindestens einer Gruppe werden zweistufig nummeriert (GG.PP); Angebote ohne Gruppe behalten die heutige einstufige Nummerierung.FR-42recalculateOfferTotalssummiert nuritem_type = 'standard'; Alternativ- und Eventualsummen werden getrennt ausgewiesen. Rabatt und USt wirken nur auf die Standardsumme.FR-43Das PDF rendert Gruppentitel, zweistufige Nummern, optionale Gruppensummen und die Abschnitte „Alternativpositionen"/„Eventualpositionen" nach dem Summenblock.FR-44Der Agent kann Pakete auflisten und anwenden (offers.apply_package) über dieselbe Domainfunktion; keine Kostendetails im Kundenkanal.FR-45Domain-Events:pakete.*,mengen_groessen.*,offers.paket_applied,offers.mengen_refreshed— ohne Einkaufspreise oder Margen.
Nicht-funktional
NFR-1Vorschau 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-3Kapazität: ≤ 50 Gruppen und ≤ 300 Positionen je Paket, ≤ 200 aktive Mengengrößen je Mandant, Body ≤ 256 KiB.NFR-4Mengen auf 3, Geldbeträge auf 2 Nachkommastellen kaufmännisch gerundet; Zwischenwerte ungerundet; Abweichung im Total ≤ 0,01 EUR.NFR-5WCAG 2.2 AA für Paketseite, Editor, Übernahmedialog und Mengengrößen-Pflege; axe meldet 0 kritische/ernste Verstöße.NFR-6100 % der neuen Queries tragen Tenant-Prädikat oder Composite-FK; Negativtests liefern404.NFR-7Bestandsangebote ohne Gruppen liefern identische Summen und ein optisch unverändertes PDF.NFR-8Je Vorschau/Übernahme genau ein strukturierter Logeintrag mit Dauer, Paket-ID, Zeilenanzahl, Warncodes, Modus — ohne Preise.NFR-9Neutralitätsnachweis: Ein automatisierter Test scanntpackages/domain/srcundapps/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:
| art | Rechenregel je Messzeile | typische Einheit |
|---|---|---|
flaeche | laenge × hoehe_breite × anzahl | m² |
laenge | laenge × anzahl | lfm |
stueck | anzahl | Stk |
volumen | laenge × hoehe_breite × tiefe × anzahl | m³ |
pauschal | konstant 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üssel | Formel |
|---|---|
grundflaeche | laenge × breite |
deckenflaeche | laenge × breite |
umfang | 2 × (laenge + breite) |
wandflaeche_brutto | umfang × hoehe |
wandflaeche_netto | wandflaeche_brutto − Abzüge |
volumen | laenge × 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) | Einheit | Menge | Mengenregel |
|---|---|---|---|---|
| 01.01 | Baustelleneinrichtung, An-/Abfahrt | psch | 1 | fix |
| 01.02 | Böden abdecken, Bauteile abkleben | m² | 85 | mengengroesse boden |
| 01.03 | Möbel verrücken und abdecken | psch | 1 | fix |
| 02.01 | Flächen prüfen, Altanstrich entfernen | m² | 220 | mengengroesse wand_decke (Formel) |
| 02.02 | Risse/Dübellöcher schließen, Q2 | m² | 220 | mengengroesse wand_decke |
| 02.03 | Tiefgrund auftragen | m² | 220 | mengengroesse wand_decke |
| 02.04 | Flächenspachtelung Q3, nur bei Bedarf | m² | 40 | manuell, eventual, nicht vorausgewählt |
| 03.01 | Wände 2× Innenfarbe | m² | 170 | mengengroesse wand_netto |
| 03.02 | Decken 2× Innenfarbe | m² | 50 | mengengroesse decke |
| 03.03 | Akzentwand 2× farbig | m² | 18 | manuell |
| 04.01 | Türzargen lackieren | Stk | 5 | mengengroesse tuerzarge |
| 04.02 | Heizkörper lackieren | Stk | 3 | mengengroesse heizkoerper |
| 05.01 | Abdeckungen entfernen, Reinigung, Entsorgung | psch | 1 | fix |
| 05.02 | Abnahme und Übergabe | psch | 1 | fix |
wand_decke ist eine Formelgröße (1×wand_netto + 1×decke) aus dem Maler-Seed — kein Sonderfall im Code.
Beispiel Garten-/Landschaftsbau
| Pos. | Leistung | Einheit | Mengenregel |
|---|---|---|---|
| 01.01 | Baustelleneinrichtung, Geräteanfuhr | psch | fix |
| 01.02 | Oberboden abtragen und lagern | m³ | mengengroesse aushub |
| 02.01 | Pflasterfläche herstellen | m² | mengengroesse pflasterflaeche |
| 02.02 | Randsteine setzen | lfm | mengengroesse randsteinlaenge |
| 03.01 | Rollrasen verlegen | m² | mengengroesse rasenflaeche |
| 03.02 | Hecke pflanzen | lfm | mengengroesse heckenlaenge |
| 03.03 | Solitärgehölze pflanzen | Stk | mengengroesse baum |
| 04.01 | Baustelle räumen, Entsorgung | psch | fix |
Beispiel Pool-/Schwimmbadbau
| Pos. | Leistung | Einheit | Mengenregel |
|---|---|---|---|
| 01.01 | Baustelleneinrichtung | psch | fix |
| 01.02 | Baugrubenaushub | m³ | mengengroesse aushub |
| 02.01 | Beckenwände herstellen | m² | mengengroesse beckenwandflaeche |
| 02.02 | Beckensohle herstellen | m² | mengengroesse beckengrundflaeche |
| 03.01 | Folienauskleidung | m² | mengengroesse folienflaeche (Formel: Wand + Sohle) |
| 03.02 | Randsteine setzen | lfm | mengengroesse beckenumfang |
| 04.01 | Technikanschlüsse herstellen | Stk | mengengroesse technikanschluss |
| 04.02 | Erstbefüllung und Inbetriebnahme | m³ | mengengroesse wasservolumen |
Beispiel Installateur (SHK)
| Pos. | Leistung | Einheit | Mengenregel |
|---|---|---|---|
| 01.01 | Baustelleneinrichtung, Absperrung | psch | fix |
| 02.01 | Rohrleitung verlegen | lfm | mengengroesse leitungslaenge |
| 02.02 | Dämmung Rohrleitung | lfm | mengengroesse leitungslaenge |
| 03.01 | Heizkörper montieren und anschließen | Stk | mengengroesse heizkoerper |
| 03.02 | Sanitärobjekte montieren | Stk | mengengroesse sanitaerobjekt |
| 04.01 | Dichtheitsprüfung und Protokoll | psch | fix |
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 → neuje Zeile und schreibt erst nach Bestätigung; manuell geänderte Mengen werden übersprungen und ausgewiesen.
Preisauflösung
Erste zutreffende Regel gewinnt:
- Eingabe des Nutzers in der Vorschau →
preis_herkunft = 'manuell',price_overridden = true. preis_quelle = 'paket_fix'→default_ep,preis_herkunft = 'paket'.preis_quelle = 'kalkulation'(nurkind = leistung) → eine Kalkulationsfunktion: heutecalculateServicePrice, nach C2calculateServicePriceV2. Vollständig →'kalkulation'; unvollständig →services.default_price,preis_herkunft = 'katalog', WarnungKALKULATION_UNVOLLSTAENDIG.preis_quelle = 'katalog'→services.default_pricebzw.materials.price; fehlt der Preis →0plusKATALOGPREIS_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/materialund/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
gesamtangewendet 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
volumen48 m³,grundflaeche32 m²,umfang24 lfm und die Formelgrößefolienflaeche= 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, WarnungMENGENGROESSE_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_errormit 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_conflictmit 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_usemit 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.
| Stufe | Inhalt | Nicht enthalten |
|---|---|---|
| P1 — Pakete pur | leistungspakete + 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ückrechnung | Mengengröß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-P1Paketposition trägtlong_text; beim Übernehmen landet er inoffer_items.long_text.FR-P2Paketgruppe trägtvorbemerkung; beim Übernehmen landet sie inoffer_sections.vorbemerkung.FR-P3Im Paketeditor sind Textbausteine (text_blocks,category='position'bzw.'vorbemerkung') einfügbar — dieselbeTextBlockInsert-Komponente wie im Angebot.FR-P4Übernahme nutzt einaddOfferItems-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-P6Rundung der skalierten Menge auf 3 Nachkommastellen (offer_items.quantityistNUMERIC(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_groupsentfällt ersatzlos zugunsten vonoffer_sections(D6/D13);offer_items.item_typewird nicht neu angelegt (D8);uq_offers_company_id_idbleibt 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
095_offer_item_groups_paket_herkunft.sqlErsetzt durch
offer_sections(Migration 103). Aus diesem Entwurf bleibt nur:uq_offers_company_id_id(wandert nach 110) sowie die Herkunftsspalten anoffer_items(paket_id,paket_position_id) und anoffer_sections(paket_id).offer_item_groupswird 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
| Methode | Pfad | Rollen | Codes |
|---|---|---|---|
| GET | /api/pakete | 1,2 | 200, 401, 403 |
| POST | /api/pakete | 1,2 | 201, 400, 403 |
| GET | /api/pakete/{id} | 1,2 | 200, 403, 404 |
| PUT | /api/pakete/{id} | 1,2 | 200, 400, 404, 409 |
| PUT | /api/pakete/{id}/struktur | 1,2 | 200, 400, 404, 409, 413 |
| POST | /api/pakete/{id}/duplicate | 1,2 | 201, 404 |
| DELETE | /api/pakete/{id} | 1 | 200, 404, 409 (in_use) |
| GET | /api/settings/mengengroessen | 1,2 | 200, 403 |
| PUT | /api/settings/mengengroessen | 1,2 | 200, 400 (ZYKLUS/EINHEIT), 409 |
| POST | /api/offers/{id}/pakete/preview | 1,2 | 200, 400, 403, 404 |
| POST | /api/offers/{id}/pakete | 1,2 | 201, 400, 403, 404, 409 |
| POST | /api/offers/{id}/mengen-refresh | 1,2 | 200, 403, 404 |
| GET | /api/offers/{id}/items | 1,2 (3 ohne Kosten) | 200 + groups[], Nebensummen |
| GET | /api/aufmass/{id}/mengenbasis | 1,2 | 200, 404 |
| POST | /api/aufmass/{id}/create-offer | 1,2 | 201 + 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
name1..255;paket_numberje Mandant eindeutig.- Gruppen 0..50, Positionen 1..300,
sort_orderpositiv und je Paket eindeutig. menge_faktor> 0 und ≤ 10 000 (≤ 4 Nachkommastellen);default_menge≥ 0 (≤ 3 Nachkommastellen);default_ep0..99 999 999,99.mengen_groesse_keyexistiert und ist aktiv; Einheitengleichheit gemäß FR-7.- Mengengrößen: Slug-Muster, eindeutiger
key, Formelprüfung (Zyklus, Tiefe ≤ 3, ≤ 10 Summanden, Einheitengleichheit),volumennur mit vorhandener Tiefenquelle. - Referenzierte
service_id/material_idmüssen im Mandanten existieren; inaktive Einträge erzeugen bei der AnwendungKATALOG_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
/materialund/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;409fü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(Berechnungsartvolume, Einheitenliste, Labels für Aufmaß/Bereich/Messzeile, Mengengrößen-Seeds je Profil, neue Profile)packages/domain/src/index.ts(addOfferItemsBatch,group_id, Summenfilter, Nebensummen, Re-Exports, Tool-Registry)
- neu
- API: neu
/api/pakete/**,/api/settings/mengengroessen,/api/offers/[id]/pakete/**,/api/offers/[id]/mengen-refresh,/api/aufmass/[id]/mengenbasis; Erweiterungcreate-offerundoffers/[id]/items. - UI: neu
app/pakete/**(Liste + Detail),app/settings/pakete/page.tsx(Redirect),app/settings/mengengroessen/**,components/offers/PaketAnwendenDialog.tsx; Änderungen inOfferDetailClient.tsx,AufmassDetailClient.tsx,navigation.ts,site-surface.ts(/pakete, außerdem fehlendes/aufmassnachziehen),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- undpaket_fix-Preisen; mit P0 entsteht der EP aus Material + Lohn + Zuschlägen. - Auswirkung hier: nur der Aufruf in
PreisauflösungSchritt 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) undleistungspakete.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.tsergänzen; Pflegeseite für Mengengrößen. - Neutralitätstest (NFR-9) einrichten.
- DoD: Paket mit 5 Gruppen/14 Positionen über
/paketeanlegbar; Seed-Katalog für alle vier Gewerke vorhanden;409bei konkurrierender Änderung nachgewiesen.
P2 — Angebotsstruktur: Gruppen, Herkunft, Batch
- Migration 095.
addOfferItems(Batch, eine Transaktion, ein Totals-Update),group_idingetOffer/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);berechnePositionsMengeundcalculateRaumPositionMengeauf 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;createOfferFromAufmassmitpaket_id;mengen-refreshmit 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_itemmititem_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):
berechneMengenbasisje Art (flaeche,laenge,stueck,volumen,pauschal).- Geometrieableitungen inkl. Grundfläche und Volumen; Regression: bestehende Wand-/ Deckenwerte unverändert.
- Formelgrößen: Summierung, Tiefe 3, Zyklus, Einheitenkonflikt.
loesePaketMenge: alle drei Mengenbasen, Faktor, fehlende Größe → Fallback + Warncode.berechnePositionsnummern: mit/ohne Gruppen, Sammelgruppe, ≥ 10 Gruppen.- Preisauflösung: alle vier Regeln inklusive Fallbacks.
- Validierung: Einheitenkonflikt,
material+kalkulation,textohnepaket_fix, Slug-Regeln, Grenzwerte 50/300/200. - 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.
- Neutralitätstest (NFR-9): Sperrliste gewerksspezifischer Bezeichner gegen
packages/domain/srcundapps/app/src, ausgenommen Seed- und Migrationsdateien.
Domain-/Integrationstests:
replacePaketStruktur: atomar, Version + 1,409, keine Teilschreibung.applyPaketToOffer: eine Transaktion, ein Totals-Update, Herkunftsfelder korrekt, Rollback bei Fehler in Zeile n.ensureMengenGroessenSeed: idempotent, überschreibt keine Nutzeränderungen.- Tenant-Negativtests für alle neuen Endpunkte; Rolle 3 erhält
403. deletePaket/deleteMengenGroessemit Verwendung →409 in_use.refreshOfferMengen: nurmenge_quelle='aufmass', Diff ohneapplyschreibt nichts.
Regression:
- Angebote ohne Gruppen: Summen und PDF-Snapshot identisch vor/nach 095.
createOfferFromAufmassohnepaket_idunverändert.- 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:
- axe für
/pakete, Paketeditor, Mengengrößen-Pflege, Übernahmedialog. - 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-1Migrationsnummern: 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-2pricing_source: ist um'paket'zu ergänzen, sobald eine versionierte n:m-Rezeptur/Engine v2 für Leistungen umgesetzt wird.A-3offer_items.item_type: Owner-Migration und Aktivierungssemantik sind bei Umsetzungsstart neu festzulegen (Phase-3-Spec bleibt normativ für das Verhalten).A-4Aufmaß-Brücke: Die Phase-4-Spec bleibt für den positionsbasierten Weg normativ; diese Spec ergänzt den paket-/größenbasierten Weg. Die Paketanwendung setztaufmass_position_idnicht — keine doppelte Herkunft.A-5Materialbedarf: Paketpositionen vom Typmaterialerzeugen normale Angebotspositionen ohne Sonderweg (kein separates Materialbedarfsmodul vorausgesetzt).A-6Trade-Profile-Spec: Berechnungsartvolume, 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_idoderpaket_position_id(+ optionalaufmass_id). offer_item_groups.offer_idundoffer_items.offer_idmü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
| Risiko | Wirkung | Gegenmaßnahme |
|---|---|---|
| Mengengrößen-Katalog wirkt für Kleinbetriebe überfordernd | Feature wird nicht genutzt | Gewerks-Seed liefert 8–12 fertige Größen; Pflegeseite ist optional erreichbar |
| Typ-Freigabe im Aufmaß (096) lässt Tippfehler-Typen zu | Datenwildwuchs | Slug-Zwang + Prüfung gegen Profil in der Domain; UI bietet nur Profiltypen an |
Umstellung flaechen_filter → mengen_groesse_key | Bestandsaufmaße rechnen falsch | Backfill + Regressionstest 18 + Beibehaltung der Altspalte |
| „Paket" kollidiert sprachlich mit Einkaufsgebinde | Verwirrung im Katalog | UI-Begriffe trennen: „Pakete" (Angebot) vs. „Einkaufsgebinde" (Material) |
| Große Pakete (300 Positionen) | Lange Transaktion | Kapazitätsgrenze, Batch-Insert, Messung NFR-2 |
| Parallelarbeit mit der Rezeptur-Umsetzung | Migrationskollision | A-1 vor Start klären |
Offene Punkte
O-1paket_numberautomatisch (PK-0001) oder frei? Vorschlag: frei mit Autovorschlag.O-2Übermessung:berechneRaumbuchignoriertcompanies.aufmass_uebermessung_m2,messzeileWirksameFlaechewendet sie an. Vor P4 entscheiden; die Mengenbasis folgt der Entscheidung ohne eigene Variante.O-3Pakete nach Gewerk vorfiltern oder immer alle zeigen? Vorschlag: Feld pflegen, Filter als Vorauswahl, keine harte Sperre.O-4Sollen Mengengrößen zusätzlich auf Projekt-/Anfrageebene überschreibbar sein (z. B. „Baustellenzuschlag 5 %")? Vorschlag: nein, erst nach Praxisfeedback.O-5Reichen fünf Mengenarten, oder braucht esgewicht(t/kg) für Erd- und Entsorgungsleistungen? Vorschlag:gewichterst bei konkretem Bedarf ergänzen — das Modell ist additiv erweiterbar.