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

79
tool/dev_usb.sh Executable file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Fährt das Backend im USB-Tunnel-Modus hoch — für App-Tests in fremden
# Netzwerken, in denen das Android-Gerät den Mac nicht über eine LAN-IP
# erreicht. Statt LAN-IP läuft alles über `adb reverse` auf localhost.
#
# Was dieses Skript macht:
# 1. Keycloak mit KC_HOSTNAME=localhost (neu)starten, damit das 'iss'-Claim
# auf http://localhost:8080/... lautet.
# 2. adb-reverse-Tunnel für API (3000) und Keycloak (8080) setzen.
# 3. Backend mit einer von config.toml abgeleiteten Temp-Config starten,
# in der nur `issuer_url` auf localhost überschrieben ist. Der Pfad
# wird per HOLZLEITNER_CONFIG injiziert; config.toml bleibt unangetastet.
#
# App-Seite (separat): mit dem passenden Flag bauen/starten:
# flutter run --dart-define=HL_BACKEND=usb
#
# Aufruf:
# ./tool/dev_usb.sh
#
# Rückkehr in den LAN-Modus:
# docker compose up -d # Keycloak wieder mit LAN-IP-Hostname
# adb reverse --remove-all # Tunnel abbauen
# cargo run # Backend mit config.toml (LAN-IP-Issuer)
set -euo pipefail
KC_HOST="${KC_HOSTNAME_OVERRIDE:-localhost}"
API_PORT="${API_PORT:-3000}"
KC_PORT="${KC_PORT:-8080}"
ISSUER="http://${KC_HOST}:${KC_PORT}/realms/holzleitner"
echo "→ USB-Tunnel-Modus (Issuer: ${ISSUER})"
# ── 1. Keycloak mit localhost-Hostname (neu)starten ──────────────────────
echo "→ Keycloak mit KC_HOSTNAME=${KC_HOST} starten …"
KC_HOSTNAME="${KC_HOST}" docker compose up -d keycloak postgres
# ── 2. adb-reverse-Tunnel ────────────────────────────────────────────────
if ! command -v adb >/dev/null 2>&1; then
echo "✗ adb nicht gefunden. Android-Platform-Tools installieren oder PATH prüfen." >&2
exit 1
fi
if [ -z "$(adb devices | sed -n '2p')" ]; then
echo "✗ Kein Gerät über adb sichtbar. USB-Kabel + USB-Debugging prüfen." >&2
exit 1
fi
echo "→ adb reverse: localhost:${API_PORT} (API) + localhost:${KC_PORT} (Keycloak)"
adb reverse "tcp:${API_PORT}" "tcp:${API_PORT}"
adb reverse "tcp:${KC_PORT}" "tcp:${KC_PORT}"
adb reverse --list
# ── 3. Backend mit localhost-Issuer starten ──────────────────────────────
# Config ist jetzt datei-basiert (config.toml). Statt einer Env-Variablen
# leiten wir eine Temp-Config ab, in der nur `issuer_url` auf localhost zeigt,
# und reichen sie per HOLZLEITNER_CONFIG rein. So bleibt die echte config.toml
# (LAN-IP-Issuer) unverändert.
BASE_CONFIG="${HOLZLEITNER_CONFIG:-config.toml}"
if [ ! -f "${BASE_CONFIG}" ]; then
echo "${BASE_CONFIG} nicht gefunden (cp config.example.toml config.toml)." >&2
exit 1
fi
TMP_CONFIG="$(mktemp -t holzleitner-usb-config.XXXXXX)"
trap 'rm -f "${TMP_CONFIG}"' EXIT
# Nur die issuer_url-Zeile ersetzen (es gibt genau eine, unter [keycloak]).
awk -v iss="${ISSUER}" '
/^[[:space:]]*issuer_url[[:space:]]*=/ { print "issuer_url = \"" iss "\""; next }
{ print }
' "${BASE_CONFIG}" > "${TMP_CONFIG}"
echo "→ Backend starten (issuer_url=${ISSUER}, Temp-Config ${TMP_CONFIG}) …"
echo " Tipp: App in einem zweiten Terminal mit"
echo " flutter run --dart-define=HL_BACKEND=usb"
echo
HOLZLEITNER_CONFIG="${TMP_CONFIG}" cargo run

97
tool/mark_all_scanned.sh Executable file
View File

@ -0,0 +1,97 @@
#!/usr/bin/env bash
#
# Markiert ALLE scanbaren Items der Test-Touren als erfolgreich gescannt
# (scan_status = 'done', scanned_quantity = required_quantity). Damit
# erscheint jede Lieferung als „fertig beladen" und die Auslieferungs-Phase
# lässt sich Ende-zu-Ende testen, ohne jedes Gerät am Gerät zu scannen.
#
# Im Gegensatz zur früheren Inline-Variante (nur Standardlager) erfasst
# dieses Skript bewusst auch das Filiale — Ziel ist „nichts mehr offen".
#
# Regeln:
# * nur scanbare Artikel (Dienstleistungen werden nicht gescannt)
# * Standard- UND Filiale
# * Items mit Status 'removed' bleiben unangetastet (bewusst entfernt)
# * 'held' wird ebenfalls auf 'done' gehoben (sauberer Fertig-Zustand)
#
# Accounts:
# * Default: 1001,1002 (die Demo-Test-Accounts)
# * via Env überschreibbar: ACCOUNTS="1001,1002,1003" ./tool/mark_all_scanned.sh
#
# Aufruf:
# ./tool/mark_all_scanned.sh
set -euo pipefail
CONTAINER="${CONTAINER:-holzleitner-postgres}"
DB_USER="${DB_USER:-holzleitner}"
DB_NAME="${DB_NAME:-holzleitner}"
ACCOUNTS="${ACCOUNTS:-1001,1002}"
# Nur Ziffern + Kommas — schützt die rohe psql-Variablen-Substitution unten
# vor Injection.
if ! [[ "$ACCOUNTS" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
echo "✗ Ungültige ACCOUNTS-Liste '$ACCOUNTS'. Erwartet: kommagetrennte Zahlen, z.B. 1001,1002." >&2
exit 1
fi
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2
exit 1
fi
echo "→ Alle scanbaren Items der Accounts ($ACCOUNTS) auf 'done' setzen …"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
-v accounts="$ACCOUNTS" <<'SQL'
BEGIN;
-- Status VOR dem Update (Kontrolle).
\echo
\echo --- VORHER ---
SELECT w.name AS lager, di.scan_status, COUNT(*) AS items
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN tours t ON t.id = d.tour_id
JOIN warehouses w ON w.id = di.warehouse_id
JOIN articles a ON a.id = di.article_id
WHERE t.account_id IN (:accounts)
AND a.scannable = TRUE
GROUP BY w.name, di.scan_status
ORDER BY w.name, di.scan_status;
-- Update über Subquery: Postgres erlaubt im UPDATE … FROM keinen
-- Self-Join auf die Zieltabelle in der JOIN-Bedingung, daher die Id-Liste
-- vorab per SELECT bestimmen.
UPDATE delivery_items
SET scanned_quantity = required_quantity,
scan_status = 'done',
scan_last_updated_at = now()
WHERE id IN (
SELECT di.id
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN tours t ON t.id = d.tour_id
JOIN articles a ON a.id = di.article_id
WHERE t.account_id IN (:accounts)
AND a.scannable = TRUE
AND di.scan_status <> 'removed'
);
\echo
\echo --- NACHHER ---
SELECT w.name AS lager, di.scan_status, COUNT(*) AS items
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN tours t ON t.id = d.tour_id
JOIN warehouses w ON w.id = di.warehouse_id
JOIN articles a ON a.id = di.article_id
WHERE t.account_id IN (:accounts)
AND a.scannable = TRUE
GROUP BY w.name, di.scan_status
ORDER BY w.name, di.scan_status;
COMMIT;
SQL
echo "✓ Alle scanbaren Items auf 'done' gesetzt — Auslieferung testbar."

101
tool/mark_standard_scanned.sh Executable file
View File

@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Markiert nur die STANDARDLAGER-Items der Test-Touren als gescannt
# (scan_status = 'done'). Filial-Items bleiben bewusst 'in_progress' —
# damit lässt sich der Prozess „Artikel aus der Filiale abholen" testen:
# die Lieferung gilt im Standardlager als fertig, hat aber noch offene
# Filial-Positionen.
#
# Schwester-Skript zu `mark_all_scanned.sh` (das ALLES inkl. Filiale
# scannt). Gleiche Regeln, nur zusätzlich der `w.is_standard = TRUE`-Filter.
#
# Regeln:
# * nur scanbare Artikel (Dienstleistungen werden nicht gescannt)
# * NUR Standardlager (Filiale bleibt offen)
# * Items mit Status 'removed' bleiben unangetastet
# * 'held' wird auf 'done' gehoben
#
# Accounts:
# * Default: 1001,1002
# * via Env überschreibbar: ACCOUNTS="1001,1002,1003" ./tool/mark_standard_scanned.sh
#
# Aufruf:
# ./tool/mark_standard_scanned.sh
set -euo pipefail
CONTAINER="${CONTAINER:-holzleitner-postgres}"
DB_USER="${DB_USER:-holzleitner}"
DB_NAME="${DB_NAME:-holzleitner}"
ACCOUNTS="${ACCOUNTS:-1001,1002}"
# Nur Ziffern + Kommas — schützt die rohe psql-Variablen-Substitution unten
# vor Injection.
if ! [[ "$ACCOUNTS" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
echo "✗ Ungültige ACCOUNTS-Liste '$ACCOUNTS'. Erwartet: kommagetrennte Zahlen, z.B. 1001,1002." >&2
exit 1
fi
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2
exit 1
fi
echo "→ Nur Standardlager-Items der Accounts ($ACCOUNTS) auf 'done' setzen (Filiale bleibt offen) …"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
-v accounts="$ACCOUNTS" <<'SQL'
BEGIN;
-- Status VOR dem Update (Kontrolle).
\echo
\echo --- VORHER ---
SELECT w.name AS lager, di.scan_status, COUNT(*) AS items
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN tours t ON t.id = d.tour_id
JOIN warehouses w ON w.id = di.warehouse_id
JOIN articles a ON a.id = di.article_id
WHERE t.account_id IN (:accounts)
AND a.scannable = TRUE
GROUP BY w.name, di.scan_status
ORDER BY w.name, di.scan_status;
-- Update über Subquery: Postgres erlaubt im UPDATE … FROM keinen
-- Self-Join auf die Zieltabelle in der JOIN-Bedingung, daher die Id-Liste
-- vorab per SELECT bestimmen. Filter `w.is_standard = TRUE` lässt das
-- Filiale bewusst offen.
UPDATE delivery_items
SET scanned_quantity = required_quantity,
scan_status = 'done',
scan_last_updated_at = now()
WHERE id IN (
SELECT di.id
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN tours t ON t.id = d.tour_id
JOIN warehouses w ON w.id = di.warehouse_id
JOIN articles a ON a.id = di.article_id
WHERE t.account_id IN (:accounts)
AND a.scannable = TRUE
AND w.is_standard = TRUE
AND di.scan_status <> 'removed'
);
\echo
\echo --- NACHHER ---
SELECT w.name AS lager, di.scan_status, COUNT(*) AS items
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN tours t ON t.id = d.tour_id
JOIN warehouses w ON w.id = di.warehouse_id
JOIN articles a ON a.id = di.article_id
WHERE t.account_id IN (:accounts)
AND a.scannable = TRUE
GROUP BY w.name, di.scan_status
ORDER BY w.name, di.scan_status;
COMMIT;
SQL
echo "✓ Standardlager gescannt, Filiale offen — Abhol-Prozess testbar."

70
tool/reseed_today.sh Executable file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# Datiert ALLE vorhandenen Touren auf den heutigen Tag um.
#
# Zweck: nach `seed_demo_data.sh 2026-06-01` (oder Touren aus dem ERP-Sync
# mit anderem Datum) holt dieses Skript alle Touren auf CURRENT_DATE, damit
# die App sie als „heutige Tour" anzeigt.
#
# WICHTIG: ersetzt, fügt nichts hinzu.
# * Es werden KEINE neuen Touren angelegt.
# * Bestehende Lieferungen / Items / Notizen / Scans bleiben erhalten —
# nur `tour_date` (und `synced_at`) der Tour selbst wandert auf heute.
#
# Sonderfall mehrere Touren pro Account: die Tabelle hat
# `UNIQUE (account_id, tour_date)`. Hätte ein Account mehrere Touren an
# verschiedenen Tagen, würde ein pauschales Umdatieren den Constraint
# verletzen. Deshalb behalten wir pro Account die jüngste Tour
# (tour_date, dann synced_at, dann id als Tie-Break) und löschen die
# älteren Duplikate (CASCADE räumt deren Lieferungen).
#
# Aufruf:
# ./tool/reseed_today.sh
set -euo pipefail
CONTAINER="${CONTAINER:-holzleitner-postgres}"
DB_USER="${DB_USER:-holzleitner}"
DB_NAME="${DB_NAME:-holzleitner}"
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2
exit 1
fi
echo "→ Alle Touren auf heute (CURRENT_DATE) umdatieren …"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q <<'SQL'
BEGIN;
-- ── 1. Duplikate pro Account abräumen ────────────────────────────────
-- Pro account_id genau die jüngste Tour behalten; ältere löschen, damit
-- das anschließende Umdatieren nicht in die UNIQUE(account_id, tour_date)-
-- Constraint läuft. CASCADE räumt deliveries → items → scans → notes.
DELETE FROM tours t
USING tours newer
WHERE t.account_id = newer.account_id
AND (newer.tour_date, newer.synced_at, newer.id)
> (t.tour_date, t.synced_at, t.id);
-- ── 2. Verbleibende Touren auf heute datieren ────────────────────────
-- Nach Schritt 1 gibt es pro Account genau eine Tour → kein Constraint-
-- Konflikt. Touren, die bereits heute liegen, sind ein No-Op.
UPDATE tours
SET tour_date = CURRENT_DATE,
synced_at = NOW();
COMMIT;
\echo
\echo --- TOUREN (jetzt alle heute) ---
SELECT t.account_id,
t.tour_date,
COUNT(d.id) AS lieferungen
FROM tours t
LEFT JOIN deliveries d ON d.tour_id = t.id
GROUP BY t.account_id, t.tour_date
ORDER BY t.account_id;
SQL
echo "✓ Alle Touren auf heute umdatiert."

85
tool/reset_test_tour.sh Executable file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env bash
#
# Setzt die Smoke-Test-Touren der Accounts 1001/1002 auf einen sauberen
# Zustand für den App-Smoke-Test:
#
# * tour_date → heute (sonst filtert /me/tours/today sie weg)
# * deliveries → state='active', state_reason=NULL, assigned_car_id=NULL
# * delivery_items → scan_status='in_progress', scanned_quantity=0,
# held_reason=NULL, scan_last_updated_at=NOW()
#
# Verwendet den laufenden Postgres-Container aus docker-compose.yml. Falls
# der Container anders heißt, kann der Name per CONTAINER überschrieben
# werden:
#
# CONTAINER=hl-postgres ./tool/reset_test_tour.sh
#
# Idempotent — kann beliebig oft ausgeführt werden.
set -euo pipefail
CONTAINER="${CONTAINER:-holzleitner-postgres}"
DB_USER="${DB_USER:-holzleitner}"
DB_NAME="${DB_NAME:-holzleitner}"
ACCOUNTS="${ACCOUNTS:-1001,1002}"
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2
exit 1
fi
echo "→ Reset Smoke-Test-Touren für Accounts ($ACCOUNTS) …"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q <<SQL
-- Tour-Datum auf heute schieben (alle Touren der Test-Accounts).
UPDATE tours
SET tour_date = CURRENT_DATE,
synced_at = NOW()
WHERE account_id IN ($ACCOUNTS);
-- Lieferungen wieder aktiv, ohne Reason, keinem Auto zugeordnet.
UPDATE deliveries
SET state = 'active',
state_reason = NULL,
assigned_car_id = NULL
WHERE tour_id IN (SELECT id FROM tours WHERE account_id IN ($ACCOUNTS));
-- Items in „nichts gescannt"-Ausgangszustand zurück.
UPDATE delivery_items
SET scan_status = 'in_progress',
scanned_quantity = 0,
held_reason = NULL,
scan_last_updated_at = NOW()
WHERE delivery_id IN (
SELECT d.id FROM deliveries d
JOIN tours t ON t.id = d.tour_id
WHERE t.account_id IN ($ACCOUNTS)
);
\\echo
\\echo --- TOUREN ---
SELECT account_id, tour_date, id FROM tours
WHERE account_id IN ($ACCOUNTS)
ORDER BY account_id, tour_date;
\\echo
\\echo --- LIEFERUNGEN ---
SELECT t.account_id, d.erp_belegnummer, d.state, d.sort_order, d.assigned_car_id
FROM deliveries d
JOIN tours t ON t.id = d.tour_id
WHERE t.account_id IN ($ACCOUNTS)
ORDER BY t.account_id, d.sort_order;
\\echo
\\echo --- ITEMS ---
SELECT t.account_id, d.erp_belegnummer, di.belegzeilen_nr,
a.name AS article, di.required_quantity, di.scan_status, di.scanned_quantity
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN tours t ON t.id = d.tour_id
JOIN articles a ON a.id = di.article_id
WHERE t.account_id IN ($ACCOUNTS)
ORDER BY t.account_id, d.sort_order, di.belegzeilen_nr;
SQL
echo "✓ Reset abgeschlossen."

299
tool/seed_demo_data.sh Executable file
View File

@ -0,0 +1,299 @@
#!/usr/bin/env bash
#
# Setzt die Test-DB auf einen realistischen Holzleitner-Datenstand:
# 8 Elektrogeräte-Lieferungen für Personalnummer 1001, Mix aus Standard-
# und Filial-Items.
#
# Im Gegensatz zu `reset_test_tour.sh` (nur Item-Status zurücksetzen)
# baut dieses Skript Stammdaten + Lieferungen komplett neu auf. Idempotent:
# vorhandene Test-Touren der Accounts 1001/1002 werden gelöscht (CASCADE
# räumt deliveries / items / scan_audit / notes mit), Customer- und
# Article-Stamm wird ersetzt.
#
# Tour-Datum:
# * ohne Argument → heute (CURRENT_DATE)
# * mit Datum YYYY-MM-DD → genau dieser Tag
#
# Aufruf:
# ./tool/seed_demo_data.sh # Tour für heute
# ./tool/seed_demo_data.sh 2026-06-01 # Tour für ein bestimmtes Datum
# TOUR_DATE=2026-06-01 ./tool/seed_demo_data.sh # alternativ via Env
set -euo pipefail
CONTAINER="${CONTAINER:-holzleitner-postgres}"
DB_USER="${DB_USER:-holzleitner}"
DB_NAME="${DB_NAME:-holzleitner}"
# Datum aus erstem Argument oder TOUR_DATE-Env. Leer = heute.
TOUR_DATE="${1:-${TOUR_DATE:-}}"
if [ -n "$TOUR_DATE" ]; then
if ! [[ "$TOUR_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "✗ Ungültiges Datum '$TOUR_DATE'. Erwartet: YYYY-MM-DD." >&2
exit 1
fi
# SQL-Literal — der Wert ist durch die Regex oben auf reine Ziffern/Bindestriche
# beschränkt, daher kein Injection-Risiko.
TOUR_DATE_EXPR="DATE '$TOUR_DATE'"
DATE_LABEL="$TOUR_DATE"
else
TOUR_DATE_EXPR="CURRENT_DATE"
DATE_LABEL="heute"
fi
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "✗ Container '$CONTAINER' läuft nicht. Starte docker compose up -d." >&2
exit 1
fi
echo "→ Seed Demo-Daten (8 Elektrogeräte-Lieferungen, PN 1001, Tour-Datum: $DATE_LABEL) …"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
-v tour_date_expr="$TOUR_DATE_EXPR" <<'SQL'
BEGIN;
-- ── 1. Alte Test-Daten räumen ────────────────────────────────────────
-- Tours mit CASCADE räumt deliveries → delivery_items → scan_audit ab.
DELETE FROM tours WHERE account_id IN (1001, 1002);
-- Existierende Test-Customer und Custom-Artikel wegwerfen — sie können
-- jetzt sauber durch realistische Daten ersetzt werden.
DELETE FROM customers
WHERE erp_customer_id BETWEEN 4700 AND 5200;
DELETE FROM articles
WHERE article_number IN (
'BRETT-200','PALETTE-EUR','FRACHT-PAUSCH','NEU-BALKEN',
'KS-EXP-200','WM-BOS-7K','TR-SIE-8K','SP-MIE-CL',
'BO-AEG-PY','MW-SAM-23','ST-GAG-IN','DH-MIE-AC'
);
-- ── 2. Elektrogeräte-Stamm ───────────────────────────────────────────
-- Default-Warehouse setzen wir nur dort, wo das Backend-Schema das
-- semantisch erwartet (Lager-Hint im ERP). Die *tatsächliche* Lager-
-- Zuordnung pro Lieferposition kommt unten in delivery_items.warehouse_id.
INSERT INTO articles (id, article_number, name, scannable, default_warehouse_id) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001', 'KS-EXP-200', 'Kühl-Gefrierkombi Exquisit 200L', true, '11111111-1111-1111-1111-111111111111'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002', 'WM-BOS-7K', 'Waschmaschine Bosch WAU28T70 7kg', true, '11111111-1111-1111-1111-111111111111'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa003', 'TR-SIE-8K', 'Wärmepumpentrockner Siemens iQ500 8kg', true, '11111111-1111-1111-1111-111111111111'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004', 'SP-MIE-CL', 'Geschirrspüler Miele G5210 Classic', true, '11111111-1111-1111-1111-111111111111'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005', 'BO-AEG-PY', 'Einbau-Backofen AEG Pyrolyse', true, '11111111-1111-1111-1111-111111111112'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006', 'MW-SAM-23', 'Mikrowelle Samsung MS23K3513 23L', true, '11111111-1111-1111-1111-111111111111'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa007', 'ST-GAG-IN', 'Induktions-Standherd Gaggenau CI491', true, '11111111-1111-1111-1111-111111111112'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa008', 'DH-MIE-AC', 'Dunstabzugshaube Miele PUR98W', true, '11111111-1111-1111-1111-111111111111');
-- ── 3. Kunden (BGL-Region) ───────────────────────────────────────────
INSERT INTO customers (id, erp_customer_id, name, street, house_number, postal_code, city, country) VALUES
('cccccccc-cccc-cccc-cccc-ccccccccc001', 5101, 'Familie Bauer', 'Marktplatz', '5', '83435', 'Bad Reichenhall', 'Deutschland'),
('cccccccc-cccc-cccc-cccc-ccccccccc002', 5102, 'Familie Hofer', 'Ludwigstraße', '12', '83435', 'Bad Reichenhall', 'Deutschland'),
('cccccccc-cccc-cccc-cccc-ccccccccc003', 5103, 'Familie Steiner', 'Bahnhofstraße', '8', '83454', 'Anger', 'Deutschland'),
('cccccccc-cccc-cccc-cccc-ccccccccc004', 5104, 'Familie Mayr', 'Hauptstraße', '22', '83471', 'Berchtesgaden', 'Deutschland'),
('cccccccc-cccc-cccc-cccc-ccccccccc005', 5105, 'Familie Wagner', 'Wittelsbacherstraße', '3', '83435', 'Bad Reichenhall', 'Deutschland'),
('cccccccc-cccc-cccc-cccc-ccccccccc006', 5106, 'Familie Berger', 'Salzburger Straße', '45', '83454', 'Anger', 'Deutschland'),
('cccccccc-cccc-cccc-cccc-ccccccccc007', 5107, 'Familie Huber', 'Reichenhaller Straße', '10', '83483', 'Bischofswiesen', 'Deutschland'),
('cccccccc-cccc-cccc-cccc-ccccccccc008', 5108, 'Familie Lechner', 'Maximilianstraße', '15', '83435', 'Bad Reichenhall', 'Deutschland');
-- Ansprechpartner — eine Person pro Familie, primär als Telefon-Anker.
INSERT INTO customer_contacts (id, customer_id, name, phone, email) VALUES
('dddddddd-dddd-dddd-dddd-ddddddddd001', 'cccccccc-cccc-cccc-cccc-ccccccccc001', 'Martin Bauer', '+49 8651 12345', NULL),
('dddddddd-dddd-dddd-dddd-ddddddddd002', 'cccccccc-cccc-cccc-cccc-ccccccccc002', 'Anna Hofer', '+49 8651 23456', 'a.hofer@example.de'),
('dddddddd-dddd-dddd-dddd-ddddddddd003', 'cccccccc-cccc-cccc-cccc-ccccccccc003', 'Josef Steiner', '+49 8656 34567', NULL),
('dddddddd-dddd-dddd-dddd-ddddddddd004', 'cccccccc-cccc-cccc-cccc-ccccccccc004', 'Sabine Mayr', '+49 8652 45678', NULL),
('dddddddd-dddd-dddd-dddd-ddddddddd005', 'cccccccc-cccc-cccc-cccc-ccccccccc005', 'Thomas Wagner', '+49 8651 56789', 't.wagner@example.de'),
('dddddddd-dddd-dddd-dddd-ddddddddd006', 'cccccccc-cccc-cccc-cccc-ccccccccc006', 'Petra Berger', '+49 8656 67890', NULL),
('dddddddd-dddd-dddd-dddd-ddddddddd007', 'cccccccc-cccc-cccc-cccc-ccccccccc007', 'Franz Huber', '+49 8652 78901', NULL),
('dddddddd-dddd-dddd-dddd-ddddddddd008', 'cccccccc-cccc-cccc-cccc-ccccccccc008', 'Maria Lechner', '+49 8651 89012', 'm.lechner@example.de');
-- ── 4. Tour für PN 1001 ───────────────────────────────────────────────
-- Datum kommt als psql-Variable (Default CURRENT_DATE, sonst DATE 'YYYY-MM-DD').
INSERT INTO tours (id, account_id, tour_date, synced_at) VALUES
('55555555-5555-5555-5555-555555555555', 1001, :tour_date_expr, NOW());
-- ── 5. Lieferungen ───────────────────────────────────────────────────
-- erp_belegart_id = 1 (Auftragsbestätigung, AB) wie in den ursprünglichen
-- Seeds. sort_order entspricht der ERP-Vorgabe; die App kann nachträglich
-- über PUT /tours/{id}/delivery-order anders sortieren.
-- Payment-Method-IDs (siehe Migration 0008):
-- cash = 99999999-9999-9999-9999-999999999901
-- ec_card = 99999999-9999-9999-9999-999999999902
-- invoice = 99999999-9999-9999-9999-999999999904
-- (credit_card wurde aus den Stammdaten entfernt — Migration 0021)
INSERT INTO deliveries (
id, tour_id, erp_belegart_id, erp_belegnummer, customer_id,
snap_street, snap_house_number, snap_postal_code, snap_city, snap_country,
assigned_car_id, desired_time, special_agreements,
state, state_reason, sort_order,
prepaid_amount, payment_method_id
) VALUES
-- Bauer: voll vorab bezahlt (Online-Kauf), Methode bleibt der ERP-Default (cash)
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee001', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1001', 'cccccccc-cccc-cccc-cccc-ccccccccc001',
'Marktplatz', '5', '83435', 'Bad Reichenhall', 'Deutschland',
NULL, '08:30 10:00', NULL, 'active', NULL, 1,
899.00, '99999999-9999-9999-9999-999999999901'),
-- Hofer: nichts vorab, EC bei Lieferung (Wasch+Trockner)
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee002', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1002', 'cccccccc-cccc-cccc-cccc-ccccccccc002',
'Ludwigstraße', '12', '83435', 'Bad Reichenhall', 'Deutschland',
NULL, '09:00 11:00', 'Bitte alte Geräte mitnehmen', 'active', NULL, 2,
0.00, '99999999-9999-9999-9999-999999999902'),
-- Steiner: 200 EUR Anzahlung, Rest auf Rechnung
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee003', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1003', 'cccccccc-cccc-cccc-cccc-ccccccccc003',
'Bahnhofstraße', '8', '83454', 'Anger', 'Deutschland',
NULL, NULL, NULL, 'active', NULL, 3,
200.00, '99999999-9999-9999-9999-999999999904'),
-- Mayr: Großgerät, EC bei Lieferung
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee004', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1004', 'cccccccc-cccc-cccc-cccc-ccccccccc004',
'Hauptstraße', '22', '83471', 'Berchtesgaden', 'Deutschland',
NULL, '13:00 15:00', 'Einbau erfolgt durch Servicepartner', 'active', NULL, 4,
0.00, '99999999-9999-9999-9999-999999999902'),
-- Wagner: nichts vorab, Bar
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee005', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1005', 'cccccccc-cccc-cccc-cccc-ccccccccc005',
'Wittelsbacherstraße', '3', '83435', 'Bad Reichenhall', 'Deutschland',
NULL, NULL, NULL, 'active', NULL, 5,
0.00, '99999999-9999-9999-9999-999999999901'),
-- Berger: 500 EUR Anzahlung, Rest EC
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee006', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1006', 'cccccccc-cccc-cccc-cccc-ccccccccc006',
'Salzburger Straße', '45', '83454', 'Anger', 'Deutschland',
NULL, '11:00 13:00', NULL, 'active', NULL, 6,
500.00, '99999999-9999-9999-9999-999999999902'),
-- Huber: nichts vorab, Rechnung (Stammkunde)
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee007', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1007', 'cccccccc-cccc-cccc-cccc-ccccccccc007',
'Reichenhaller Straße', '10', '83483', 'Bischofswiesen', 'Deutschland',
NULL, NULL, 'Anlieferung Tiefgaragen-Zugang', 'active', NULL, 7,
0.00, '99999999-9999-9999-9999-999999999904'),
-- Lechner: nichts vorab, Bar
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee008', '55555555-5555-5555-5555-555555555555',
1, 'AB-2026-1008', 'cccccccc-cccc-cccc-cccc-ccccccccc008',
'Maximilianstraße', '15', '83435', 'Bad Reichenhall', 'Deutschland',
NULL, '15:30 17:00', NULL, 'active', NULL, 8,
0.00, '99999999-9999-9999-9999-999999999901');
-- Ansprechpartner pro Lieferung (1:1).
INSERT INTO delivery_contact_persons (delivery_id, customer_contact_id) VALUES
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee001', 'dddddddd-dddd-dddd-dddd-ddddddddd001'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee002', 'dddddddd-dddd-dddd-dddd-ddddddddd002'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee003', 'dddddddd-dddd-dddd-dddd-ddddddddd003'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee004', 'dddddddd-dddd-dddd-dddd-ddddddddd004'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee005', 'dddddddd-dddd-dddd-dddd-ddddddddd005'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee006', 'dddddddd-dddd-dddd-dddd-ddddddddd006'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee007', 'dddddddd-dddd-dddd-dddd-ddddddddd007'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeee008', 'dddddddd-dddd-dddd-dddd-ddddddddd008');
-- ── 6. Items pro Lieferung ───────────────────────────────────────────
-- 1 Stück pro Gerät (Elektrogeräte-Geschäft: pro Belegzeile genau ein
-- Gerät). Filial-Items (Backofen, Standherd) landen explizit auf
-- Filiale Freilassing, weil die Geräte zu sperrig für das Standardlager sind.
INSERT INTO delivery_items (
id, delivery_id, article_id, required_quantity, warehouse_id,
belegzeilen_nr, komponenten_artikel_nr,
scanned_quantity, scan_status, held_reason
) VALUES
-- Bauer: Kühl-Gefrierkombi
('ffffffff-ffff-ffff-ffff-fffffffff001', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee001',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL),
-- Hofer: Waschmaschine + Trockner
('ffffffff-ffff-ffff-ffff-fffffffff002', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee002',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL),
('ffffffff-ffff-ffff-ffff-fffffffff003', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee002',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa003', 1, '11111111-1111-1111-1111-111111111111', 2, NULL, 0, 'in_progress', NULL),
-- Steiner: Geschirrspüler
('ffffffff-ffff-ffff-ffff-fffffffff004', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee003',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL),
-- Mayr: NUR Filiale (Backofen) — UX-Test „Standardlager fertig — Filiale offen"
('ffffffff-ffff-ffff-ffff-fffffffff005', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee004',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005', 1, '11111111-1111-1111-1111-111111111112', 1, NULL, 0, 'in_progress', NULL),
-- Wagner: Mikrowelle + Dunstabzugshaube
('ffffffff-ffff-ffff-ffff-fffffffff006', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee005',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL),
('ffffffff-ffff-ffff-ffff-fffffffff007', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee005',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa008', 1, '11111111-1111-1111-1111-111111111111', 2, NULL, 0, 'in_progress', NULL),
-- Berger: drei Geräte (alle Standard)
('ffffffff-ffff-ffff-ffff-fffffffff008', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee006',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL),
('ffffffff-ffff-ffff-ffff-fffffffff009', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee006',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002', 1, '11111111-1111-1111-1111-111111111111', 2, NULL, 0, 'in_progress', NULL),
('ffffffff-ffff-ffff-ffff-fffffffff010', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee006',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006', 1, '11111111-1111-1111-1111-111111111111', 3, NULL, 0, 'in_progress', NULL),
-- Huber: NUR Filiale (Standherd) — UX-Test „nur Filiale"
('ffffffff-ffff-ffff-ffff-fffffffff011', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee007',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa007', 1, '11111111-1111-1111-1111-111111111112', 1, NULL, 0, 'in_progress', NULL),
-- Lechner: Spüler (Standard) + Backofen (Filiale) — UX-Test „gemischt"
('ffffffff-ffff-ffff-ffff-fffffffff012', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee008',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004', 1, '11111111-1111-1111-1111-111111111111', 1, NULL, 0, 'in_progress', NULL),
('ffffffff-ffff-ffff-ffff-fffffffff013', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeee008',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005', 1, '11111111-1111-1111-1111-111111111112', 2, NULL, 0, 'in_progress', NULL);
-- Stückpreise (brutto, EUR) pro Artikel. Der Warenwert einer Lieferung =
-- Σ unit_price × ausgelieferte Menge (Soll Gutschrift). Plausible Demo-Werte;
-- das ERP-Sync-Makro liefert die Preise später live mit.
UPDATE delivery_items di SET unit_price = CASE di.article_id
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa001' THEN 899.00 -- Kühl-Gefrierkombi
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa002' THEN 649.00 -- Waschmaschine
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa003' THEN 599.00 -- Trockner
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa004' THEN 549.00 -- Geschirrspüler
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa005' THEN 499.00 -- Backofen
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa006' THEN 199.00 -- Mikrowelle
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa007' THEN 749.00 -- Standherd
WHEN 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa008' THEN 329.00 -- Dunstabzugshaube
ELSE unit_price END
FROM deliveries d
WHERE d.id = di.delivery_id AND d.tour_id = '55555555-5555-5555-5555-555555555555';
COMMIT;
\echo
\echo --- ARTIKEL ---
SELECT article_number, name, scannable FROM articles ORDER BY article_number;
\echo
\echo --- KUNDEN ---
SELECT erp_customer_id, name, postal_code, city FROM customers ORDER BY erp_customer_id;
\echo
\echo --- TOUR ---
SELECT account_id, tour_date, synced_at
FROM tours
WHERE id = '55555555-5555-5555-5555-555555555555';
\echo
\echo --- LIEFERUNGEN (PN 1001) ---
SELECT d.sort_order, d.erp_belegnummer, c.name AS kunde, d.snap_city
FROM deliveries d
JOIN customers c ON c.id = d.customer_id
WHERE d.tour_id = '55555555-5555-5555-5555-555555555555'
ORDER BY d.sort_order;
\echo
\echo --- ITEMS PRO LIEFERUNG ---
SELECT d.erp_belegnummer, di.belegzeilen_nr, a.article_number, a.name,
w.name AS lager, di.required_quantity
FROM delivery_items di
JOIN deliveries d ON d.id = di.delivery_id
JOIN articles a ON a.id = di.article_id
JOIN warehouses w ON w.id = di.warehouse_id
WHERE d.tour_id = '55555555-5555-5555-5555-555555555555'
ORDER BY d.sort_order, di.belegzeilen_nr;
SQL
echo "✓ Demo-Daten geseedet (Tour-Datum: $DATE_LABEL)."