Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -0,0 +1,17 @@
-- 0007_scan_audit_unremove.sql
--
-- Erweitert den CHECK-Constraint auf scan_audit.action um den Wert
-- 'unremove'. Hintergrund: Phase C+D-4 erlaubt das Wiederherstellen
-- entfernter Items — jedes Apply landet als eigene Audit-Zeile, und
-- ohne diesen Constraint-Update bricht die Insert-Query mit 500.
--
-- Reversibel: ältere Server-Versionen, die 'unremove' nicht kennen,
-- können nach diesem Constraint-Update weiterhin die anderen Actions
-- normal schreiben — die Erweiterung ist additiv.
ALTER TABLE scan_audit
DROP CONSTRAINT IF EXISTS scan_audit_action_check;
ALTER TABLE scan_audit
ADD CONSTRAINT scan_audit_action_check
CHECK (action IN ('scan','unscan','hold','unhold','remove','unremove'));

View File

@ -0,0 +1,57 @@
-- 0008_payment_methods.sql
--
-- Zahlungs-Stammdaten + Zahlungs-Felder an Lieferungen.
--
-- Designentscheidung: eigene Tabelle statt Enum, damit Methoden über
-- einen API-Endpoint zur Laufzeit angelegt/deaktiviert werden können
-- (z. B. neue Anbieter wie PayPal oder Klarna). FK auf `deliveries`
-- mit `ON DELETE RESTRICT` — historische Lieferungen sollen ihren
-- Methoden-Bezug niemals verlieren. Soft-Delete ist als
-- `active`-Flag möglich; Hard-Delete bleibt für nie-genutzte Methoden.
CREATE TABLE payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- `code` ist der stabile Programm-Identifier (z. B. „cash"). Wird vom
-- Aufrufer vergeben, muss eindeutig sein. App und Berichts-Code
-- können darüber spezielle Methoden referenzieren, ohne die UUID
-- kennen zu müssen.
code TEXT NOT NULL UNIQUE,
-- Display-Name in der UI — frei änderbar via PATCH, z. B.
-- „EC-Karte" → „Girocard".
name TEXT NOT NULL,
-- Soft-Delete: deaktivierte Methoden bleiben im DB-Stamm und
-- bleiben damit für historische Lieferungen referenzierbar.
-- Bei neuen Lieferungen filtert die App standardmäßig auf
-- `active = true`.
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Initialer Stamm: die vier vom CEO definierten Methoden mit
-- deterministischen UUIDs, damit Demo-Seeds und Tests stabile Keys
-- haben.
INSERT INTO payment_methods (id, code, name) VALUES
('99999999-9999-9999-9999-999999999901', 'cash', 'Bar'),
('99999999-9999-9999-9999-999999999902', 'ec_card', 'EC-Karte'),
('99999999-9999-9999-9999-999999999903', 'credit_card', 'Kreditkarte'),
('99999999-9999-9999-9999-999999999904', 'invoice', 'Auf Rechnung (14 Tage netto)');
-- Lieferungen: Vorauszahlung + gewählte Zahlungsmethode für den Rest.
-- `prepaid_amount` ist `DOUBLE PRECISION` (≈ f64), kein NUMERIC —
-- für 2 Nachkommastellen reicht das locker und die sqlx-Default-
-- Features unterstützen es nativ ohne extra Crates.
ALTER TABLE deliveries
ADD COLUMN prepaid_amount DOUBLE PRECISION NOT NULL DEFAULT 0
CHECK (prepaid_amount >= 0),
-- Default beim Migrieren auf „cash" — die häufigste Methode. Die
-- Default-Klausel räumen wir gleich wieder ab, damit zukünftige
-- INSERTs den FK explizit setzen müssen.
ADD COLUMN payment_method_id UUID NOT NULL
DEFAULT '99999999-9999-9999-9999-999999999901'
REFERENCES payment_methods(id) ON DELETE RESTRICT;
ALTER TABLE deliveries ALTER COLUMN payment_method_id DROP DEFAULT;
-- Lookup-Index: Reports oder Filter „alle Lieferungen mit Rechnung"
-- profitieren davon. Ohne Index wäre das ein voller Tabelle-Scan.
CREATE INDEX deliveries_payment_method ON deliveries(payment_method_id);

View File

@ -0,0 +1,16 @@
-- 0009_warehouse_filiale_rename.sql
--
-- Wording-Angleichung: Die App spricht durchgängig von „Filiale" statt
-- „Außenlager" (Nicht-Standard-Lager sind faktisch andere Filialen, aus
-- denen sperrige Geräte separat geholt werden). Der Stammdaten-Name wird
-- entsprechend angepasst.
--
-- Bewusst als eigene Migration statt Edit an 0002: 0002 ist bereits
-- angewendet, eine nachträgliche Änderung würde die sqlx-Checksum brechen.
-- Dieses idempotente UPDATE wirkt sowohl auf bestehende DBs als auch auf
-- frische Setups (dort seedet 0002 zuerst „Außenlager A", danach benennt
-- diese Migration um).
UPDATE warehouses
SET name = 'Filiale Freilassing'
WHERE code = 'A'
AND name = 'Außenlager A';

View File

@ -0,0 +1,18 @@
-- 0010_app_state.sql
--
-- Generischer Key-Value-Store für kleine, langlebige Backend-Zustände, die
-- Prozess-Neustarts überleben müssen, aber kein eigenes Schema rechtfertigen.
--
-- Erster Nutzer: die GSD/DOCUframe-Session-Id. Der GSD-Server „blockt" einen
-- Lizenz-Seat pro Session, bis die Session abläuft oder via
-- `/v1/license/release` freigegeben wird. Ginge die Session-Id bei einem
-- Backend-Neustart verloren (reiner In-Memory-Cache), bliebe der Seat
-- verwaist geblockt — bei wiederholten Neustarts droht ein Lizenz-Lockout.
-- Deshalb persistieren wir die Id hier durabel (überlebt Restart UND
-- Redeploy, da im DB-Volume) und können sie wiederverwenden bzw. gezielt
-- freigeben.
CREATE TABLE app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@ -0,0 +1,34 @@
-- 0011_attachments.sql
--
-- Metadaten-Registry für hochgeladene Dateien (aktuell Bild-Notizen).
--
-- Die eigentlichen Bytes liegen in DOCUframe; hier halten wir die
-- Metadaten und die DOCUframe-Referenz (`docuframe_object_id` = ~ObjectID).
-- Die App referenziert ausschließlich `attachments.id` (unsere UUID) —
-- DOCUframe bleibt damit ein austauschbares Implementierungsdetail
-- (z. B. später Object-Storage statt DOCUframe).
--
-- Verknüpfung zur Notiz: `delivery_notes.image_attachment` enthält die
-- `attachments.id` (als Text). Über `delivery_id` hängt der Anhang
-- zusätzlich direkt an der Lieferung (CASCADE-Cleanup).
CREATE TABLE attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- DOCUframe ~ObjectID — für den späteren Download der Bytes.
docuframe_object_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
-- Originaldateiname beim Upload (optional).
filename TEXT,
-- SHA-256 der Bytes (Hex) — Integrität / Dedup.
checksum_sha256 TEXT NOT NULL,
-- Bildabmessungen in Pixeln (nullable: nicht jede Datei ist ein Bild
-- bzw. das Format ist evtl. nicht erkennbar).
width INT,
height INT,
-- Personalnummer des Hochladenden (aus dem JWT).
uploaded_by BIGINT NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE
);
CREATE INDEX attachments_delivery ON attachments(delivery_id);

View File

@ -0,0 +1,35 @@
-- 0012_credit_quantity.sql
--
-- Mengen-Gutschrift ("Belegzeile ganz oder teilweise entfernen").
--
-- Bis hierher war "entfernen" binär: scan_status kippte auf 'removed' und
-- die ganze Position galt als zurückgegeben. Für Teilmengen
-- ("Kunde nimmt 1 von 3 Stück nicht an") braucht es eine eigene
-- Mengen-Dimension, getrennt von scanned_quantity (= verladen):
--
-- required_quantity = ERP-Soll (unverändert)
-- scanned_quantity = verladen / Beladen (unverändert)
-- credited_quantity = Gutschrift / zurück (NEU)
-- ausgeliefert = required - credited (abgeleitet)
--
-- scan_status = 'removed' bedeutet künftig "voll gutgeschrieben"
-- (credited == required). Teil-Gutschrift = 0 < credited < required;
-- der Status bleibt dann 'done' (bzw. was er vorher war).
ALTER TABLE delivery_items
ADD COLUMN credited_quantity INT NOT NULL DEFAULT 0
CHECK (credited_quantity >= 0 AND credited_quantity <= required_quantity);
-- Audit der Gutschrift läuft über dieselbe append-only scan_audit-Tabelle
-- wie Scan/Hold. Die bestehenden delta/resulting_quantity beschreiben die
-- SCAN-Dimension; für remove/unremove kommen zwei eigene, nur dort gefüllte
-- Spalten dazu, damit die beiden Dimensionen sauber getrennt bleiben.
--
-- credit_delta = +n bei remove, -n bei unremove
-- resulting_credited_quantity = Snapshot von credited_quantity danach
--
-- Beide nullable: bei scan/unscan/hold/unhold/etc. bleiben sie NULL.
ALTER TABLE scan_audit
ADD COLUMN credit_delta INT,
ADD COLUMN resulting_credited_quantity INT
CHECK (resulting_credited_quantity IS NULL OR resulting_credited_quantity >= 0);

View File

@ -0,0 +1,15 @@
-- 0013_note_credit_link.sql
--
-- Verknüpft eine Notiz mit der Belegzeile, für die sie als Gutschrift-Grund
-- angelegt wurde. Damit lässt sich die Notiz beim Zurücknehmen der Gutschrift
-- (Unremove) gezielt wieder löschen.
--
-- Nullable: normale Notizen (Text/Foto ohne Gutschrift-Bezug) haben hier NULL.
-- ON DELETE SET NULL: verschwindet die Belegzeile mal (z. B. ERP-Resync), bleibt
-- die Notiz als reine Doku erhalten und wird nur „entkoppelt".
ALTER TABLE delivery_notes
ADD COLUMN credit_delivery_item_id UUID
REFERENCES delivery_items(id) ON DELETE SET NULL;
CREATE INDEX delivery_notes_credit_item
ON delivery_notes (credit_delivery_item_id);

View File

@ -0,0 +1,43 @@
-- 0014_delivery_credit_audit.sql
--
-- Betrags-Gutschrift pro Lieferung (Geld-Nachlass, unabhängig von Stückzahl;
-- ≤150 €, in 10-€-Schritten, Pflichtgrund).
--
-- Append-only Audit-Log — analog scan_audit: jede Änderung (set/remove) ist
-- eine eigene Zeile, nichts wird geupdated/gelöscht. Der "aktuelle" Stand
-- einer Lieferung ist das jüngste Ereignis (DISTINCT ON delivery_id …
-- ORDER BY recorded_at DESC): action='set' → Betrag+Grund, action='remove'
-- → keine Gutschrift.
--
-- Idempotenz: client_event_id ist UNIQUE. Ein Netz-Retry desselben Events
-- kollidiert auf dem Index (INSERT … ON CONFLICT DO NOTHING) — der Server
-- liefert den aktuellen Stand zurück, ohne eine Dublette anzuhängen.
CREATE TABLE delivery_credit_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Idempotenz-Schlüssel vom Client.
client_event_id UUID NOT NULL UNIQUE,
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
action TEXT NOT NULL CHECK (action IN ('set', 'remove')),
-- Resultierender Betrag NACH diesem Ereignis (in Cent). 0 bei 'remove'.
-- Harte Obergrenze 150 € als CHECK; die 10-€-Schritt-Regel prüft der
-- Use Case (fachlich, leichter änderbar als ein DB-CHECK).
amount_cents BIGINT NOT NULL DEFAULT 0
CHECK (amount_cents >= 0 AND amount_cents <= 15000),
-- Pflicht bei 'set' (im Use Case erzwungen), bei 'remove' optional.
reason TEXT,
-- Akteur: Personalnummer aus dem JWT (Pflicht), Fahrzeug optional.
author_personalnummer BIGINT NOT NULL,
author_car_id UUID,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX delivery_credit_audit_delivery
ON delivery_credit_audit (delivery_id, recorded_at DESC);

View File

@ -0,0 +1,11 @@
-- 0015_note_amount_credit_flag.sql
--
-- Markiert eine Notiz als Grund-Notiz einer Betrags-Gutschrift (Geld-Nachlass
-- auf Lieferungs-Ebene). Anders als die Mengen-Gutschrift hängt der Nachlass
-- nicht an einer Belegzeile, daher kein credit_delivery_item_id, sondern ein
-- einfaches Flag.
--
-- Damit kann der Client beim Entfernen der Gutschrift die zugehörige(n)
-- Grund-Notiz(en) der Lieferung gezielt wieder löschen.
ALTER TABLE delivery_notes
ADD COLUMN is_amount_credit_note BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,65 @@
-- 0016_services.sql
--
-- Services (früher „Lieferoptionen") — admin-konfigurierbare Stammdaten plus
-- Pro-Lieferung-Werte.
--
-- Im alten ERPframe-Stand kamen die Optionen datengetrieben pro Lieferung
-- (key/display/numerical/value). Neu: der Administrator pflegt die Definitionen
-- frei (anlegen/ändern/löschen, Muster wie payment_methods), der Fahrer wählt
-- sie in Phase 4 pro Lieferung aus (Checkbox bzw. Zahl mit min/max).
CREATE TABLE services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Stabiler Programm-Identifier (z. B. "podium_setup"). Eindeutig.
key TEXT NOT NULL UNIQUE,
-- Anzeige-Name in der UI (z. B. "Podest aufgestellt"). Frei änderbar.
name TEXT NOT NULL,
-- 'boolean' → Checkbox, 'numeric' → Zahlenfeld mit min/max.
kind TEXT NOT NULL CHECK (kind IN ('boolean', 'numeric')),
-- Nur für numeric relevant; bei boolean NULL.
min_value INT,
max_value INT,
-- Soft-Delete: deaktivierte Services bleiben für historische Lieferungen
-- referenzierbar, tauchen aber im Default-Listing nicht auf.
active BOOLEAN NOT NULL DEFAULT TRUE,
-- Anzeige-Reihenfolge in Phase 4.
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- boolean trägt keine Grenzen; numeric darf welche haben.
CONSTRAINT services_bounds_kind CHECK (
(kind = 'boolean' AND min_value IS NULL AND max_value IS NULL)
OR kind = 'numeric'
),
-- Falls beide gesetzt: min ≤ max.
CONSTRAINT services_min_le_max CHECK (
min_value IS NULL OR max_value IS NULL OR min_value <= max_value
)
);
-- Initialer Stamm (deterministische UUIDs für stabile Seeds/Tests). Der
-- Administrator kann sie jederzeit ändern/deaktivieren/ergänzen.
INSERT INTO services (id, key, name, kind, min_value, max_value, sort_order) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa01', 'podium_setup', 'Podest aufgestellt', 'boolean', NULL, NULL, 1),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa02', 'old_appliance_taken', 'Altgerät mitgenommen', 'boolean', NULL, NULL, 2),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa03', 'commissioning', 'Inbetriebnahme', 'boolean', NULL, NULL, 3),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa04', 'floor', 'Stockwerk', 'numeric', 0, 20, 4);
-- Pro-Lieferung gewählter Wert eines Service. Genau eine Zeile je
-- (Lieferung, Service) — Upsert. `bool_value` für boolean, `numeric_value`
-- für numeric (Use Case stellt sicher, dass nur das passende Feld gesetzt ist).
CREATE TABLE delivery_services (
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
-- RESTRICT: ein referenzierter Service darf nicht hart gelöscht werden
-- (sonst verlöre die Lieferung ihren Wert) — stattdessen deaktivieren.
service_id UUID NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
bool_value BOOLEAN,
numeric_value INT,
author_personalnummer BIGINT,
author_car_id UUID,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (delivery_id, service_id)
);
CREATE INDEX delivery_services_delivery ON delivery_services (delivery_id);

View File

@ -0,0 +1,32 @@
-- 0017_seed_real_services.sql
--
-- Ersetzt den Platzhalter-Seed aus 0016 durch die **echten** Lieferoptionen
-- aus dem Altsystem. Quelle: DOCUframe-Makro
-- `_SV/_DF_APP/_initDelivery.dfm` (gegengeprüft in `_getOptionColumnByKey.dfm`)
-- — 8 Optionen, 2 numerisch (Stückzahlen), 6 boolean. Im Makro gibt es keine
-- min/max-Werte; für die Stückzahlen setzen wir fachlich `min = 0`, kein max.
--
-- (Migration 0016 wurde bereits angewandt und bleibt unangetastet — daher die
-- Korrektur in einer eigenen Migration statt eines Edits.)
-- Platzhalter-Seeds entfernen. Erst etwaige Pro-Lieferung-Werte lösen
-- (FK ON DELETE RESTRICT), dann die Definitionen.
DELETE FROM delivery_services
WHERE service_id IN (
SELECT id FROM services
WHERE key IN ('podium_setup', 'old_appliance_taken', 'commissioning', 'floor')
);
DELETE FROM services
WHERE key IN ('podium_setup', 'old_appliance_taken', 'commissioning', 'floor');
-- Echte Optionen (deterministische UUIDs für stabile Seeds/Tests).
-- sort_order = Reihenfolge wie in der alten App (numerische zuerst).
INSERT INTO services (id, key, name, kind, min_value, max_value, sort_order) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa01', 'AMOUNT_OLD_DEVICES', 'Anzahl alter Geräte', 'numeric', 0, NULL, 1),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa02', 'AMOUNT_MOUNTED_DEVICES', 'Anzahl montierter Geräte', 'numeric', 0, NULL, 2),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa03', 'DEVICE_ALIGNED', 'Gerät ausgerichtet', 'boolean', NULL, NULL, 3),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa04', 'ON_PLATFORM', 'Auf Podest gestellt', 'boolean', NULL, NULL, 4),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa05', 'REVISIT_REQUIRED', 'Erneute Anfahrt notwendig', 'boolean', NULL, NULL, 5),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa06', 'TEST_RUN_COMPLETE', 'Testdurchlauf durchgeführt', 'boolean', NULL, NULL, 6),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa07', 'TIGHTNESS_TEST_DONE', 'Dichtigkeitstest durchgeführt','boolean', NULL, NULL, 7),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa08', 'TUBE_EXTENSION_REQUIRED', 'Schlaucherweiterung notwendig','boolean', NULL, NULL, 8);

View File

@ -0,0 +1,11 @@
-- 0018_delivery_total_amount.sql
--
-- Gesamt-/Rechnungsbetrag (brutto, EUR) einer Lieferung. Zusammen mit
-- `prepaid_amount` (bei Bestellung bezahlt) und der Betrags-Gutschrift rechnet
-- die App daraus den offenen Betrag:
-- offen = max(0, total_amount - prepaid_amount - gutschrift)
--
-- Wird vom ERP-Sync geliefert. Default 0, bis das Sync-Makro den Wert mitsendet.
ALTER TABLE deliveries
ADD COLUMN total_amount DOUBLE PRECISION NOT NULL DEFAULT 0
CHECK (total_amount >= 0);

View File

@ -0,0 +1,16 @@
-- 0019_item_unit_price.sql
--
-- Preis pro Belegzeile/Artikel + Entfernen des statischen Liefer-Totals.
--
-- Hintergrund: Der Gesamt-/Warenwert einer Lieferung wird jetzt **aus den
-- Artikeln berechnet** (Σ Stückpreis × ausgelieferte Menge), nicht mehr als
-- statischer Betrag geführt. Ausgelieferte Menge = required - credited, d. h.
-- entfernte/teil-entfernte Positionen reduzieren den Wert automatisch. Damit
-- ist `deliveries.total_amount` (Migration 0018) überflüssig und wird wieder
-- entfernt — eine Quelle der Wahrheit (der Stückpreis).
ALTER TABLE deliveries DROP COLUMN total_amount;
ALTER TABLE delivery_items
ADD COLUMN unit_price DOUBLE PRECISION NOT NULL DEFAULT 0
CHECK (unit_price >= 0);

View File

@ -0,0 +1,40 @@
-- 0020_delivery_completions.sql
--
-- Abschluss-Beleg einer Lieferung: dokumentiert den Moment, in dem Kunde
-- und Fahrer unterschreiben und die Lieferung auf `completed` geht.
--
-- Genau EINE Zeile pro Lieferung (PK = delivery_id). Der Abschluss ist
-- terminal — ein zweiter Versuch wird vom Use Case über das
-- `state == active`-Gate abgelehnt; existiert die Zeile schon und steht
-- die Lieferung auf `completed`, liefert der Server idempotent denselben
-- Stand zurück.
--
-- Die beiden Unterschriften liegen NICHT in der DB, sondern als PNG-Dateien
-- lokal im Backend-Server (Pfad konfigurierbar). Hier stehen nur die
-- relativen Datei-Referenzen.
--
-- Die Checkbox-Bestätigungen des Kunden werden hier dauerhaft dokumentiert:
-- * receipt_confirmed — „Ware ordnungsgemäß erhalten / Aufbau korrekt"
-- * notes_acknowledged — „Anmerkungen zur Lieferung zur Kenntnis genommen"
-- `acknowledged_note_ids` hält die konkreten Notiz-IDs fest, die zum
-- Zeitpunkt des Abschlusses sichtbar waren und mit-bestätigt wurden
-- (Audit-Robustheit, falls später Notizen dazukommen/wegfallen).
CREATE TABLE delivery_completions (
delivery_id UUID PRIMARY KEY REFERENCES deliveries(id) ON DELETE CASCADE,
-- Relative Pfade/Referenzen der lokal gespeicherten Signatur-PNGs.
customer_signature_path TEXT NOT NULL,
driver_signature_path TEXT NOT NULL,
-- Dokumentierte Checkbox-Bestätigungen des Kunden.
receipt_confirmed BOOLEAN NOT NULL,
notes_acknowledged BOOLEAN NOT NULL,
acknowledged_note_ids UUID[] NOT NULL DEFAULT '{}',
-- Akteur: Personalnummer aus dem JWT (Pflicht), Fahrzeug optional.
completed_by_personalnummer BIGINT NOT NULL,
completed_by_car_id UUID,
completed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@ -0,0 +1,18 @@
-- 0021_remove_credit_card.sql
--
-- Entfernt die Zahlungsmethode `credit_card` (Kreditkarte) aus den
-- Stammdaten. Sie wurde in Migration 0008 geseedet, kommt aber im
-- ERP-Liefer-Flow nicht vor (Allowlist der ERP-Zahlungsbedingungen:
-- D16/d53/D10 = cash/ec_card/invoice).
--
-- 0008 selbst bleibt unangetastet (angewandte Migration → Checksum). Daher
-- diese Folge-Migration: erst etwaige Referenzen umhängen (FK
-- ON DELETE RESTRICT auf deliveries.payment_method_id), dann löschen.
-- Bestehende Lieferungen, die noch auf Kreditkarte zeigen, auf `cash`
-- (ERP-Default) umstellen — verhindert die FK-Verletzung beim DELETE.
UPDATE deliveries
SET payment_method_id = (SELECT id FROM payment_methods WHERE code = 'cash')
WHERE payment_method_id = (SELECT id FROM payment_methods WHERE code = 'credit_card');
DELETE FROM payment_methods WHERE code = 'credit_card';

View File

@ -0,0 +1,12 @@
-- 0022_scan_audit_manual.sql
--
-- Fallback-Scan: markiert einen Scan-Eintrag als **manuell bestätigt**
-- ("Ich habe den Artikel geladen"), wenn der Barcode-/QR-Scan nicht klappt.
--
-- Reine Audit-Information: WIE der Scan zustande kam. Die Mengen-/Status-
-- Wahrheit (scanned_quantity, scan_status) bleibt unverändert — ein manueller
-- Scan ist fachlich ein vollwertiger Scan, nur ohne Barcode-Lesung.
-- Default false: alle bestehenden Einträge gelten als regulär gescannt.
ALTER TABLE scan_audit
ADD COLUMN manual BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,15 @@
-- 0023_item_parent_artikel_nr.sql
--
-- Stücklisten-Hierarchie sichtbar machen: Artikelnummer des Oberartikels,
-- zu dem eine Komponente gehört.
--
-- Bisher teilten sich Oberartikel und seine Komponenten nur die
-- `belegzeilen_nr`; die `komponenten_artikel_nr` trägt die EIGENE Nummer der
-- Komponente (muss je belegzeilen_nr eindeutig sein) und taugt daher nicht
-- als Parent-Referenz. `parent_artikel_nr` schließt diese Lücke:
-- * Oberartikel / reguläre Belegzeile → NULL
-- * Komponente → Artikelnummer des Oberartikels
--
-- Damit kann die App Komponenten unter ihrem Oberartikel einrücken.
ALTER TABLE delivery_items
ADD COLUMN parent_artikel_nr TEXT;

View File

@ -0,0 +1,20 @@
-- 0024_completion_payment_collected.sql
--
-- Inkasso-Bestätigung beim Abschluss: Wenn beim Abschluss noch ein offener
-- Betrag besteht UND die Zahlungsmethode ein Vor-Ort-Inkasso ist (Bar oder
-- EC-Karte), muss der Fahrer vor den Unterschriften bestätigen, dass das Geld
-- erhalten/abgerechnet wurde. „Auf Rechnung" (invoice) bleibt bewusst offen →
-- kein Inkasso, keine Bestätigung.
--
-- * payment_collected — Fahrer hat das Inkasso bestätigt. Bei
-- Lieferungen ohne Inkasso (offen == 0 oder
-- Methode = invoice) bleibt es `false`.
-- * collected_amount_cents — Snapshot des tatsächlich kassierten offenen
-- Betrags in Cent (server-seitig autoritativ
-- berechnet). NULL = kein Inkasso erforderlich.
-- Eingefroren, damit der Report-/Audit-Wert
-- unabhängig von späteren Daten-Resyncs bleibt.
ALTER TABLE delivery_completions
ADD COLUMN payment_collected BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN collected_amount_cents BIGINT;

View File

@ -0,0 +1,18 @@
-- 0025_delivery_belegart_code.sql
--
-- Belegart lesbar machen: bisher steht nur die `erp_belegart_id` (die ERP-
-- `row_id` der Belegart, z. B. 24) in `deliveries` — eine nackte Nummer ohne
-- Aussagekraft. Der Sync zieht jetzt zusätzlich den Belegart-Code und die
-- Bezeichnung aus ERPframe (`Belegarten.Belegart` / `.Bezeichnung`,
-- z. B. „VL5" / „Lieferschein EH") mit, damit der Report sie statt der
-- nackten ID anzeigen kann.
--
-- * erp_belegart_code — Kurzcode der Belegart (z. B. „VL5").
-- * erp_belegart_name — Klartext-Bezeichnung (z. B. „Lieferschein EH").
--
-- Beide nullable: Altbestand vor diesem Sync hat sie nicht; der nächste
-- (Re-)Sync füllt sie idempotent nach.
ALTER TABLE deliveries
ADD COLUMN erp_belegart_code TEXT,
ADD COLUMN erp_belegart_name TEXT;

View File

@ -0,0 +1,40 @@
-- 0026_delivery_report_jobs.sql
--
-- Zustandsanker für die Übertragung des PDF-Lieferreports an DOCUframe.
-- Beim Abschluss wird der Report erzeugt und in DOCUframe abgelegt; das ist
-- ein mehrstufiger, netzabhängiger Vorgang (Upload → ~ObjectID → Makro
-- `_SV_assignDeliveryReport`). Damit nichts verloren geht und fehlgeschlagene
-- Übertragungen per Cron wiederholt werden können, hält diese Tabelle den
-- Fortschritt **hart** in Postgres.
--
-- Genau eine Zeile pro Lieferung (PK = delivery_id). `status` ist die
-- Resume-Marke; ein Retry führt nur die noch offenen Schritte aus:
-- * 'pending' — angelegt, noch nichts übertragen
-- * 'uploaded' — PDF liegt in DOCUframe, `docuframe_object_id` gesetzt,
-- Makro-Zuordnung steht noch aus
-- * 'done' — Makro erfolgreich; lokale Dateien wurden aufgeräumt
--
-- Fehlersemantik: schlägt ein Schritt fehl, bleibt `status` auf der erreichten
-- Stufe, `last_error`/`attempts`/`last_attempt_at` werden gesetzt. Der Cron
-- greift alle Zeilen mit status <> 'done' erneut auf.
CREATE TABLE delivery_report_jobs (
delivery_id UUID PRIMARY KEY REFERENCES deliveries(id) ON DELETE CASCADE,
belegnummer TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'uploaded', 'done')),
-- DOCUframe ~ObjectID des hochgeladenen Reports — hart gespeichert nach
-- dem Upload, damit ein Retry nicht doppelt hochlädt.
docuframe_object_id TEXT,
-- Zeitpunkt der erfolgreichen Makro-Zuordnung (= „Report hochgeladen").
report_uploaded_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
last_attempt_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Schneller Zugriff des Retry-Cron auf offene Jobs.
CREATE INDEX delivery_report_jobs_open ON delivery_report_jobs (status)
WHERE status <> 'done';

View File

@ -0,0 +1,14 @@
-- 0027_attachment_deleted.sql
--
-- „Soft-Delete" für Bild-Notizen: Sobald der PDF-Lieferreport (in dem die
-- Bilder eingebettet sind) erfolgreich in DOCUframe liegt, wird die lokale
-- Bilddatei gelöscht, um keinen Datenmüll zu halten. Die Metadaten-Zeile
-- bleibt jedoch erhalten — so ist weiterhin ersichtlich, dass es ein Bild
-- gab. Die App zeigt für solche Anhänge keinen Vorschau-Download mehr,
-- sondern den Hinweis, dass das Bild im Lieferbericht enthalten ist.
--
-- `deleted_at` doppelt als Flag (NULL = lokale Datei vorhanden) UND als
-- Audit-Zeitstempel (wann aufgeräumt).
ALTER TABLE attachments
ADD COLUMN deleted_at TIMESTAMPTZ;

View File

@ -0,0 +1,20 @@
-- 0028_completion_mail_sent.sql
--
-- Markierung, ob für eine ausgelieferte (abgeschlossene) Lieferung die
-- Liefer-/Belegmail bereits versendet wurde. Der externe Mailclient pollt die
-- noch NICHT versendeten Belege (GET /admin/delivered-belegnummern liefert nur
-- `mail_sent_at IS NULL`), stößt ERPframe an (das die Mails verschickt) und
-- markiert sie anschließend über POST /admin/mark-mail-sent als versendet.
--
-- NULL = noch nicht versendet (offen).
-- Wert = Zeitstempel, wann markiert wurde (Audit + server-seitiges Dedup,
-- damit derselbe Beleg nicht alle 5 Minuten erneut eine Mail auslöst).
ALTER TABLE delivery_completions
ADD COLUMN mail_sent_at TIMESTAMPTZ;
-- Partieller Index: der Mailclient fragt praktisch immer nur die offenen
-- (mail_sent_at IS NULL) Belege ab.
CREATE INDEX delivery_completions_mail_unsent
ON delivery_completions (completed_at)
WHERE mail_sent_at IS NULL;

View File

@ -0,0 +1,62 @@
-- 0029_delivery_contact_sources.sql
--
-- Belegspezifische Kontaktdaten-Snapshots. ERPframe modelliert pro Beleg bis
-- zu fünf Adressen (Belegadresse `Belegkopf.AdressId`, Lieferadresse
-- `LieferAdressId`, Rechnungsadresse `RechnungsAdressId`, Ansprechpartner
-- `AnsprechpartnerId`, plus die Kundenstamm-Adresse über `Kunden.AdressId`).
-- Jeder dieser Adress-Datensätze trägt seinerseits mehrere Telefon-,
-- Mobil- und E-Mail-Spalten (`Telefon..Telefon4`, `Mobiltel..Mobiltel2`,
-- `EMail..EMail3`, `InternetAdresse`) sowie einen Namensblock
-- (`Anrede`/`Titel`/`Name1..3`/`Abteilung`/`Funktion`).
--
-- Wir spiegeln das als zwei Tabellen: pro Lieferung 0..n
-- `delivery_contact_sources` (eine Zeile je Rolle), und pro Source 0..n
-- `delivery_contact_channels` (eine Zeile je Telefon-/Mobil-/E-Mail-/Web-
-- Eintrag). Snapshot-Semantik wie bei `deliveries.snap_*`: beim Sync werden
-- alle Sources einer Lieferung gelöscht und neu geschrieben — die Lieferung
-- behält damit den Stand vom letzten Sync, unabhängig von späteren ERP-
-- Adressänderungen.
--
-- `Fax` wird bewusst nicht gespiegelt (in der App nicht benutzt).
CREATE TABLE delivery_contact_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
-- Mapping auf die ERP-FKs am Belegkopf bzw. den Kunden-Umweg:
-- header → Belegkopf.AdressId
-- delivery → Belegkopf.LieferAdressId
-- billing → Belegkopf.RechnungsAdressId
-- contact_person → Belegkopf.AnsprechpartnerId
-- customer_master → Kunden.AdressId (über Belegkopf.KundenId)
role TEXT NOT NULL
CHECK (role IN ('header','delivery','billing',
'contact_person','customer_master')),
anrede TEXT,
titel TEXT,
name1 TEXT,
name2 TEXT,
name3 TEXT,
abteilung TEXT,
funktion TEXT,
UNIQUE (delivery_id, role)
);
CREATE INDEX delivery_contact_sources_delivery
ON delivery_contact_sources(delivery_id);
CREATE TABLE delivery_contact_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES delivery_contact_sources(id) ON DELETE CASCADE,
-- phone → Adressen.Telefon{,2,3,4}
-- mobile → Adressen.Mobiltel{,2}
-- email → Adressen.EMail{,2,3}
-- web → Adressen.InternetAdresse
kind TEXT NOT NULL
CHECK (kind IN ('phone','mobile','email','web')),
-- 1-basierte Position aus dem ERP-Spaltennamen (Telefon → 1, Telefon2 → 2,
-- usw.). Erlaubt der App, „primären" Kanal je Kind stabil zu erkennen.
position SMALLINT NOT NULL CHECK (position >= 1),
value TEXT NOT NULL CHECK (value <> ''),
UNIQUE (source_id, kind, position)
);
CREATE INDEX delivery_contact_channels_source
ON delivery_contact_channels(source_id);