User Stories — Epic 4: Offerten
Format-Muster: Pilot 50-us-logistik.md. Methodik: Connextra (Als/möchte/damit) + Gherkin-Akzeptanzkriterien (Gegeben/Wenn/Dann) + INVEST-Qualitätsgate. Sprache der Stories DE; Fachbegriffe/Status wie im Schema. Bezug: Modul 4 (13-offerten.md), Schema Dok 30 §2.5 (
quotes/quote_lines), §2.6 (orders), RLS Dok 30 §13. Rollen-Codes kanonisch (UPPERCASE):ADMIN,OPERATIONS,BUCHHALTUNG,FAHRER,AFFILIATE,READONLY. IDs:US-OFF-NN(stabil). Status: Entwurf (zur Freigabe).
Personas in diesem Epic
| Rolle | Person | Bezug zu den Offerten |
|---|---|---|
OPERATIONS | Markus | Offerten erstellen, Positionen aus Katalog erfassen, versenden, akzeptieren/konvertieren |
ADMIN | Marcel | alles + Preis-Override (BR-25) + Expired-Reaktivierung + Storno |
BUCHHALTUNG | Mariela | nur lesen (R) — Beträge/Konversion für die Finanzsicht |
AFFILIATE | externer Partner | nur eigene Offerten (R(own)), kein Einblick in fremde Offerten/Beträge |
| Kunde | — (kein Login) | empfängt das Offert-PDF per Mail/WhatsApp in seiner Sprache |
INVEST-Gate (für alle Stories dieses Epics geprüft): jede Story ist unabhängig schneidbar, verhandelbar, liefert Geschäftswert, schätzbar, klein genug für einen Schritt und über die Gherkin-Szenarien testbar (inkl. Negativfälle/RLS).
US-OFF-01 — Offerte als Entwurf anlegen
Als OPERATIONS möchte ich für einen Kontakt eine neue Offerte mit Gültigkeitsdatum eröffnen, damit ich ein strukturiertes Angebot aufbauen kann, bevor es versendet wird.
Akzeptanzkriterien
- Gegeben ein existierender Kontakt (
contacts), wenn ich „Neue Offerte" anlege, dann entsteht einquotes-Datensatz mit fortlaufenderquote_number(QUO-YYYY-NNNN),status = DRAFT,issue_date = heuteundvalid_until = heute + 14(konfigurierbar). - Gegeben ein Kontakt ohne E-Mail und ohne Telefon, wenn ich die Offerte speichere, dann ist das Speichern erlaubt (Versand-Warnung erst beim Versenden, US-OFF-05).
- Gegeben ich bin
BUCHHALTUNG/READONLY, wenn ich eine Offerte anlegen will, dann wird die Aktion verweigert (nurADMIN/OPERATIONSdürfenC).
Traceability: quotes (§2.5), RLS §13 (quotes CRUD⁴ für ADM/OPS), Workflow §4.1.
US-OFF-02 — Position aus Katalog mit Preis-Resolver erfassen
Als OPERATIONS möchte ich Produkte aus dem Box-/Fass-Katalog wählen und den gültigen Preis automatisch ziehen, damit jede Position korrekt bepreist und revisionssicher als Snapshot festgehalten ist.
Akzeptanzkriterien
- Gegeben ein aktives Produkt (
box_products), wenn ich es als Position hinzufüge, dann zieht der Preis-Resolver die aktuell gültigeprice_list_items-Zeile (effective_from ≤ heuteAND (effective_to IS NULLOR≥ heute)) und speichertunit_price_chf,price_list_item_idsowieproduct_snapshot({code,label_de,label_es,label_en}). - Gegeben ich erfasse Menge und Einheit, wenn ich die Zeile speichere, dann wird
line_subtotal_chf = quantity × unit_price_chfserver-seitig berechnet und inquote_linespersistiert (positioneindeutig je Offerte). - Gegeben ein Produkt ohne gültige Preisliste, wenn ich es hinzufüge, dann erscheint eine Warnung, der Preis ist manuell zu erfassen und
price_list_item_idbleibtNULL(Override-Recht greift, US-OFF-08). - Gegeben ein Produkt mit
ON DELETE RESTRICT, dann sichertproduct_snapshotdie Anzeige auch nach späterer Deaktivierung des Katalogeintrags.
Traceability: quote_lines (§2.5), price_list_items (K-12), Preis-Resolver Modul 3 §4, Workflow §4.1 (3).
US-OFF-03 — Zeilen- und Gesamtrabatt anwenden
Als OPERATIONS möchte ich Rabatte je Position und auf den Gesamtbetrag vergeben, damit ich kommerziell flexibel anbieten kann, ohne die Berechnung zu verfälschen.
Akzeptanzkriterien
- Gegeben eine Position, wenn ich einen Zeilenrabatt (
line_discount_kind=PERCENT/ABSOLUTE,line_discount_value) setze, dann gilt die kanonische Reihenfolge:line_discount_chf(PERCENT:line_subtotal × rate/100; ABSOLUTE:min(value, line_subtotal)), dannline_total_chf = line_subtotal − line_discount_chf. - Gegeben mehrere Positionen, wenn ich einen Gesamtrabatt (
total_discount_kind/total_discount_value) setze, dann wird er nach den Zeilenrabatten aufsubtotal_chf = Σ line_total_chfangewendet →discount_total_chf,grand_total_chf(kein Doppelrabatt-Siloing). - Gegeben ein Rabatt, der das (Zeilen-/Gesamt-)Subtotal überschreitet, wenn ich speichere, dann wird er auf das Maximum gecapped —
grand_total_chfwird nie negativ. - Gegeben beliebige Client-Eingaben, dann sind alle Beträge server-seitig neu berechnet (HALF_UP, 2 Dezimalstellen je Zeile), die Client-Anzeige ist nur Preview.
Traceability: quote_lines.line_discount_*, quotes.total_discount_* (§2.5), Berechnungsreihenfolge §7.1, Edge-Case „Grand Total < 0" §7.3.
US-OFF-04 — Affiliate-/Rabattcode anhängen
Als OPERATIONS möchte ich einen Affiliate-/Rabattcode auf die Offerte anwenden, damit Partner-Konditionen greifen und die spätere Provision verknüpfbar ist.
Akzeptanzkriterien
- Gegeben ich gebe einen Code ein, wenn ich ihn bestätige, dann prüft das System
referral_codesauf Gültigkeit (is_active = true, Datumsfenster) und setzt bei Erfolgreferral_code_id,referral_code_text(Snapshot) sowie den denormalisiertenaffiliate_id. - Gegeben ein abgelaufener oder inaktiver Code, wenn ich ihn anwende, dann erscheint eine Inline-Fehlermeldung und der Code wird nicht gesetzt.
- Gegeben ein gültiger Code, dann bleibt
referral_code_textdauerhaft auf der Offerte erhalten, auch wenn der Code später deaktiviert wird. - 🔲 Entscheid-abhängig (O-08): Ob der Code-Rabatt den Kundenpreis reduziert (Ausweis auf PDF, fliesst in
discount_from_code_chf/grand_total_chf) oder nur intern als Provisions-Grundlage ohne Preis-Reduktion dient — beeinflusstgrand_total_chfund PDF. Bis zum Entscheid wird der Code gespeichert, aber nicht preiswirksam verrechnet.
Traceability: quotes.referral_code_id/referral_code_text/affiliate_id/discount_from_code_* (§2.5), referral_codes (§ Affiliate), offener Punkt O-08.
US-OFF-05 — Offerte als PDF generieren & versenden
Als OPERATIONS möchte ich die Offerte als PDF erzeugen und per WhatsApp und/oder E-Mail versenden, damit der Kunde ein professionelles Angebot in seiner Sprache erhält.
Akzeptanzkriterien
- Gegeben eine Offerte im Status
DRAFT/SENT, wenn ich „Versenden" auslöse, dann wird das PDF server-seitig erzeugt (Kopf, Positionen, Rabatte,grand_total_chf,valid_until, Code falls vorhanden,customer_notes, Branding), in Storage abgelegt (quotes/{id}/QUO-…pdf) undpdf_storage_pathgesetzt. - Gegeben der Versand erfolgreich, dann wechselt der Status auf
SENT,sent_at = now()undsent_viawird um den genutzten Kanal ergänzt (whatsapp/email). - Gegeben der Kontakt hat keine E-Mail (bzw. kein Telefon), wenn ich den Dialog öffne, dann ist der jeweilige Kanal deaktiviert/nicht wählbar.
- Gegeben die Sprache, dann richtet sich PDF + Mail-Template nach
contacts.preferred_lang(DE/ES/EN); der Benutzer kann sie im Versand-Dialog übersteuern (🔲 O-06). - Gegeben PDF-Generierung oder Storage-Upload schlägt fehl, wenn ich versende, dann bleibt der Status
DRAFT(kein Teilzustand), und es erscheint ein Fehler mit Retry.
Traceability: quotes.pdf_storage_path/sent_at/sent_via (§2.5), Versand-Workflow §4.2, contacts.preferred_lang, Resend-Setup; offene Punkte O-02/O-06.
US-OFF-06 — Status-Lifecycle steuern (DRAFT → SENT → ACCEPTED → CONVERTED, + EXPIRED)
Als OPERATIONS möchte ich den Status einer Offerte entlang erlaubter Übergänge fortschreiben, damit der kommerzielle Verlauf eindeutig und nachvollziehbar bleibt.
Akzeptanzkriterien
- Gegeben eine Offerte, wenn ich einen Statuswechsel auslöse, dann sind nur erlaubte Transitionen möglich:
DRAFT → SENT|REJECTED,SENT → ACCEPTED|REJECTED|EXPIRED,ACCEPTED → CONVERTED,EXPIRED → ACCEPTED(nur mit ADMIN-Bestätigung). - Gegeben eine unerlaubte Transition (z.B.
CONVERTED → DRAFT), wenn ich sie versuche, dann wird sie server-seitig mit409 Conflictabgelehnt. - Gegeben der tägliche Cron-Job, wenn
valid_until < heuteANDstatus = SENT, dann setzt er den Status automatisch aufEXPIRED. - Gegeben jeder Statuswechsel, dann schreibt das
audit_logeinen Eintrag (source_type='quote',old_status,new_status,user_id,timestamp).
Traceability: quote_statuses(code) (§2.5), erlaubte Transitionen §7.2, Cron §4.5, audit_log §8.1.
US-OFF-07 — Offerte akzeptieren & in Auftrag konvertieren
Als OPERATIONS möchte ich eine akzeptierte Offerte transaktional in einen Auftrag (und Rechnung) überführen, damit die operative Abwicklung mit eingefrorenen Beträgen startet.
Akzeptanzkriterien
- Gegeben eine Offerte im Status
SENT, wenn ich „Akzeptieren" klicke, dann läuft ausschliesslich eine Server-Action (service_role) transaktional:status → ACCEPTED,orders.INSERT(quote_id,customer_id,status = OPEN,total_chf = quotes.grand_total_chf),invoices.INSERT, dannstatus → CONVERTED+quotes.order_idsetzen. - Gegeben der Total-Snapshot, dann spiegelt
orders.total_chfdengrand_total_chfder akzeptierten Offerte read-only; spätere Preislistenänderungen verändern den Auftragswert nicht. - Gegeben keine Kopie der Positionen (F-01), dann bleiben die
quote_linesder konvertierten Offerte die Quelle; Modul 5 materialisiert Boxen direkt daraus. - Gegeben für die Offerte existiert bereits ein Auftrag, wenn ich erneut konvertiere, dann greift
orders_quote_uniq(1 Offerte ⇒ max. 1 Auftrag) und der Versuch wird als Konflikt abgefangen (idempotent/kein Duplikat). - Gegeben die Transaktion schlägt teilweise fehl, dann erfolgt vollständiger Rollback; der Status bleibt
ACCEPTED(nichtCONVERTED) und der fehlgeschlagene Schritt wird gemeldet.
Traceability: Konversion §4.4, orders §2.6 (orders_quote_uniq), RLS §13 Fussnote 4 (Server-Action/service_role), Auto-Posting (Rechnung bucht INVOICE, keine Doppelzählung).
US-OFF-08 — Preis-Override nur mit Recht (BR-25)
Als ADMIN möchte ich den vom Resolver gelieferten Preis bewusst übersteuern können, während andere Rollen das nicht dürfen, damit Preisautorität serverseitig durchgesetzt und jede Abweichung revisionssicher belegt ist.
Akzeptanzkriterien
- Gegeben ich besitze das Recht
PRICE_OVERRIDE(DefaultADMIN, optionalOPERATIONS— 🔲 Entscheid), wenn ichunit_price_chfabweichend vom Resolver-Preis setze, dann akzeptiert die Server-Action den Wert und schreibtaudit_logmitcontext = {reason, resolver_price, override_price}(Begründungspflicht). - Gegeben ich besitze das Recht nicht, wenn ich einen vom Resolver abweichenden Preis sende, dann lehnt die Server-Action mit
403 PRICE_OVERRIDE_DENIEDab — Positionen anlegen bleibt erlaubt, nur das Übersteuern nicht. - Gegeben ein beliebiger Speichervorgang, dann vergleicht das Backend den gesetzten Preis immer gegen den Resolver (Backend nimmt keinen Preis ungeprüft, Abkehr von IST BR-50).
Traceability: Preis-Override-Recht BR-25 (Modul 13 IST-Paritäts-Nachtrag), quote_lines.unit_price_chf, audit_log. (Setzt Audit-Härtung gegen frontend-only BR-46 testbar um.)
US-OFF-09 — Affiliate sieht nur eigene Offerten (RLS-Negativfall)
Als AFFILIATE möchte ich ausschliesslich meine eigenen Offerten sehen, damit Mandantentrennung gewahrt ist und ich keine fremden Kunden oder Beträge einsehen kann.
Akzeptanzkriterien
- Gegeben ich bin als
AFFILIATEeingeloggt, wenn ich die Offertenliste öffne, dann sehe ich nur Offerten mit eigenemreferral_code_id/affiliate_id(=current_affiliate_id()); alle anderen sind unsichtbar (R(own)). - Gegeben eine fremde Offerte, wenn ich sie per ID/Deep-Link direkt aufrufe, dann liefert die RLS-Policy „nicht gefunden" — kein Einblick in Positionen,
grand_total_chfoder Kundendaten fremder Offerten. - Gegeben ich bin
AFFILIATE, wenn ich eine Offerte erstellen/ändern will, dann wird jeder Schreibversuch abgelehnt (keinC/U/D; nur Lesen eigener). - Gegeben das PDF in Storage, wenn ich es abrufe, dann greift
storage_objects/AFFnur auf Objekte, deren Owner-Offerte mir gehört (Policy-Joinquotes.affiliate_id = current_affiliate_id()).
Traceability: RLS §13 (quotes/quote_lines R(own)⁵, Fussnoten 5 & 11), current_affiliate_id(), Datenschutz §8.2. (Negativszenario: kein Einblick in fremde Offerten/Beträge.)
US-OFF-10 — Revisionssicherheit akzeptierter/konvertierter Offerten (Audit-Negativfall)
Als ADMIN möchte ich, dass akzeptierte und konvertierte Offerten weder verändert noch gelöscht werden können, damit der Vertragsinhalt nach OR 957 / GeBüV unveränderbar dokumentiert bleibt.
Akzeptanzkriterien
- Gegeben eine Offerte im Status
ACCEPTEDoderCONVERTED, wenn irgendeine Rolle einUPDATEversucht, dann wird es abgelehnt — die RLS erlaubtUnur beistatus IN ('DRAFT','SENT'), die Server-Action prüft zusätzlich. - Gegeben eine Offerte im Status
ACCEPTEDoderCONVERTED, wenn irgendeine Rolle einDELETEversucht, dann wird es abgelehnt (keinDfür diese Status, Revisionssicherheit). - Gegeben der Betrags-/Positions-Snapshot, dann bleiben
subtotal_chf/grand_total_chfund diequote_lineszum Zeitpunkt der Akzeptanz eingefroren, auch wenn Preislisten sich später ändern. - Gegeben das PDF, dann ist jede Version in Storage unveränderlich (neue Version = neuer Pfad); alte Versionen werden nicht überschrieben.
Traceability: Revisionssicherheit §8.1, RLS §13 Fussnote 4 (kein D für ACCEPTED/CONVERTED), audit_log (append-only).
US-OFF-11 — Kunde empfängt das Offert-PDF (kein Login)
Als Kunde möchte ich das Angebot als PDF in meiner Sprache per WhatsApp oder E-Mail erhalten, damit ich es ohne Konto prüfen und entscheiden kann.
Akzeptanzkriterien
- Gegeben der E-Mail-Versand, wenn die Offerte verschickt wird, dann erhalte ich eine Resend-Mail mit PDF-Anhang an
contacts.email, Betreff/Text in meiner Sprache (contacts.preferred_lang). - Gegeben der WhatsApp-Versand, wenn der Benutzer ihn auslöst, dann öffnet sich WhatsApp mit vorausgefülltem Text und einem signierten Storage-Link (max. 1h gültig); Caja sendet keine Daten direkt an die WhatsApp-API.
- Gegeben ich öffne den PDF-Link, dann sehe ich nur das Angebot (keine internen Notizen
internal_notes, keine fremden Daten); öffentliche Buckets für Offerten-PDFs existieren nicht.
Traceability: Versand §4.2, signierte URLs / Datenschutz §8.2, contacts.preferred_lang, quotes.customer_notes (auf PDF) vs. internal_notes (nicht auf PDF).
US-OFF-12 — Offerte kopieren / neu auflegen
Als OPERATIONS möchte ich aus einer bestehenden Offerte (beliebiger Status) eine Kopie als neuen Entwurf erzeugen, damit ich abgelaufene oder abgelehnte Angebote schnell erneuern kann.
Akzeptanzkriterien
- Gegeben eine beliebige Offerte, wenn ich „Kopieren" auslöse, dann entsteht eine neue Offerte mit neuer
id/quote_number,status = DRAFT,issue_date = heute,valid_until = heute + 14. - Gegeben die Kopie, dann werden Positionen, Rabatte und Notizen übernommen, nicht aber
sent_at,pdf_storage_path,order_id. - Gegeben die übernommenen Positionen, dann werden Preise neu aus der aktuellen Preisliste gezogen (nicht der alte Snapshot), mit Hinweis im UI.
Traceability: Kopieren §4.6, quotes (§2.5), Status-Transition „Jeder → DRAFT" §4.3.
US-OFF-13 — MWST-Ausweis (schema-ready)
Als BUCHHALTUNG möchte ich bei Bedarf einen MWST-Ausweis auf der Offerte aktivieren, damit Caja bei eintretender MWST-Pflicht ohne Code-Änderung konform wird.
Akzeptanzkriterien
- Gegeben MWST ist deaktiviert (Default,
app_settings), wenn eine Offerte erstellt wird, dann bleibenvat_code/vat_rate_percent/vat_amount_chfNULLund das PDF weist keine MWST aus. - Gegeben MWST ist via Settings aktiviert, wenn ich eine Offerte erstelle, dann wird
vat_amount_chfberechnet und auf dem PDF ausgewiesen. - 🔲 Entscheid-abhängig: Berechnungsbasis brutto vs. netto (
grand_total_chf × rate/100auf Brutto- oder Netto-Basis) sowie der Zeitpunkt der MWST-Pflicht für Dominicano Express GmbH (O-07) sind vor Aktivierung festzulegen.
Traceability: MWST §8.3, quotes.vat_code/vat_rate_percent/vat_amount_chf (§2.5), vat_rates, offene Punkte O-07 (+ brutto/netto).
Schätzung & Priorität (Vorschlag — vom Team final zu bestätigen)
| Story | Größe | Priorität (MoSCoW) |
|---|---|---|
| US-OFF-01 Offerte als Entwurf anlegen | S | Must |
| US-OFF-02 Position aus Katalog + Preis-Resolver | M | Must |
| US-OFF-03 Zeilen- & Gesamtrabatt | M | Must |
| US-OFF-04 Affiliate-/Rabattcode | M | Should |
| US-OFF-05 PDF generieren & versenden | L | Must |
| US-OFF-06 Status-Lifecycle (+ EXPIRED) | M | Must |
| US-OFF-07 Akzeptieren & Konversion → Auftrag | L | Must |
| US-OFF-08 Preis-Override-Recht (BR-25) | M | Must |
| US-OFF-09 Affiliate-RLS (nur eigene) | M | Must |
| US-OFF-10 Revisionssicherheit (kein D/U) | S | Must |
| US-OFF-11 Kunde empfängt PDF (kein Login) | M | Should |
| US-OFF-12 Offerte kopieren | S | Should |
| US-OFF-13 MWST-Ausweis (schema-ready) | S | Could |
Größe = grobe Aufwands-Indikation (S/M/L), nicht Story Points. Priorität nach MoSCoW (Must/Should/Could). Beides ist ein Vorschlag zur Release-Planung — die verbindliche Schätzung/Priorisierung macht das Team im Backlog.
Abdeckung & offene Punkte (dieses Epic)
- 13 Stories decken den Offerten-Lebenszyklus end-to-end ab: anlegen → Positionen aus Katalog (Preis-Resolver, Snapshot) → Rabatte (Zeile/Gesamt) + Affiliate-Code → PDF generieren/versenden (Mail/WhatsApp, Sprache aus
contacts.preferred_lang) → Status-Lifecycle (DRAFT→SENT→ACCEPTED→CONVERTED+EXPIRED) → Konversion in Auftrag (Server-Action/service_role, Total-Snapshot) → kopieren → MWST-Readiness. - Audit-Verzahnung: US-OFF-08 (Preis-Override serverseitig autoritativ, BR-25, gegen IST BR-46/BR-50), US-OFF-09 (AFFILIATE-Mandantentrennung
R(own), Negativszenario: kein Einblick in fremde Offerten/Beträge, §13 Fn 5/11), US-OFF-10 (Revisionssicherheit: keinD/UfürACCEPTED/CONVERTED, §13 Fn 4, OR 957/GeBüV) — die Akzeptanzkriterien encodieren die Härtungen als testbare Szenarien. - Entscheid-abhängig markiert: US-OFF-04 (Affiliate-Code-Rabatt-Verrechnung, O-08), US-OFF-06/US-OFF-12 (Expired-Reaktivierung vs. Pflicht-Kopie, O-10 — in US-OFF-06 als ADMIN-bestätigter
EXPIRED→ACCEPTEDabgebildet), US-OFF-13 (MWST brutto/netto + Pflichtzeitpunkt O-07), US-OFF-05 (Sprach-Override O-06, PDF-Lib O-02), US-OFF-08 (PRICE_OVERRIDEauch fürOPERATIONS? 🔲). - Bewusst nicht hier: Provisions-Erzeugung (
commission_entries) entsteht erst beiinvoices.status → 'PAID'(Modul 7/16, nicht bei Konversion); Box-Materialisierung/Tracking liegt in Modul 5 (Epic 5, 50-us-logistik.md); strukturierter DR-Empfänger entsteht erst bei der Sendung (F-24, Modul 14). - Nächster Schritt: Format-/Inhalts-Freigabe → INVEST-Red-Team-Pass → offene Entscheide O-02/O-06/O-07/O-08/O-10 +
PRICE_OVERRIDE-Scope klären.