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

RollePersonBezug zu den Offerten
OPERATIONSMarkusOfferten erstellen, Positionen aus Katalog erfassen, versenden, akzeptieren/konvertieren
ADMINMarcelalles + Preis-Override (BR-25) + Expired-Reaktivierung + Storno
BUCHHALTUNGMarielanur lesen (R) — Beträge/Konversion für die Finanzsicht
AFFILIATEexterner Partnernur 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 ein quotes-Datensatz mit fortlaufender quote_number (QUO-YYYY-NNNN), status = DRAFT, issue_date = heute und valid_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 (nur ADMIN/OPERATIONS dürfen C).

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ültige price_list_items-Zeile (effective_from ≤ heute AND (effective_to IS NULL OR ≥ heute)) und speichert unit_price_chf, price_list_item_id sowie product_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_chf server-seitig berechnet und in quote_lines persistiert (position eindeutig 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_id bleibt NULL (Override-Recht greift, US-OFF-08).
  • Gegeben ein Produkt mit ON DELETE RESTRICT, dann sichert product_snapshot die 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)), dann line_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 auf subtotal_chf = Σ line_total_chf angewendet → 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_chf wird 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_codes auf Gültigkeit (is_active = true, Datumsfenster) und setzt bei Erfolg referral_code_id, referral_code_text (Snapshot) sowie den denormalisierten affiliate_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_text dauerhaft 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 — beeinflusst grand_total_chf und 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) und pdf_storage_path gesetzt.
  • Gegeben der Versand erfolgreich, dann wechselt der Status auf SENT, sent_at = now() und sent_via wird 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 mit 409 Conflict abgelehnt.
  • Gegeben der tägliche Cron-Job, wenn valid_until < heute AND status = SENT, dann setzt er den Status automatisch auf EXPIRED.
  • Gegeben jeder Statuswechsel, dann schreibt das audit_log einen 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, dann status → CONVERTED + quotes.order_id setzen.
  • Gegeben der Total-Snapshot, dann spiegelt orders.total_chf den grand_total_chf der akzeptierten Offerte read-only; spätere Preislistenänderungen verändern den Auftragswert nicht.
  • Gegeben keine Kopie der Positionen (F-01), dann bleiben die quote_lines der 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 (nicht CONVERTED) 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 (Default ADMIN, optional OPERATIONS — 🔲 Entscheid), wenn ich unit_price_chf abweichend vom Resolver-Preis setze, dann akzeptiert die Server-Action den Wert und schreibt audit_log mit context = {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_DENIED ab — 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 AFFILIATE eingeloggt, wenn ich die Offertenliste öffne, dann sehe ich nur Offerten mit eigenem referral_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_chf oder Kundendaten fremder Offerten.
  • Gegeben ich bin AFFILIATE, wenn ich eine Offerte erstellen/ändern will, dann wird jeder Schreibversuch abgelehnt (kein C/U/D; nur Lesen eigener).
  • Gegeben das PDF in Storage, wenn ich es abrufe, dann greift storage_objects/AFF nur auf Objekte, deren Owner-Offerte mir gehört (Policy-Join quotes.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 ACCEPTED oder CONVERTED, wenn irgendeine Rolle ein UPDATE versucht, dann wird es abgelehnt — die RLS erlaubt U nur bei status IN ('DRAFT','SENT'), die Server-Action prüft zusätzlich.
  • Gegeben eine Offerte im Status ACCEPTED oder CONVERTED, wenn irgendeine Rolle ein DELETE versucht, dann wird es abgelehnt (kein D für diese Status, Revisionssicherheit).
  • Gegeben der Betrags-/Positions-Snapshot, dann bleiben subtotal_chf/grand_total_chf und die quote_lines zum 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 bleiben vat_code/vat_rate_percent/vat_amount_chf NULL und das PDF weist keine MWST aus.
  • Gegeben MWST ist via Settings aktiviert, wenn ich eine Offerte erstelle, dann wird vat_amount_chf berechnet und auf dem PDF ausgewiesen.
  • 🔲 Entscheid-abhängig: Berechnungsbasis brutto vs. netto (grand_total_chf × rate/100 auf 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)

StoryGrößePriorität (MoSCoW)
US-OFF-01 Offerte als Entwurf anlegenSMust
US-OFF-02 Position aus Katalog + Preis-ResolverMMust
US-OFF-03 Zeilen- & GesamtrabattMMust
US-OFF-04 Affiliate-/RabattcodeMShould
US-OFF-05 PDF generieren & versendenLMust
US-OFF-06 Status-Lifecycle (+ EXPIRED)MMust
US-OFF-07 Akzeptieren & Konversion → AuftragLMust
US-OFF-08 Preis-Override-Recht (BR-25)MMust
US-OFF-09 Affiliate-RLS (nur eigene)MMust
US-OFF-10 Revisionssicherheit (kein D/U)SMust
US-OFF-11 Kunde empfängt PDF (kein Login)MShould
US-OFF-12 Offerte kopierenSShould
US-OFF-13 MWST-Ausweis (schema-ready)SCould

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: kein D/U für ACCEPTED/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→ACCEPTED abgebildet), US-OFF-13 (MWST brutto/netto + Pflichtzeitpunkt O-07), US-OFF-05 (Sprach-Override O-06, PDF-Lib O-02), US-OFF-08 (PRICE_OVERRIDE auch für OPERATIONS? 🔲).
  • Bewusst nicht hier: Provisions-Erzeugung (commission_entries) entsteht erst bei invoices.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.