Compare commits

..
Author SHA1 Message Date
gitea_admin c11cb39dc4 Sync styleguide 2026.05.18.1 2026-09-06 15:32:07 +02:00
43 changed files with 271 additions and 5165 deletions

No files matched your search

-5
View File
@@ -15,11 +15,6 @@
11. `db/migrations/0015_sales_order_payments.sql`
12. `db/migrations/0016_stock_lot_forecast_history.sql`
13. `db/migrations/0017_manufacturer_lot_traceability.sql`
14. `db/migrations/0018_shopify_delta_sync_state.sql`
15. `db/migrations/0019_allow_multiple_open_stock_lots.sql`
16. `db/migrations/0020_include_stock_adjustments_in_balance.sql`
17. `db/migrations/0021_stock_lot_activation_selection.sql`
18. `db/migrations/0022_backfill_stock_lot_activation_selection.sql`
## Beispielausfuehrung
@@ -1,5 +0,0 @@
BEGIN;
DROP INDEX IF EXISTS uq_stock_lot_one_open_per_product;
COMMIT;
@@ -1,22 +0,0 @@
BEGIN;
CREATE OR REPLACE VIEW v_stock_lot_balance AS
SELECT
sl.id AS stock_lot_id,
sl.product_id,
COALESCE(SUM(CASE WHEN sm.move_type = 'in' THEN sm.qty ELSE 0 END), 0) AS qty_in,
COALESCE(SUM(CASE WHEN sm.move_type = 'out' THEN sm.qty ELSE 0 END), 0) AS qty_out,
COALESCE(SUM(
CASE
WHEN sm.move_type = 'in' THEN sm.qty
WHEN sm.move_type = 'out' THEN -sm.qty
WHEN sm.move_type = 'adjustment' AND sm.to_location_id IS NOT NULL AND sm.from_location_id IS NULL THEN sm.qty
WHEN sm.move_type = 'adjustment' AND sm.from_location_id IS NOT NULL AND sm.to_location_id IS NULL THEN -sm.qty
ELSE 0
END
), 0) AS qty_net
FROM stock_lot sl
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
GROUP BY sl.id, sl.product_id;
COMMIT;
@@ -1,119 +0,0 @@
BEGIN;
ALTER TABLE stock_lot
ADD COLUMN IF NOT EXISTS next_active BOOLEAN NOT NULL DEFAULT FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_stock_lot_one_next_active_per_product
ON stock_lot(product_id)
WHERE status = 'open' AND next_active = TRUE;
CREATE OR REPLACE FUNCTION fn_auto_switch_lot_when_depleted()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_product_id BIGINT;
v_open_lot_id BIGINT;
v_qualified_count INTEGER;
v_current_net NUMERIC;
v_event_key TEXT;
BEGIN
SELECT product_id
INTO v_product_id
FROM stock_lot
WHERE id = NEW.lot_id;
IF NOT EXISTS (
SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current'
) THEN
RETURN NEW;
END IF;
SELECT qty_net
INTO v_current_net
FROM v_stock_lot_balance
WHERE stock_lot_id = NEW.lot_id;
IF COALESCE(v_current_net, 0) > 0 THEN
RETURN NEW;
END IF;
PERFORM 1
FROM stock_lot
WHERE product_id = v_product_id
FOR UPDATE;
IF NOT EXISTS (
SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current'
) THEN
RETURN NEW;
END IF;
SELECT sl.id
INTO v_open_lot_id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = v_product_id
AND sl.status = 'open'
AND sl.next_active = TRUE
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
LIMIT 1;
IF v_open_lot_id IS NULL THEN
SELECT COUNT(*), MIN(sl.id)
INTO v_qualified_count, v_open_lot_id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = v_product_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0;
IF v_qualified_count <> 1 THEN
RETURN NEW;
END IF;
UPDATE stock_lot
SET next_active = TRUE, updated_at = NOW()
WHERE id = v_open_lot_id;
END IF;
UPDATE stock_lot
SET status = 'closed', updated_at = NOW()
WHERE id = NEW.lot_id;
UPDATE stock_lot
SET status = 'current', next_active = FALSE, updated_at = NOW()
WHERE id = v_open_lot_id;
v_event_key := format('lot.auto_switched:%s:%s', v_product_id, txid_current());
PERFORM fn_enqueue_event(
'lot.auto_switched',
v_event_key,
'stock_lot',
NEW.lot_id::TEXT,
jsonb_build_object(
'productId', v_product_id,
'closedLotId', NEW.lot_id,
'newCurrentLotId', v_open_lot_id,
'occurredAt', NOW()
)
);
RETURN NEW;
END;
$$;
COMMIT;
@@ -1,25 +0,0 @@
BEGIN;
WITH qualified AS (
SELECT
sl.id,
sl.product_id,
COUNT(*) OVER (PARTITION BY sl.product_id) AS qualified_count
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
)
UPDATE stock_lot sl
SET next_active = TRUE, updated_at = NOW()
FROM qualified q
WHERE sl.id = q.id
AND q.qualified_count = 1;
COMMIT;
-215
View File
@@ -1,215 +0,0 @@
BEGIN;
-- Market Management base schema.
-- Fachliche Ownership: market_management.
CREATE TABLE market_category (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_category_code ON market_category(code);
CREATE UNIQUE INDEX uq_market_category_name ON market_category(name);
INSERT INTO market_category (code, name)
VALUES
('herbstmarkt', 'Herbstmärkte'),
('weihnachtsmarkt', 'Weihnachtsmärkte'),
('fruehlingsmarkt', 'Frühlingsmärkte'),
('ostermarkt', 'Ostermärkte'),
('kunsthandwerksmarkt', 'Kunsthandwerksmärkte'),
('regionalmarkt', 'Regionalmärkte'),
('weitere_geeignete_maerkte', 'Weitere geeignete Märkte');
CREATE TABLE market_municipality_reference (
id BIGSERIAL PRIMARY KEY,
bfs_municipality_number INTEGER NOT NULL,
name TEXT NOT NULL,
canton_code TEXT NOT NULL,
data_version DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_municipality_reference_bfs_number_version
ON market_municipality_reference(bfs_municipality_number, data_version);
CREATE INDEX idx_market_municipality_reference_canton
ON market_municipality_reference(canton_code, name);
CREATE TABLE market_definition (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
category_id BIGINT NOT NULL REFERENCES market_category(id) ON DELETE RESTRICT,
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_definition_name ON market_definition(name);
CREATE INDEX idx_market_definition_category ON market_definition(category_id);
CREATE TABLE market_occurrence (
id BIGSERIAL PRIMARY KEY,
market_definition_id BIGINT NOT NULL REFERENCES market_definition(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'open',
place TEXT,
address TEXT,
municipality_reference_id BIGINT REFERENCES market_municipality_reference(id) ON DELETE RESTRICT,
notes TEXT,
registration_deadline DATE,
request_or_registration_date DATE,
confirmation_date DATE,
payment_deadline DATE,
stand_size TEXT,
table_available BOOLEAN,
table_rentable BOOLEAN,
own_table_required BOOLEAN,
shelter_available BOOLEAN,
power_available BOOLEAN,
water_available BOOLEAN,
lighting_available BOOLEAN,
other_infrastructure TEXT,
own_stand_requirements TEXT,
travel_address TEXT,
travel_distance_km NUMERIC(10, 2),
travel_duration_minutes INTEGER,
parking_options TEXT,
delivery_access TEXT,
direct_unload_possible BOOLEAN,
storage_options TEXT,
travel_notes TEXT,
payment_status TEXT NOT NULL DEFAULT 'unpaid',
payment_date DATE,
paid_amount NUMERIC(14, 2),
payment_method_code TEXT,
cancellation_terms TEXT,
revenue_share_terms TEXT,
other_participation_conditions TEXT,
contract_folder_url TEXT,
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_status CHECK (
status IN ('open', 'waitlist', 'registered', 'confirmation_pending', 'confirmed', 'cancelled', 'completed')
),
CONSTRAINT chk_market_occurrence_payment_status CHECK (
payment_status IN ('paid', 'unpaid')
),
CONSTRAINT chk_market_occurrence_travel_distance CHECK (
travel_distance_km IS NULL OR travel_distance_km >= 0
),
CONSTRAINT chk_market_occurrence_travel_duration CHECK (
travel_duration_minutes IS NULL OR travel_duration_minutes >= 0
),
CONSTRAINT chk_market_occurrence_paid_amount CHECK (
paid_amount IS NULL OR paid_amount >= 0
)
);
CREATE INDEX idx_market_occurrence_market ON market_occurrence(market_definition_id);
CREATE INDEX idx_market_occurrence_status ON market_occurrence(status);
CREATE INDEX idx_market_occurrence_municipality
ON market_occurrence(municipality_reference_id);
CREATE INDEX idx_market_occurrence_archived_at ON market_occurrence(archived_at);
CREATE TABLE market_occurrence_organizer (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL UNIQUE REFERENCES market_occurrence(id) ON DELETE RESTRICT,
organization_contact_id BIGINT NOT NULL,
contact_person_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_market_occurrence_organizer_organization
ON market_occurrence_organizer(organization_contact_id);
CREATE INDEX idx_market_occurrence_organizer_contact_person
ON market_occurrence_organizer(contact_person_id);
CREATE TABLE market_occurrence_day (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
date DATE NOT NULL,
stand_start_time TIME,
stand_end_time TIME,
setup_start_time TIME,
setup_end_time TIME,
delivery_start_time TIME,
delivery_end_time TIME,
observations TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_day_stand_time CHECK (
stand_start_time IS NULL OR stand_end_time IS NULL OR stand_end_time >= stand_start_time
),
CONSTRAINT chk_market_occurrence_day_setup_time CHECK (
setup_start_time IS NULL OR setup_end_time IS NULL OR setup_end_time >= setup_start_time
),
CONSTRAINT chk_market_occurrence_day_delivery_time CHECK (
delivery_start_time IS NULL OR delivery_end_time IS NULL OR delivery_end_time >= delivery_start_time
)
);
CREATE UNIQUE INDEX uq_market_occurrence_day_date ON market_occurrence_day(occurrence_id, date);
CREATE INDEX idx_market_occurrence_day_date ON market_occurrence_day(date);
CREATE TABLE market_occurrence_cost_item (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
cost_type TEXT NOT NULL,
description TEXT,
gross_amount NUMERIC(14, 2) NOT NULL,
tax_status TEXT NOT NULL,
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
tax_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
CASE
WHEN tax_status = 'tax_free' THEN 0
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
END
) STORED,
net_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
gross_amount - CASE
WHEN tax_status = 'tax_free' THEN 0
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
END
) STORED,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_cost_gross_nonnegative CHECK (gross_amount >= 0),
CONSTRAINT chk_market_occurrence_cost_tax_status CHECK (tax_status IN ('tax_free', 'taxable')),
CONSTRAINT chk_market_occurrence_cost_tax_rate CHECK (
(tax_status = 'tax_free' AND tax_rate = 0)
OR (tax_status = 'taxable' AND tax_rate > 0)
)
);
CREATE INDEX idx_market_occurrence_cost_item_occurrence
ON market_occurrence_cost_item(occurrence_id);
CREATE TABLE market_mcp_idempotency (
id BIGSERIAL PRIMARY KEY,
user_subject TEXT NOT NULL,
client_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
tool_name TEXT NOT NULL,
request_hash TEXT NOT NULL,
result_status TEXT NOT NULL,
result_entity_type TEXT,
result_entity_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
CONSTRAINT chk_market_mcp_idempotency_result_status CHECK (
result_status IN ('processing', 'succeeded', 'failed')
)
);
CREATE UNIQUE INDEX uq_market_mcp_idempotency_client_key
ON market_mcp_idempotency(client_id, idempotency_key);
CREATE INDEX idx_market_mcp_idempotency_expiry ON market_mcp_idempotency(expires_at);
COMMIT;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE market_mcp_idempotency
ADD COLUMN result_payload JSONB;
COMMIT;
@@ -1,10 +0,0 @@
BEGIN;
ALTER TABLE market_definition
ADD COLUMN website_url TEXT;
ALTER TABLE market_definition
ADD CONSTRAINT chk_market_definition_website_url
CHECK (website_url IS NULL OR website_url ~* '^https?://');
COMMIT;
@@ -20,7 +20,7 @@ Es werden keine vorbereiteten Shopify-Geschäftsdaten an nachgelagerte Module ü
## 4. Exakter Prozessablauf
1. `cutoff_timestamp` und `dry_run` validieren; beim Cron den kleineren Wert aus Wasserstand und ältestem offenen Webhook-Eingang verwenden und offene Webhook-Order-GIDs zusätzlich direkt zur Recovery aufnehmen.
1. `cutoff_timestamp` und `dry_run` validieren; beim Cron den kleineren Wert aus Wasserstand und ältestem offenen Webhook-Eingang verwenden.
2. Read-only Shopify-Bestellungen mit `createdAt` nach dem Stichtag in stabiler Reihenfolge laden.
3. Stornierte oder vollständig erstattete Bestellungen als nicht lagerwirksam klassifizieren und nur technisch protokollieren.
4. Für jede qualifizierte Bestellung die Shopify Order-GID idempotent gegen `sales_order.shopify_order_gid` prüfen.
@@ -43,7 +43,7 @@ Write targets:
## 5. Batch, Betriebsmodell und Einbettung
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere tägliche Reconciliation-Läufe nutzen den persistierten Wasserstand und ziehen offene oder fehlgeschlagene Webhook-Eingänge sowohl über den Prüfbereich als auch über ihre stabile Order-GID wieder in die Verarbeitung. Ein technisches Event wird erst nach erfolgreicher Projektion als verarbeitet markiert. Der Wasserstand wird nur nach einem vollständig erfolgreichen Lauf fortgeschrieben. Keine Parallelisierung.
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere tägliche Reconciliation-Läufe nutzen den persistierten Wasserstand und ziehen offene oder fehlgeschlagene Webhook-Eingänge wieder in den Prüfbereich. Der Wasserstand wird nur nach einem vollständig erfolgreichen Lauf fortgeschrieben. Keine Parallelisierung.
## 6. Output
@@ -4,7 +4,7 @@ Status: Verbindliche Prozess-Spezifikation
## 1. Zweck
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums der aktuellen Charge und – sofern vorhanden – der qualifizierten, als nächste Charge markierten offenen Charge aktiver Produkte.
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder aktuellen Charge aktiver Produkte.
## 2. Prozess-Einbettung
@@ -13,18 +13,18 @@ Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolg
## 3. Input
- optional eine einzelne `product_id` bei synchronem Verkaufstrigger
- aktive Produkte, aktuelle Charge, qualifizierte als nächste Charge markierte offene Charge, physische Chargenbestände und gültige Verkaufsallokationen aus dem eigenen Datenbestand
- aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand
## 4. Exakter Prozessablauf
1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen.
2. Aktive Produkte, ihre aktuelle Charge und die qualifizierte, als nächste Charge markierte offene Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden. Für jede Charge wird nur ihr eigener positiver Nettobestand berücksichtigt.
2. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden.
3. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
6. Den gewichteten Tagesabsatz ab dem ersten gültigen Verkaufstag innerhalb des geladenen Zeitraums mit einer Halbwertszeit von 60 Tagen berechnen; Tage vor dem ersten Verkauf werden nicht als Nullabsatz berücksichtigt.
7. Das Leer-Datum der aktuellen Charge aus ihrem physischen Bestand und der gewichteten Nachfrage berechnen. Das Leer-Datum der nächsten Charge wird mit derselben Nachfrage ab dem Leer-Datum der aktuellen Charge weitergerechnet; bei bereits leerer aktueller Charge beginnt es am aktuellen Datum.
8. Jede erfolgreiche Berechnung je Charge in `stock_lot_forecast_history` speichern und den jeweiligen Snapshot in `stock_lot` aktualisieren.
6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
7. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen.
8. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren.
9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
## 5. Batch, Betriebsmodell und Einbettung
@@ -37,8 +37,7 @@ Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produ
## 7. Erfolgskriterien
- Jede aktuelle Charge erhält täglich einen Prognosesnapshot nur für ihren eigenen Bestand.
- Eine qualifizierte, als nächste Charge markierte offene Charge erhält einen separaten Prognosesnapshot nur für ihren eigenen Bestand.
- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot.
- Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage.
- Die vollständige Berechnungshistorie bleibt erhalten.
- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen.
-67
View File
@@ -1,67 +0,0 @@
# Market Management
Stand: 2026-09-15
Status: Fachliches Zielbild / Anforderungen in Ausarbeitung
## Zweck
Das Modul verwaltet Märkte von der Recherche und Prüfung über Anfrage, Anmeldung
und Durchführung bis zur Kosten-, Zahlungs- und Erlösauswertung.
## Fachliche Ownership
Das Modul soll die fachliche Ownership für Marktstammdaten, Markttermine,
Teilnahme- und Durchführungsstatus, marktbezogene Kosten und Zahlungen sowie
Erlös- und Tagesauswertungen tragen.
## Zugriffsmodell
Die Nutzung des Moduls ist an eine Benutzeridentität gebunden. Das Modul soll
separat von der übrigen Anwendung nutzbar und an weitere Personen verkaufbar
sein.
Die Ownership der Lizenz-/Entitlement-Daten ist noch nicht entschieden und
bleibt offen.
## MCP-Schnittstelle zu Hermes
Die App stellt die MCP-Server-Schnittstelle bereit. Hermes Agent ist MCP-Client
und ruft ausschließlich freigegebene Funktionen des Marktmanagement-Moduls
auf.
Die App behält die Kontrolle über:
- Authentifizierung und Berechtigungsprüfung
- Validierung und fachliche Interpretation der Eingaben
- erlaubte Schreiboperationen
- Persistierung in der eigenen Datenbank
- Audit- und Quelleninformationen
Hermes erhält keinen direkten Zugriff auf die Datenbank. Das Zielbild und die
Schnittstellenentscheidungen sind im
[market_management_hermes_mcp_konzept.md](konzepte/market_management_hermes_mcp_konzept.md)
dokumentiert.
## Abgrenzung
- Zunächst keine eigene Benutzerverwaltung.
- Keine eigene technische Runtime- oder Scheduler-Logik.
- Fachlich neutrale UI- und technische Querschnittsbausteine gehören nach
`shared`.
## Dokumente
- [Anforderungsprofil](requirements/market_management_requirements.md)
- [Datenarchitektur](data/market_management_data_architecture.md)
- [Technisches Tabellen- und Prozesskonzept](konzepte/market_management_technisches_konzept.md)
- [Hermes-MCP-Konzept](konzepte/market_management_hermes_mcp_konzept.md)
- [DB-Ownership-Konzept](konzepte/market_management_db_ownership.md)
- Prozessdokumente werden unter `processes/main/` und
`processes/sub_processes/` angelegt, sobald die fachlichen Prozesse explizit
definiert sind.
## Offene Architekturentscheidung
Die Ownership und konkrete Ablage der Lizenz-/Entitlement-Daten ist offen.
Diese Entscheidung darf bei der weiteren Umsetzung nicht stillschweigend
vorweggenommen werden.
@@ -1,106 +0,0 @@
# market_management_data_architecture
Stand: 2026-09-18
Status: Bestätigtes Datenarchitektur-Zielbild
## 1. Ziel
Das Marktmanagement-Modul muss wiederkehrende Märkte mit mehreren konkreten
Durchführungen und mehreren Tagen je Durchführung ohne redundante Vermischung
der Daten abbilden können.
## 2. Bestätigte Hierarchie
```text
Kategorie 1 ─── n Marktdefinition 1 ─── n Durchführung 1 ─── n Durchführungstag
```
Eine Kategorie enthält beliebig viele Marktdefinitionen. Jede Marktdefinition
gehört genau einer Kategorie.
### Kategorie
Eine Kategorie ordnet Märkte fachlich. Fabienne darf die deterministische
Kategorie-/Marktartliste erweitern. Hermes darf bestehende Kategorien
verwenden, aber keine Kategorien anlegen.
### Marktdefinition
Eine Marktdefinition ist die übergeordnete fachliche Identität, unter der
mehrere konkrete Durchführungen geführt werden können. Der zentrale Marktstamm
enthält nur den eindeutigen Marktnamen und die Marktart/Kategorie.
### Durchführung
Eine Durchführung ist eine konkrete Ausgabe bzw. Teilnahme an einem Markt. Die
Durchführung kann einen oder mehrere Durchführungstage umfassen.
### Durchführungstag
Ein Durchführungstag ist ein einzelner Tag innerhalb einer konkreten
Durchführung. Tagesbezogene Zeit-, Erlös- und Verkaufsdaten gehören auf diese
Ebene.
## 3. Feld- und Referenzwert-Zuordnung
### Zentraler Marktstamm
- Marktname
- Marktart/Kategorie aus einer deterministischen Liste
- optionale Website-URL der Veranstaltung
### Durchführung
Alle übrigen veränderlichen Marktangaben werden pro Durchführung erfasst,
insbesondere Kontakt, Ort, Termine, Anmeldung, Status, Stand, Infrastruktur,
Logistik, Kosten, Zahlung, Vertragslink und Notizen.
### Durchführungstag
- Datum des Durchführungstags
- Standzeit und tagesbezogene Durchführungszeiten
- Bruttoerlös aus zugeordneten OTC-Verkäufen
- Anzahl verkaufter Artikel aus zugeordneten OTC-Positionen
- Ereignisse und Beobachtungen
### Deterministische Referenzwerte
- Marktart/Kategorie
- Marktstatus
- Zahlungsstatus
- Zahlungsart
- Kanton
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
### Abgeleitete Werte
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
- Gesamtkosten aus den Kostenpositionen
- offener Betrag aus Kosten und bezahltem Betrag
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
Durchführungstagen
## 4. Ownership- und Normalisierungsregeln
- Jede Ebene erhält eine eigene stabile Identität.
- Mehrere Durchführungstage werden nicht als wiederholte Spalten in der
Durchführung gespeichert.
- Mehrere Durchführungen werden nicht als wiederholte Spalten im Markt
gespeichert.
- Tageserlöse und Tagesverkaufszahlen werden nur für den Durchführungstag aus
zugeordneten OTC-Verkäufen abgeleitet.
- Aggregierte Werte für Durchführung oder Markt werden aus den untergeordneten
Durchführungstagen abgeleitet oder als klar definierte Auswertung bereitgestellt.
- MCP-Schreiboperationen müssen eindeutig an die betroffene Ebene gebunden sein.
- Fremde Modul- oder DB-Ownership wird durch diese Datenarchitektur nicht
eingeführt.
## 5. Bestätigte Ergänzungen
- Eine Durchführung ist eine konkrete Teilnahme bzw. Veranstaltungsausgabe.
- Jeder Durchführungstag ist eine eigene Subdurchführung mit eigenen OTC-
Verkäufen und abgeleiteten Tageswerten.
- Markt und Durchführung werden unabhängig voneinander archiviert und
reaktiviert.
- Der Zahlungsstatus ist manuell und besitzt nur `paid` oder `unpaid`.
@@ -1,57 +0,0 @@
# Market Management: DB-Ownership-Konzept
Stand: 2026-09-19
Status: Verbindliches DB-Ownership-Konzept
## 1. Owned Tabellen des Moduls
| Tabelle | Ownership | Schreibverantwortung |
|---|---|---|
| `market_category` | `market_management` | Marktmanagement-Service |
| `market_definition` | `market_management` | Marktmanagement-Service |
| `market_occurrence` | `market_management` | Marktmanagement-Service |
| `market_occurrence_organizer` | `market_management` | Marktmanagement-Service über Kontakt-Schnittstelle |
| `market_occurrence_day` | `market_management` | Marktmanagement-Service |
| `market_occurrence_cost_item` | `market_management` | Marktmanagement-Service |
| `market_municipality_reference` | `market_management` | Referenzdatenprozess |
| `market_mcp_idempotency` | `market_management` | MCP-Submodul |
## 2. Fremde Datenreferenzen
### Kontaktmodul
Das Marktmanagement-Modul speichert nur die stabilen IDs von Organisation und
Kontaktperson. Es schreibt keine Kontakt-, Organisations- oder Adressdaten in
fremde Tabellen.
### OTC-/Direktverkaufsmodul
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
referenziert einen gültigen `market_occurrence_day_id`. Marktmanagement liest
die Verkaufsdaten ausschließlich über die definierte Schnittstelle für die
Umsatz- und Artikelmengenaggregation.
## 3. Schreibgrenzen
- Direkte Cross-Modul-Writes sind verboten.
- Marktmanagement schreibt nicht in OTC-, Kontakt- oder Organisations-Tabellen.
- Das OTC-Modul schreibt nicht in Marktmanagement-Tabellen.
- MCP schreibt nie direkt in Tabellen.
- Alle Marktmanagement-Writes laufen über den owning Modul-Service.
## 4. Referenzdaten
Der Gemeindereferenzbestand verwendet die BFS-Gemeindenummer als stabile
fachliche Referenz und speichert den Datenstand zur Nachvollziehbarkeit.
Die Kategorie-/Marktartliste gehört zum Marktmanagement und wird durch Fabienne
gepflegt. Hermes darf sie nur lesen.
## 5. Technische Änderungsregeln
- Tabellen- und Constraint-Änderungen erfolgen ausschließlich über versionierte
Migrationen.
- Fachliche Constraints werden im DB- und Prozessvertrag gespiegelt.
- Aggregierte Umsatzwerte werden nicht als unabhängige Fachwahrheit gespeichert.
- Archivierte Datensätze bleiben erhalten.
- Markt und Durchführung besitzen getrennte Archivstatus.
@@ -1,245 +0,0 @@
# Hermes: Market-Management-MCP-API
Stand: 2026-09-19
Status: Aktive technische Schnittstellendokumentation
## Verbindung
Hermes verwendet den MCP-Endpunkt der DEV-App:
```text
https://erpnaurua.imhochrain.ch/api/market-management-mcp.php
```
Jeder Request ist ein JSON-RPC-2.0-Request über HTTPS. Der Request benötigt:
```http
Authorization: Bearer <MARKET_MCP_TOKEN>
Content-Type: application/json
```
Der Token wird nicht in dieser Dokumentation oder im Repository gespeichert.
## Verbindungsablauf
### 1. Initialisieren
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26"
}
}
```
### 2. Werkzeuge laden
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
```
### 3. Werkzeug aufrufen
```json
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "market_management.list_categories",
"arguments": {}
}
}
```
Das Ergebnis steht bei `tools/call` als JSON im Textinhalt des ersten
`content`-Elements.
## Verfügbare Werkzeuge
### Marktdefinitionen
- `market_management.search_markets`
- optional: `search`, `category_id`, `status`, `place`,
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
`sort`, `sort_direction`, `limit`, `offset`
- `market_management.search_occurrences`
- optional: `search`, `market_definition_id`, `status`, `place`, `address`,
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
`sort`, `sort_direction`, `limit`, `offset`
- `market_management.get_market`
- erforderlich: `market_id`
- optional: `include_archived`
- `market_management.create_market`
- erforderlich: `name`, `category_id`
- optional: `website_url` als HTTP(S)-URL der Veranstaltung
- `market_management.update_market`
- erforderlich: `market_id`
- änderbar: `name`, `category_id`, `website_url`
- `market_management.archive_market`
- erforderlich: `market_id`
- `market_management.reactivate_market`
- erforderlich: `market_id`
### Kategorien
- `market_management.list_categories`
Hermes darf Kategorien lesen und bei einem Markt referenzieren, aber keine
Kategorien anlegen oder ändern.
- `market_management.list_reference_data`
- liefert Kategorien, Kantone und aktive Gemeinden
- `market_management.get_overview`
- liefert serverseitig gefilterte Markt- und Durchführungslisten
### Durchführungen
- `market_management.create_occurrence`
- erforderlich: `market_definition_id`, `days` mit mindestens einem Eintrag
- initialer Status: `open`
- weitere Durchführungsfelder sind optional und werden typisiert validiert
- `market_management.get_occurrence`
- erforderlich: `occurrence_id`
- `market_management.update_occurrence`
- erforderlich: `occurrence_id`
- `market_management.archive_occurrence`
- erforderlich: `occurrence_id`
- `market_management.reactivate_occurrence`
- erforderlich: `occurrence_id`
Unterstützte Statuswerte:
```text
open
waitlist
registered
confirmation_pending
confirmed
cancelled
completed
```
Unterstützte Zahlungsstatuswerte:
```text
unpaid
paid
```
### Durchführungstage
- `market_management.create_day`
- erforderlich: `occurrence_id`, `date`
- `market_management.update_day`
- erforderlich: `day_id`
- `market_management.delete_day`
- erforderlich: `day_id`
Weitere mögliche Tagesfelder sind `stand_start_time`, `stand_end_time`,
`setup_start_time`, `setup_end_time`, `delivery_start_time`,
`delivery_end_time` und `observations`.
### Veranstalter und Kosten
- `market_management.set_organizer`
- erforderlich: `occurrence_id`, `organization_contact_id`,
`contact_person_id`
- `market_management.delete_organizer`
- erforderlich: `occurrence_id`
- `market_management.create_cost_item`
- erforderlich: `occurrence_id`, `cost_type`, `gross_amount`, `tax_status`
- `tax_status`: `tax_free` oder `taxable`
- `market_management.update_cost_item`
- erforderlich: `cost_item_id`
- `market_management.delete_cost_item`
- erforderlich: `cost_item_id`
## Beispiel: Markt anlegen
```json
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "market_management.create_market",
"arguments": {
"name": "Beispielmarkt Hermes Test",
"category_id": 1,
"idempotency_key": "hermes-create-market-001"
}
}
}
```
## Beispiel: Durchführung mit Tag anlegen
```json
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "market_management.create_occurrence",
"arguments": {
"market_definition_id": 1,
"status": "open",
"place": "Beispielort",
"registration_deadline": "2026-10-01",
"idempotency_key": "hermes-create-occurrence-001",
"days": [
{
"date": "2026-11-21",
"stand_start_time": "09:00",
"stand_end_time": "18:00"
}
]
}
}
}
```
```json
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "market_management.create_day",
"arguments": {
"occurrence_id": 1,
"date": "2026-11-21",
"stand_start_time": "09:00",
"stand_end_time": "18:00"
}
}
}
```
## Fachliche Grenzen
- Hermes erhält keinen direkten Datenbankzugriff.
- Hermes darf keine Kategorien anlegen.
- Umsatz und Artikelmengen werden nicht über MCP geschrieben; sie werden aus
den OTC-Verkäufen abgeleitet.
- Markt und Durchführung werden unabhängig archiviert.
- Alle Schreibvorgänge werden serverseitig validiert.
- Jeder schreibende MCP-Aufruf benötigt `idempotency_key`; optional kann
`client_id` mitgegeben werden. Wiederholungen liefern dasselbe Ergebnis.
- Umsatz- und Artikelmengen aus OTC-Verkäufen sind erst verfügbar, sobald die
separate OTC-Schnittstelle die Zuordnung zum Durchführungstag bereitstellt.
## Fehler
Bei fehlender oder ungültiger Authentifizierung antwortet der Endpunkt mit
HTTP 401. Ungültige JSON-RPC-Requests oder fachlich ungültige Eingaben werden
als JSON-RPC-Fehlerantwort zurückgegeben.
@@ -1,92 +0,0 @@
# Market Management: Hermes-MCP-Konzept
Stand: 2026-09-19
Status: Verbindliches Schnittstellenkonzept
## 1. Zweck
Die App stellt einen geschützten MCP-Server bereit. Hermes Agent ist MCP-Client
und arbeitet im Benutzerkontext. Hermes kann Marktmanagement-Daten suchen,
anlegen, ändern, archivieren und reaktivieren.
## 2. Verantwortungen
Die App kontrolliert:
- Authentifizierung
- Benutzer- und Modulberechtigung
- Eingabevalidierung
- fachliche Feldzuordnung
- Persistierung
- Audit- und Herkunftsdaten
- Idempotenz
Hermes besitzt keinen direkten Zugriff auf Datenbank, interne Services oder
fremde Module.
## 3. Schnittstellenmodell
Der MCP-Server veröffentlicht fachliche Tools, keine generischen Datenbank-
oder Tabellenoperationen.
Vorgesehene Toolgruppen:
- Märkte suchen und lesen
- Märkte anlegen, ändern, archivieren und reaktivieren
- Durchführungen suchen und lesen
- Durchführungen anlegen, ändern, archivieren und reaktivieren
- Durchführungstage anlegen, ändern und entfernen
- Kategorien, Kantone und Gemeinden lesen
- Kosten und Zahlungsstatus ändern
- Übersichtsdaten lesen
Hermes darf bestehende Kategorien verwenden, aber keine Kategorien anlegen.
Umsatz- und Artikelmengen werden nicht über Hermes geschrieben, sondern aus den
OTC-Verkäufen abgeleitet.
## 4. Authentifizierung und Autorisierung
- MCP-Endpunkt über geschütztes HTTPS
- Bearer-Token-Prüfung an der Transportgrenze
- der konfigurierte Token identifiziert den technischen Hermes-Benutzerkontext
- zusätzliche Prüfung im jeweiligen Tool-Handler
- kein direkter Datenbankzugriff für Hermes
Für die erste Implementierungsstufe wird bewusst keine neue OAuth-/OIDC- oder
Entitlement-Infrastruktur eingeführt. Der Token wird ausschließlich über die
geschützte App-Konfiguration bereitgestellt und mit konstanter Zeit verglichen.
Die Implementierung verwendet dafür den Konfigurationswert
`MARKET_MCP_TOKEN`.
Die fachliche Ownership der Lizenz-/Entitlement-Daten bleibt offen. Die
Schnittstelle prüft nur die über den bestehenden Zugriffskontext gelieferte
Modulberechtigung.
## 5. Schreibregeln
- Jeder Tool-Aufruf ist atomar.
- Fehlgeschlagene Aufrufe erzeugen keine Teilpersistierung.
- Alle Schreibvorgänge laufen über die interne Modul-API.
- Hermes darf bei einem neuen Markt nur eine bestehende Kategorie referenzieren.
- Dokumentlinks können optional als Herkunftsinformation mitgegeben werden.
- Unsichere Erkennung wird nicht durch eine zusätzliche App-Freigabelogik
behandelt; die fachliche Qualitätssicherung liegt im Hermes-Training.
## 6. Wiederholungsschutz
Schreibende Aufrufe können eine Idempotenzreferenz übergeben. Die App speichert
Client, Benutzer, Tool, Schlüssel, Request-Hash und Verarbeitungsergebnis in
`market_mcp_idempotency`.
Ein wiederholter Aufruf mit gleichem Schlüssel und gleichem Request liefert das
bereits bekannte Ergebnis. Ein gleicher Schlüssel mit verändertem Request wird
abgewiesen.
## 7. Daten- und Modulgrenzen
- Marktmanagement schreibt nur in eigene Tabellen.
- Das OTC-Modul schreibt die Verkaufstabelle und referenziert den
`market_occurrence_day_id`.
- Kontakt- und Organisationsdaten werden über das Kontaktmodul referenziert.
- Gemeindedaten werden über den eigenen Referenzbestand gelesen.
- MCP-Komposition enthält keine Marktfachlogik.
@@ -1,384 +0,0 @@
# Market Management: Normatives Tabellen- und Prozesskonzept
Stand: 2026-09-19
Status: Verbindliches Konzept für das Modul `market_management`
## 1. Übersicht
Tabellen:
- `market_category`
- `market_definition`
- `market_occurrence`
- `market_occurrence_organizer`
- `market_occurrence_day`
- `market_occurrence_cost_item`
- `market_municipality_reference`
- `market_mcp_idempotency`
Bestehende Integrationsreferenz:
- `sales_order.market_occurrence_day_id` im owning OTC-/Direktverkaufsmodul
Prozessbereiche:
- zentrale Marktdefinition verwalten
- Durchführung verwalten
- Durchführungstage verwalten
- Kosten und Zahlungsstatus verwalten
- OTC-Verkäufe einem Durchführungstag zuordnen
- Umsatz- und Artikelmengen lesen und aggregieren
- Marktübersicht lesen
- Hermes-MCP-Zugriff ausführen
- Referenzdaten verwalten
## 2. Zweck und Scope
Dieses Dokument beschreibt das normative technische Zielbild für die Umsetzung
bis einschließlich Punkt 10 des Anforderungsprofils.
Enthalten sind:
- zentrale Marktdefinition mit eindeutigem Namen und genau einer Kategorie
- beliebig viele Durchführungen je Markt
- mindestens ein Durchführungstag je Durchführung
- veranstaltungs-, tages-, kosten- und zahlungsbezogene Daten
- OTC-basierte Umsatz- und Artikelmengenermittlung
- Archivierung von Markt und Durchführung auf getrennten Ebenen
- serverseitige Übersichtsdaten
- Benutzer- und Modulberechtigung
- Hermes als MCP-Client der App
Nicht enthalten sind Punkt 11 und folgende, insbesondere intelligente
Auswertungen, Korrelationen und wirtschaftliche Gemeindedaten.
GUI-Details, konkrete HTTP-Komposition und technische Hermes-Implementierung
werden in den zugehörigen Detaildokumenten beschrieben.
## 3. Normatives Datenmodell
```text
Kategorie / Marktart
└── Marktdefinition
└── Durchführung
└── Durchführungstag / Subdurchführung
└── OTC-Verkäufe
```
### 3.1 `market_category`
Deterministische Kategorie-/Marktartliste.
Normativ:
- Kategorienamen sind eindeutig.
- Fabienne darf die Liste erweitern.
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
- Deaktivierte Kategorien bleiben für historische Zuordnungen erhalten.
### 3.2 `market_definition`
Zentrale fachliche Marktdefinition.
Normativ:
- jeder Markt besitzt genau einen eindeutigen Namen
- jeder Markt besitzt genau eine Kategorie
- jeder Markt kann eine optionale Website-URL der Veranstaltung besitzen
- der Markt enthält keine Durchführungs-, Kontakt-, Kosten-, Zahlungs- oder
Erlösdaten
- Markt und Durchführungen werden getrennt archiviert und reaktiviert
### 3.3 `market_occurrence`
Konkrete Teilnahme bzw. Veranstaltungsausgabe eines Marktes.
Normativ:
- jede Durchführung gehört genau einer Marktdefinition
- jede Durchführung besitzt mindestens einen Durchführungstag
- variable Veranstaltungs-, Kontakt-, Orts-, Logistik-, Kosten- und
Zahlungsdaten gehören zur Durchführung
- der Status gehört zur Durchführung
- Zahlungsstatus und Marktstatus sind unabhängig
- der Zahlungsstatus ist manuell `paid` oder `unpaid`
Minimale Pflichtfelder über alle Statuswerte hinweg:
- `market_definition_id`
- initialer Status `open`
- mindestens ein Durchführungstag
### 3.4 `market_occurrence_organizer`
Veranstalterbeziehung einer Durchführung.
Normativ:
- der Veranstalter ist eine Organisation aus dem Kontaktmodul
- jede Durchführung besitzt höchstens genau einen Veranstalterdatensatz
- der Veranstalterdatensatz referenziert genau eine Kontaktperson
- Kontakt- und Organisationsdaten werden nicht im Marktmanagement dupliziert
### 3.5 `market_occurrence_day`
Einzelner Durchführungstag bzw. Subdurchführung.
Normativ:
- jeder Tag gehört genau einer Durchführung
- das Datum ist je Durchführung eindeutig
- Stand-, Aufbau-, Abbau- und tagesbezogene Anlieferungszeiten gehören hierher
- Ereignisse und Beobachtungen gehören hierher
- OTC-Verkäufe werden eindeutig diesem Datensatz zugeordnet
- Bruttoerlös und Artikelmenge werden aus den zugeordneten OTC-Verkäufen
abgeleitet und nicht manuell gespeichert
Minimale Pflichtfelder:
- `occurrence_id`
- `date`
### Mindestpflichtfelder
Über alle Statuswerte hinweg gelten nur diese Pflichtfelder:
- Marktdefinition: `name`, `category_id`; `website_url` ist optional
- Durchführung: `market_definition_id`, initialer Status `open`, mindestens ein
Durchführungstag
- Durchführungstag: `occurrence_id`, `date`
Alle weiteren Felder bleiben bei der Anlage optional und werden unabhängig vom
Status ergänzt.
### 3.6 `market_occurrence_cost_item`
Einzelne Kostenposition einer Durchführung.
Normativ:
- jede Kostenposition gehört genau einer Durchführung
- CHF ist die einzige Währung
- Bruttobetrag wird erfasst
- steuerfreie Positionen sind zulässig
- Netto- und Steuerbetrag werden aus Brutto und Steuerart/-satz berechnet
- Gesamtkosten sind die Summe der Kostenpositionen und nicht manuell
überschreibbar
### 3.7 `market_municipality_reference`
Technischer Referenzbestand des offiziellen Schweizer Gemeindeverzeichnisses.
Normativ:
- Gemeinden werden über BFS-Gemeindenummern referenziert
- Gemeindename und Kantonszuordnung stammen aus dem Referenzbestand
- nur gültige Gemeinden stehen für neue Eingaben zur Auswahl
- der Datenstand wird versioniert gespeichert
### 3.8 `market_mcp_idempotency`
Technischer Idempotenzbestand für wiederholte Hermes-Schreibaufrufe.
Normativ:
- derselbe Client und Idempotenzschlüssel erzeugen keine doppelte Wirkung
- ein abweichender Request mit demselben Schlüssel wird abgewiesen
- der gespeicherte Verarbeitungsausgang bleibt nachvollziehbar
### 3.9 OTC-Integration
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
referenziert eindeutig einen `market_occurrence_day_id`. Markt, Durchführung,
Datum, Umsatz und Artikelmengen werden daraus ermittelt.
Historische OTC-Verkäufe ohne diese Zuordnung werden nicht migriert.
## 4. Normatives Prozessmodell
Alle fachlichen Prozesse gehören zum owning Modul `market_management`, außer der
OTC-Verkaufsprozess selbst. Die OTC-Integration erfolgt ausschließlich über die
öffentliche Marktmanagement-Schnittstelle.
### 4.1 Zentrale Marktdefinition verwalten
Fachliche Aufgabe:
- Marktdefinition mit eindeutigem Namen und Kategorie anlegen, ändern,
archivieren oder reaktivieren
Liest:
- Kategorien
- bestehende Marktdefinitionen
Schreibt:
- `market_category`, sofern Fabienne eine Kategorie erweitert
- `market_definition`
### 4.2 Durchführung verwalten
Fachliche Aufgabe:
- eine konkrete Durchführung eines Marktes anlegen, bearbeiten, archivieren
oder reaktivieren
Liest:
- Marktdefinition
- Kontaktmodul
- Gemeindereferenz
Schreibt:
- `market_occurrence`
- `market_occurrence_organizer`
### 4.3 Durchführungstage verwalten
Fachliche Aufgabe:
- Durchführungstage anlegen, bearbeiten und entfernen
Liest:
- Durchführung
Schreibt:
- `market_occurrence_day`
### 4.4 Kosten und Zahlungsstatus verwalten
Fachliche Aufgabe:
- Kostenpositionen und manuelle Zahlungsinformationen pflegen
Liest:
- Durchführung
- bestehende Kostenpositionen
Schreibt:
- `market_occurrence_cost_item`
- Zahlungsfelder in `market_occurrence`
### 4.5 OTC-Verkauf einem Durchführungstag zuordnen
Fachliche Aufgabe:
- beim OTC-Verkauf einen gültigen Durchführungstag auswählen und referenzieren
Liest:
- verfügbare Märkte, Durchführungen und Durchführungstage über die
Marktmanagement-Schnittstelle
Schreibt:
- ausschließlich das OTC-Modul in seine eigene Verkaufstabelle
### 4.6 Umsatz- und Artikelmengen lesen
Fachliche Aufgabe:
- Umsatz und Artikelmengen für Tag, Durchführung und Markt aus den OTC-Daten
aggregieren
Liest:
- zugeordnete OTC-Verkäufe und Verkaufspositionen
- Markt-, Durchführungs- und Tagesdaten
Schreibt:
- keine fachlichen Umsatzsummen; die Werte sind abgeleitet
### 4.7 Marktübersicht lesen
Fachliche Aufgabe:
- Märkte, Durchführungen und Tage serverseitig suchen, filtern, sortieren und
aggregiert ausgeben
Liest:
- alle owned Marktmanagement-Daten
- Kontakt- und Gemeindereferenzen
- OTC-Aggregate
Schreibt:
- nichts
### 4.8 Hermes-MCP-Zugriff
Fachliche Aufgabe:
- Hermes über die kontrollierte MCP-Schnittstelle suchen, anlegen, ändern,
archivieren und reaktivieren lassen
Liest:
- Marktmanagement-Daten und erlaubte Referenzdaten
Schreibt:
- ausschließlich über die internen Marktmanagement-Services
- `market_mcp_idempotency` für Wiederholungsschutz
Die konkrete MCP-Schnittstelle ist in
[market_management_hermes_mcp_konzept.md](market_management_hermes_mcp_konzept.md)
dokumentiert.
## 5. Normative Betriebsregeln
- Marktname und Kategorie sind zentrale Stammdaten.
- Alle variablen Veranstaltungsdaten werden je Durchführung gepflegt.
- Tageswerte werden je Durchführungstag gepflegt.
- Wochentag, Saison, Datumsbereich, Gesamtkosten, offener Betrag, Umsatz und
Artikelmengen werden abgeleitet.
- Umsatz und Artikelmengen stammen ausschließlich aus OTC-Verkäufen.
- Stornos und Rückerstattungen werden in den Aggregaten abgezogen.
- Markt- und Durchführungarchivierung sind unabhängig.
- Der erste Status ist `offen`.
- `durchgefuehrt` wird nach dem letzten Durchführungstag automatisch gesetzt,
sofern die Durchführung nicht `abgesagt` ist.
- Die GUI enthält keine fachliche Primärlogik.
- Hermes erhält keinen direkten DB-Zugriff.
- Punkt 11 und folgende sind nicht Teil dieses Zielbilds.
## 6. Ausgabevertrag
Das Modul liefert:
- eindeutige Markt-, Durchführungs- und Tagesidentitäten
- validierte Stammdaten und Referenzen
- unabhängigen Markt- und Zahlungsstatus
- berechnete Kosten-, Umsatz- und Artikelmengenwerte
- serverseitig filter- und sortierbare Übersichtsdaten
- kontrollierte MCP-Ergebnisse für Hermes
## 7. Erfolgskriterien
- Marktdefinitionen sind eindeutig und genau einer Kategorie zugeordnet.
- Durchführungen und Durchführungstage sind unabhängig und vollständig
verwaltbar.
- Jeder neue OTC-Verkauf ist genau einem Durchführungstag zugeordnet.
- Umsatz und Artikelmengen werden nicht manuell doppelt erfasst.
- Kontakt- und Gemeindedaten werden über definierte Referenzen verwendet.
- Archivierung funktioniert getrennt für Markt und Durchführung.
- MCP-Schreibvorgänge sind authentifiziert, berechtigt und idempotent.
- Die fachliche Logik bleibt im owning Modul.
## 8. Noch nicht normative Detailverträge
Die folgenden Dokumente werden erst nach expliziter Prozessdefinition als
normative Verträge erstellt:
- Hauptprozesse unter `processes/main/`
- Sub-Prozesse unter `processes/sub_processes/`
- konkrete API-Schemas der Marktmanagement-Schnittstelle
- GUI- und View-Verträge
@@ -1 +0,0 @@
@@ -1,295 +0,0 @@
# Anforderungsprofil Marktübersicht
Stand: 2026-09-15
Status: Fachliche Arbeitsanforderung
## 1. Ziel des Systems
Das System unterstützt die Suche, Prüfung, Anmeldung, Durchführung und
Auswertung von Herbstmärkten, Weihnachtsmärkten und weiteren geeigneten
Märkten.
## 2. Umfang der ersten Phase
- Es wird jeweils nur ein Markt gleichzeitig bearbeitet.
- Zunächst gibt es nur eine Benutzerin: Fabienne.
- Eine eigene Benutzerverwaltung ist zunächst nicht erforderlich.
- Die Anwendung soll übersichtlich und einfach bedienbar sein.
## 3. Datenhierarchie
Die Daten müssen mehrstufig abbildbar sein:
```text
Kategorie
└── Markt
└── Durchführung
└── Durchführungstag
```
- Es können beliebig viele Kategorien und Märkte existieren.
- Ein Markt gehört genau einer Kategorie.
- Fabienne darf die deterministische Kategorie-/Marktartliste erweitern.
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
- Ein Markt kann beliebig viele Durchführungen enthalten.
- Eine Durchführung kann beliebig viele Durchführungstage enthalten.
- Erlös- und Verkaufsdaten werden auf Ebene des Durchführungstags erfasst und
auf Ebene der Durchführung sowie des Marktes auswertbar gemacht.
Der zentrale Marktstamm enthält ausschließlich:
- Marktname
- Marktart/Kategorie aus einer deterministischen Liste
- optionale Website-URL der Veranstaltung
Alle übrigen veränderlichen Angaben werden pro Durchführung erfasst. Tages-
und Verkaufsdaten werden pro Durchführungstag erfasst.
## 4. Nutzungsberechtigung
- Die Rechte zur Nutzung des Moduls werden an die Benutzeridentität geknüpft.
- Das Modul soll ohne die restliche Anwendung an weitere Personen verkauft und
separat genutzt werden können.
- Die Ownership und Ablage der Lizenz-/Entitlement-Daten ist noch nicht
entschieden und bleibt offen.
## 5. Marktarten
Initial verfügbare Marktarten:
- Herbstmärkte
- Weihnachtsmärkte
- Frühlingsmärkte
- Ostermärkte
- Kunsthandwerksmärkte
- Regionalmärkte
- Weitere geeignete Märkte
Die Marktart/Kategorie wird zentral am Markt aus einer deterministischen Liste
ausgewählt. Eine Durchführung überschreibt diese Zuordnung nicht.
## 6. Markt- und Durchführungsdaten
### 6.1 Allgemeine Informationen
- Veranstalter
- Ort
- Gemeinde
- Kanton
- Adresse
- Kontaktperson
- E-Mail-Adresse
- Telefonnummer
- Notizen
Diese Angaben werden pro Durchführung erfasst. Marktname und Marktart/Kategorie
gehören ausschließlich zur zentralen Marktdefinition.
### 6.2 Termin und Durchführung
- Beginn und Ende des Marktes
- Datum jedes einzelnen Markttages
- Standzeiten pro Tag
- Auf- und Abbauzeiten
- Anmeldefrist
- Datum der Anfrage oder Anmeldung
- Datum der Bestätigung
- Datum der Durchführung
- Wochentag
- Saison
### 6.3 Stand und Infrastruktur
- Standgröße
- Tisch vorhanden
- Tisch kann gemietet werden
- Eigener Tisch erforderlich
- Zelt oder Überdachung vorhanden
- Strom vorhanden
- Wasser vorhanden
- Beleuchtung vorhanden
- Weitere Infrastruktur
- Anforderungen an den eigenen Stand
### 6.4 Anfahrt und Logistik
- Anfahrtsadresse
- Entfernung oder Fahrzeit
- Parkmöglichkeiten
- Zufahrt für Anlieferung
- Zeitfenster für die Anlieferung
- Möglichkeit zum direkten Ausladen
- Lager- oder Aufbewahrungsmöglichkeiten
- Besondere Hinweise zur Anfahrt
### 6.5 Kosten und Konditionen
- Standgebühr
- Gebühren für Tisch, Strom oder weitere Infrastruktur
- Weitere Pflichtkosten
- Gesamtkosten
- Zahlungsfrist
- Zahlungsart
- Abmeldebedingungen
- Umsatzbeteiligung, falls vorhanden
- Weitere Teilnahmebedingungen
### 6.6 Vertrag und Unterlagen
Die zugehörigen Unterlagen sollen über einen Link mit einem eigenen Ordner
verbunden werden können.
- Link zum Vertragsordner
Der Link wird pro Durchführung erfasst.
### 6.7 Deterministische Referenzwerte und Berechnungen
Deterministisch vorgegebene Referenzwerte; die jeweilige Auswahl wird auf der
zuständigen Ebene gespeichert:
- Marktart/Kategorie
- Marktstatus
- Zahlungsstatus
- Zahlungsart
- Kanton
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
Abgeleitete Werte werden nicht manuell eingegeben:
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
- Gesamtkosten aus den Kostenpositionen
- offener Betrag aus Kosten und bezahltem Betrag
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
Durchführungstagen
## 7. Statusverwaltung
Folgende Marktstatus sollen verfügbar sein:
- Offen
- Warteliste
- Angemeldet
- Bestätigung ausstehend
- Bestätigt
- Abgesagt
- Durchgeführt
Der initiale Status ist `Offen`. Die Statuswerte werden grundsätzlich manuell
gesetzt. `Durchgeführt` wird nach dem letzten Durchführungstag automatisch
gesetzt, sofern die Durchführung nicht abgesagt ist. Manuelle Korrektur bleibt
möglich.
## 8. Bezahlung
Der Zahlungsstatus wird unabhängig vom allgemeinen Marktstatus gespeichert.
Zahlungsstatus:
- Nicht bezahlt
- Bezahlt
Zusätzliche Zahlungsinformationen:
- Zahlungsdatum
- Bezahlter Betrag
- Offener Betrag
## 9. Erlöserfassung
Der Erlös wird nicht manuell erfasst, sondern aus den OTC-Verkäufen berechnet.
Bei mehrtägigen Märkten wird der Erlös je Durchführungstag separat ermittelt.
Pro Durchführungstag:
- Datum
- Bruttoerlös aus OTC-Verkäufen
- Anzahl verkaufter Artikel aus OTC-Positionen
- Besondere Ereignisse oder Beobachtungen
Automatisch zu berechnen:
- Gesamterlös des Marktes
- Gesamtzahl verkaufter Artikel
- Durchschnittlicher Erlös pro Tag
- Durchschnittlicher Erlös pro verkauftem Artikel, sofern berechenbar
Die Tageswerte sollen einzeln und als Gesamtergebnis auswertbar sein.
## 10. Marktübersicht
Die zentrale Übersicht soll ermöglichen:
- alle Märkte anzuzeigen
- nach Marktart zu filtern
- nach Status zu filtern
- nach Gemeinde oder Ort zu filtern
- nach Datum zu sortieren
- nach Erlös zu sortieren
- den Bezahlstatus zu erkennen
- kommende Märkte hervorzuheben
- überfällige Anmeldungen oder Zahlungen zu erkennen
- Märkte zu öffnen und zu bearbeiten
Mindestens sichtbar:
- Marktname
- Marktart
- Ort oder Gemeinde
- Datum
- Wochentag
- Status
- Gesamtkosten
- Bezahlstatus
- Bruttoerlös
## 11. Intelligente Auswertungen
Das System soll später Zusammenhänge, Unterschiede und mögliche Korrelationen
zwischen Marktmerkmalen und Ergebnissen sichtbar machen.
### 10.1 Marktart und Erlös
- Welche Marktarten erzielen den höchsten Erlös?
- Sind Weihnachtsmärkte erfolgreicher als Herbstmärkte?
- Welche Marktarten haben das beste Verhältnis zwischen Erlös und Kosten?
- Welche Marktarten führen zu den meisten verkauften Artikeln?
### 10.2 Datum, Saison und Erlös
- An welchen Daten werden die höchsten Erlöse erzielt?
- Gibt es Unterschiede zwischen frühem und spätem Saisonzeitpunkt?
- Wie entwickelt sich der Erlös innerhalb einer Saison?
- Welche Monate und Jahreszeiten sind besonders erfolgreich?
- Gibt es einen Zusammenhang zwischen Marktdatum und Verkaufsleistung?
### 10.3 Zahlungstermin und Markterfolg
- Wie viele Tage liegen zwischen Zahlungstermin und Marktdatum?
- Laufen Märkte besser, wenn der Zahlungstermin früh oder spät vor dem Markt
liegt?
- Gibt es einen Zusammenhang zwischen dem Zeitpunkt der Zahlung und dem
späteren Erlös?
- Gibt es Unterschiede zwischen Märkten vor und nach dem eigenen Zahlungstag?
### 10.4 Gemeinde und wirtschaftliches Umfeld
Der Ort soll später über die Gemeinde mit zusätzlichen wirtschaftlichen Daten
vergleichbar sein.
Zu prüfen ist insbesondere die Hypothese, dass Märkte in Gemeinden mit höherem
durchschnittlichem Einkommen möglicherweise einen höheren Erlös erzielen als
Märkte in Gemeinden mit tieferem Einkommen.
Auswertungen:
- Zusammenhang zwischen Gemeinde und Erlös
- Zusammenhang zwischen Gemeinde-Einkommen und Erlös
- Zusammenhang zwischen Kaufkraft und Anzahl verkaufter Artikel
- Vergleich verschiedener Gemeinden
- Vergleich ähnlicher Märkte in Gemeinden mit unterschiedlichem
Einkommensniveau
Die Quelle, Aktualisierung und fachliche Ownership der zusätzlichen
wirtschaftlichen Gemeindedaten sind noch nicht spezifiziert.
@@ -26,10 +26,6 @@
<button class="sg-interaction-element sg-button sg-button--active" type="button" data-component="button" data-component-state="active">
Link-Button aktiv
</button>
<button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning">
Link Button Warning
</button>
</div>
</section>
@@ -847,28 +843,7 @@
</section>
<script src="../scripts/help-icon-overlays.js"></script>
<section id="component-file-input">
<p class="sg-preview-label">Component: File Input</p>
<div class="sg-form-preview-area">
<div class="sg-file-input" data-component="file-input">
<div class="sg-file-input__row sg-labeled-input-row">
<label class="sg-label" for="component-file-input-control">Datei auswählen</label>
<input class="sg-file-input__control" id="component-file-input-control" type="file" multiple>
<button class="sg-interaction-element sg-button sg-button--active sg-file-input__trigger" type="button">Dateien auswählen</button>
</div>
</div>
</div>
</section>
<script>
document.querySelectorAll('[data-component="file-input"]').forEach((fileInput) => {
const input = fileInput.querySelector('.sg-file-input__control');
const trigger = fileInput.querySelector('.sg-file-input__trigger');
if (input && trigger) {
trigger.addEventListener('click', () => input.click());
}
});
document.querySelectorAll('.sg-mode-toggle:not(.sg-activation-mode-toggle)').forEach((toggle) => {
toggle.addEventListener('click', () => {
const nextState = toggle.dataset.active === 'relative' ? 'absolute' : 'relative';
-1
View File
@@ -51,7 +51,6 @@
<li><a href="./patterns/content-cards-group.html">Content Cards Group</a></li>
<li><a href="./patterns/card-gruppe-mit-tastennavigation.html">Card Gruppe mit Tastennavigation</a></li>
<li><a href="./patterns/formular-mit-abschnitten.html">Formular mit Abschnitten</a></li>
<li><a href="./patterns/file-upload.html">Datei-Upload</a></li>
<li><a href="./patterns/multiselektions-pulldown.html">Multiselektions-Pulldown</a></li>
<li><a href="./patterns/text-layouts.html">Text Layouts</a></li>
<li><a href="./patterns/delete-confirmation-overlay.html">Overlay Card</a></li>
-71
View File
@@ -1,71 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Pattern Datei-Upload</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Pattern – Datei-Upload</h1>
<section id="pattern-file-upload">
<p class="sg-preview-label">Pattern: Datei-Upload</p>
<form class="sg-file-upload" data-pattern="file-upload" action="#" method="post">
<div class="sg-file-upload__dropzone sg-labeled-input-row" data-drag-over="false">
<label class="sg-label" for="file-upload-input">Datei auswählen</label>
<input class="sg-file-input__control" id="file-upload-input" name="documents" type="file" multiple aria-describedby="file-upload-status">
<button class="sg-interaction-element sg-button sg-button--active sg-file-upload__trigger" type="button">Dateien auswählen</button>
</div>
<p class="sg-body sg-file-upload__status" id="file-upload-status" aria-live="polite">Noch keine Dateien ausgewählt.</p>
<ul class="sg-file-upload__files" aria-label="Ausgewählte Dateien"></ul>
<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive sg-file-upload__submit" type="submit" disabled aria-disabled="true">Hochladen</button>
</form>
</section>
<script>
(() => {
const upload = document.querySelector('[data-pattern="file-upload"]');
if (!upload) return;
const input = upload.querySelector('#file-upload-input');
const dropzone = upload.querySelector('.sg-file-upload__dropzone');
const trigger = upload.querySelector('.sg-file-upload__trigger');
const filesList = upload.querySelector('.sg-file-upload__files');
const status = upload.querySelector('.sg-file-upload__status');
const submit = upload.querySelector('[type="submit"]');
let selectedFiles = [];
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
const render = () => {
filesList.innerHTML = '';
selectedFiles.forEach((file, index) => {
const item = document.createElement('li');
item.className = 'sg-file-upload__file';
const name = document.createElement('span');
name.className = 'sg-file-upload__file-name';
name.textContent = file.name;
const meta = document.createElement('span');
meta.className = 'sg-file-upload__file-meta';
meta.textContent = ` · ${formatSize(file.size)}`;
name.append(meta);
const remove = document.createElement('button');
remove.className = 'sg-interaction-element sg-button sg-button--active sg-file-upload__remove';
remove.type = 'button';
remove.textContent = 'Entfernen';
remove.addEventListener('click', () => { selectedFiles.splice(index, 1); render(); });
item.append(name, remove);
filesList.append(item);
});
const count = selectedFiles.length;
status.textContent = count === 0 ? 'Noch keine Dateien ausgewählt.' : `${count} Datei${count === 1 ? '' : 'en'} ausgewählt.`;
submit.disabled = count === 0;
submit.setAttribute('aria-disabled', String(count === 0));
submit.classList.toggle('sg-button--process-inactive', count === 0);
};
trigger.addEventListener('click', () => input.click());
input.addEventListener('change', () => { selectedFiles = [...selectedFiles, ...Array.from(input.files)]; input.value = ''; render(); });
['dragenter', 'dragover'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'true'; }));
['dragleave', 'drop'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'false'; }));
dropzone.addEventListener('drop', (event) => { selectedFiles = [...selectedFiles, ...Array.from(event.dataTransfer.files)]; render(); });
render();
})();
</script>
</body>
</html>
@@ -148,14 +148,6 @@
<tr><td>layout-delete-confirmation-target-dim-opacity</td><td>opacity-delete-confirmation-target-dim</td><td>Deckkraft der grauen Überlagerung auf dem zu löschenden Objekt.</td></tr>
<tr><td>layer-delete-confirmation-overlay</td><td>dimension-layer-delete-confirmation-overlay</td><td>Z-Index-Layer des schwebenden Bestätigungsfensters.</td></tr>
</tbody></table>
<h3 class="sg-sub-heading sg-section-h3">Datei-Upload</h3>
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
<tr><td>surface-file-upload-dropzone</td><td>surface-form-preview</td><td>Hintergrundfläche des auswählbaren Upload-Bereichs.</td></tr>
<tr><td>surface-file-upload-file-item</td><td>surface-control-default</td><td>Hintergrundfläche eines Eintrags in der Dateiliste.</td></tr>
<tr><td>text-file-upload-meta</td><td>text-control-disabled</td><td>Textfarbe für Dateigröße und ergänzende Dateimetadaten.</td></tr>
<tr><td>text-file-upload-error</td><td>color-signal-red</td><td>Textfarbe für Validierungs- und Upload-Fehlermeldungen.</td></tr>
</tbody></table>
</section>
</body>
-1
View File
@@ -2,7 +2,6 @@
@import "./styles/02-base.css";
@import "./styles/03-typography-helpers.css";
@import "./styles/10-components-interactive-elements.css";
@import "./styles/47-patterns-file-upload.css";
@import "./styles/20-patterns-portal-header.css";
@import "./styles/21-patterns-options-row.css";
@import "./styles/22-patterns-object-card.css";
@@ -139,13 +139,6 @@
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -1585,52 +1578,3 @@
color: var(--text-hyperlink);
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
@@ -1,91 +0,0 @@
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
-322
View File
@@ -1,322 +0,0 @@
# Technische Architektur
Stand: 2026-04-08
Status: Verbindlicher Architektur-Master
Dokumentklasse: normative
## 1. Zweck und Geltung
Dieses Dokument ist die verbindliche Architekturvorgabe fuer die Entwicklung hochmodularer Webportale in diesem Repository. Es steuert Analyse, Implementierung, Refactoring und Review durch Mensch und LLM.
Verbindliche Betriebsregel:
- Entwicklung und erste Integration erfolgen lokal auf `feature/<thema>` im lokalen Docker-Stack.
- `main` ist der gemeinsame, auf DEV betriebene Integrationsstand. Ein Feature wird erst nach lokaler Verifikation und expliziter Freigabe nach `main` gemergt.
- PROD wird ausschliesslich durch das bestehende Release-Skript mit einem expliziten Release-Tag eines in DEV getesteten `main`-Commits ausgerollt und nur nach expliziter User-Freigabe angefasst.
Es definiert verbindlich:
- Modul-, Prozess-, Frontend-, API- und Datenbankgrenzen
- Ownership und oeffentliche Schnittstellen
- Standard-Scope fuer Analyse und Implementierung
- Wiederverwendungs- und Redundanzregeln
- Regeln fuer neue Module, Submodule und zentrale Bausteine
Dieses Dokument ist kein Inventar konkreter Tabellen, Prozesse, Dateien oder Runtime-Artefakte. Solche Details liegen in den zustaendigen Modul-, Prozess- oder DB-Vertraegen.
## 2. Architekturprinzipien
### 2.1 Module
Ein Modul ist ein fachlich oder technisch gekapselter Contract, nicht nur eine Verzeichnisstruktur.
Verbindliche Regel:
- jedes Modul besitzt genau eine klar abgegrenzte Primaerverantwortung
- jedes Modul kapselt seine Domain-Logik, Datenzugriffe, externen Schnittstellen, internen Datenstrukturen und Speicherdetails
- jedes Modul definiert eine explizite oeffentliche Schnittstelle fuer andere Module
- jedes Modul besitzt definierte Owned Artefakte und Schreibrechte
- interne Implementierungen anderer Module duerfen nicht direkt genutzt werden
- jede Modulgrenze muss als potenzielle spaetere Service-Grenze behandelbar bleiben
### 2.2 Prozesse
Ein Prozess ist ein sequenzieller Hauptablauf innerhalb genau eines owning Hauptmoduls.
Verbindliche Regel:
- ein Hauptmodul besitzt beliebig viele Hauptprozesse; jeder Hauptprozess gehoert genau einem Hauptmodul
- ein Hauptprozess darf Submodule desselben Hauptmoduls nutzen
- fachliche Hauptprozesse ueber Hauptmodulgrenzen hinweg sind nicht zulaessig
- Batching, Parallelisierung, Retry, Resume, Fehlerklassifikation, Statusermittlung und Ergebnisvertrag gehoeren in den owning Prozessvertrag
- zusaetzliche harte Schreib-, Feld- oder Datenbankschranken sind nur zulaessig, wenn sie explizit im Prozess- oder DB-Vertrag festgelegt sind
Verbindliche Kettenarten:
- asynchrone Prozessketten: ein Startprozess stoesst Folgeprozesse an, diese laufen als eigene Runs weiter
- synchrone Prozessketten: Subprozesse haengen direkt aneinander; jeder Schritt stoesst nur den naechsten an
- der Kettenmodus muss pro Prozessvertrag explizit festgelegt sein
- die Wahl des Kettenmodus erfolgt jeweils nach fachlichem Sinn und Prozessvertrag
Aktive Prozesswahrheit:
`docs/modules/<modul>/<submodul>/processes/`
Verbindliche Regel:
- Prozess- und Sub-Prozess-Dokumente unter diesem Pfad sind die aktive fachliche Wahrheit
- Prozessnomenklatur folgt `component.subcomponent.process_name`
- jeder Hauptprozess und jeder Sub-Prozess besitzt genau einen owning Modulpfad
- `docs/system_components/` darf fuer aktive Prozessreferenzen nicht verwendet werden
### 2.3 Implementierung
Module, Prozesse und Sub-Prozesse sind technisch so scharf wie moeglich zu trennen.
Verbindliche Regel:
- jedes Modul besitzt eine interne API oder Zugriffsschicht
- Cross-Modul-Kommunikation erfolgt nur ueber definierte Modul-Schnittstellen
- direkte Zugriffe auf interne Implementierungen oder Daten anderer Module sind verboten
- neue APIs im geschuetzten Bereich sind standardmaessig `protected`; die Abo-Pruefung greift vor jeder Datenlieferung
- Module, Prozesse und Sub-Prozesse muessen getrennt entwickelbar, testbar, ausrollbar, ueberwachbar, debugbar und optimierbar sein
- technische Kopplung zwischen Prozessen und Sub-Prozessen ist auf das notwendige Minimum zu reduzieren
- Module duerfen keine impliziten Abhaengigkeiten besitzen, die eine spaetere Extraktion verhindern
### 2.4 Frontend
Das Frontend ist bewusst minimalistisch. Ziel ist eine kleine Zahl konsistenter, wiederverwendbarer UI-Bausteine statt vieler seiten- oder feature-spezifischer Sonderkomponenten.
Verbindliche Regel:
- Seiten und Views komponieren Daten, Layouts und Komponenten, erzeugen aber keine Fachlogik
- UI-Komponenten duerfen keine fachliche Primaerlogik, fachliche Dateninterpretation oder eigenstaendige Fachwahrheit besitzen
- fachliche UI-Komponenten, fachliche Darstellungsregeln und fachlich interpretierende UI-Logik gehoeren zum owning Fachmodul
- fachlich neutrale UI-Komponenten, Layout-Komponenten, Formatierungshelfer, Interaktionsmuster und Frontend-Utilities gehoeren nach `shared`
- neue oder geaenderte Frontend-Komponenten muessen zuerst gegen bestehende Komponenten, Patterns und `shared`-Bausteine geprueft werden
- Varianten bestehender Komponenten sind ueber Props, Konfiguration oder dokumentierte Erweiterungspunkte umzusetzen, nicht durch Copy-Paste
- Sortierung, Filterung und Suche werden serverseitig im owning Modul bzw. in der API umgesetzt; das Frontend rendert nur die Ergebnisse
- Karten, Tabellen, Filter, Tabs, Buttons, Statusanzeigen, Ladezustaende, Empty States, Fehlermeldungen und Detailansichten sind als wiederverwendbare Patterns zu behandeln
- seitenlokale Sonderkomponenten sind nur zulaessig, wenn sie nachweislich nicht sinnvoll wiederverwendbar sind
- API-Responses duerfen frontendnah komponiert sein, aber keine Fachlogik aus dem owning Modul in API oder Frontend verschieben
- Seiten mit externen Redirect- oder Callback-Flows muessen Browser-History und Ruecksprungverhalten bewusst behandeln; der verbindliche Detailvertrag liegt zentral unter `docs/modules/shared/ui_foundation/`.
### 2.4.1 Mehrsprachigkeit
Mehrsprachigkeit ist ein verbindlicher Teil der Frontend-Architektur und gilt identisch fuer statische Texte, dynamisch gerenderte Texte und interaktive UI-Elemente.
Verbindliche Regel:
- jeder sichtbare GUI-String muss ueber einen i18n-Key aufgeloest werden, unabhaengig davon, ob er serverseitig, clientseitig oder aus einer API-Komposition gerendert wird
- dazu gehoeren insbesondere Navigationslabels, Tab-Titel, Buttontexte, Menuepunkte, Tooltips, Hilfetexte, Platzhalter, Empty States, Ladezustände, Fehlermeldungen, Bestätigungsdialoge, ARIA-Labels und sonstige Screenreader-Texte
- source-code-Defaults sind nur Fallback- bzw. Seed-Werte; die Anzeige-Wahrheit wird pro Locale aus dem i18n-Store bezogen
- interaktive Elemente muessen sichtbare Beschriftung und funktionalen Wert strikt trennen: der angezeigte Text ist uebersetzbar, der Wert fuer State, Transport, Routing, Filterung und Backend-Verarbeitung bleibt technisch stabil und locale-unabhaengig
- Auswahlwerte, Keys, IDs, Data-Attribute, Option-Keys, Tab-Keys und sonstige Steuerungswerte duerfen nicht aus uebersetzten Labels abgeleitet werden
- Locale-Wechsel darf ausschliesslich die Anzeigeschicht wechseln; Funktion, Auswahlzustand, Navigation, Filterlogik und Datenuebergabe muessen identisch weiter funktionieren
- wenn Text im DOM sichtbar ist, gilt er als GUI-String und unterliegt dieser Regel auch dann, wenn er clientseitig zusammengesetzt wird
- zusammengesetzte UI-Texte duerfen keine fachliche Logik enthalten; sie kombinieren nur locale-gebundene Textbausteine mit stabilen technischen Werten
## 3. Modulmodell
### 3.1 Zulaessige Modulbereiche
Fachliche Hauptmodule:
- `company`
- `ratings`
- `groups`
- `lists`
Technische Modulbereiche:
- `system`
- `shared`
Verbindliche Regel:
- fachliche Hauptmodule besitzen Business-Logik und fachliche Ownership
- `system` enthaelt nur technische Runtime-, Start-, Trigger- und Laufzeitlogik
- `shared` enthaelt nur fachlich neutrale, wiederverwendbare technische Bausteine
- konkrete Submodule, Prozesse, Artefakte und Schnittstellen werden in Modulvertraegen dokumentiert, nicht in diesem Architektur-Master
### 3.2 `shared`
`shared` ist der zentrale technische Querschnittsbereich.
Verbindliche Regel:
- `shared` darf keine Business-Logik, Fachwahrheit oder primaere fachliche Ownership besitzen
- `shared` darf Services, Komponenten, Helper, technische Schnittstellen, UI-nahe und API-nahe Bausteine bereitstellen
- ein Baustein gehoert nur nach `shared`, wenn er fachlich neutral ist und keine Verantwortung eines Fachmoduls uebernimmt
- fachliche Regeln, Dateninterpretation, Statuslogik und Entscheidungen bleiben im owning Fachmodul
- technische Querschnittslogik ohne fachlichen Inhalt muss in `shared` oder einem dort dokumentierten zentralen Baustein liegen, sobald Wiederverwendung ueber mehr als eine Stelle absehbar ist
### 3.3 `admin`
`admin` ist kein Hauptmodul und besitzt keine fachliche Ownership.
Verbindliche Regel:
- Admin-Funktionen sind nur Bedienoberflaechen auf bestehende Hauptmodule
- Admin-spezifische Fachlogik liegt im owning Hauptmodul
- ein separates fachliches Modul `admin` ist nicht zulaessig
### 3.4 `public/api/`
`public/api/` ist die HTTP-Service- und Kompositionsschicht des modularen Monolithen.
Verbindliche Regel:
- `public/api/` besitzt keine fachliche Primaerverantwortung
- `public/api/` darf Modul- oder Submodul-Schnittstellen fuer konkrete Frontend-User-Stories zusammensetzen
- `public/api/` bleibt duenn und enthaelt nur HTTP-nahe und technische Kompositionslogik
- fachliche Ownership darf nicht aus Modulen in `public/api/` wandern
## 4. Datenbankgrenze
Die Plattform nutzt aktuell genau eine gemeinsame Datenbank. Diese gemeinsame Datenbank hebt Modul- und Ownership-Grenzen nicht auf.
Verbindliche Regel:
- jede Tabelle, View, materialisierte View und DB-Funktion besitzt genau eine primaere Ownership
- primaere Ownership folgt genau einem Hauptmodul oder einem technischen Shared-Bereich
- fachliche Beziehungen sowie Ownership- und Berechtigungsschluessel verwenden stabile interne Entitaets-IDs; veraenderliche Attribute wie E-Mail, Username oder Anzeigename duerfen dafuer nicht verwendet werden
- nur das owning Modul definiert Struktur, Write-Logik, Constraints und fachliche Semantik eines DB-Artefakts
- direkte Cross-Modul-Writes in fremde Owned Tabellen sind verboten
- andere Module duerfen fremde Artefakte nur ueber definierte Schnittstellen des owning Moduls nutzen
- physische DB-Splits sind kein aktueller Standard und beduerfen einer expliziten Architekturentscheidung
Operative DB-Referenz:
`docs/database/module_db_ownership.md`
Verbindliche Regel:
- konkrete DB-Artefaktlisten gehoeren in die DB-Ownership-Dokumentation, nicht in diesen Architektur-Master
- bei Analyse, Refactoring und Implementierung wird der DB-Scope zuerst ueber Modul plus Owned DB-Artefakte bestimmt
- fremde DB-Artefakte duerfen nur bei deklarierter Leseabhaengigkeit, FK-Beziehung oder Integrationspruefung in den Scope aufgenommen werden
## 5. Dokumentation und Lesescope
Aktive Dokumentation ist komponentennah.
Verbindliche Regel:
- aktive Modul- und Submodul-Dokumentation liegt unter `docs/modules/<modul>/...`
- aktive technische Shared-Dokumentation liegt unter `docs/modules/shared/...`
- technische Runtime-Dokumentation liegt unter `docs/modules/system/...`
- fachliche Hauptprozesse liegen innerhalb des owning Modulbaums
- historische oder abgeloeste Dokumentation liegt ausserhalb des aktiven Modulbaums
- `docs/technical_architecture.md` bleibt der einzige aktive Architektur-Master
Standard-Lesescope:
- Arbeit an einem Modul: `docs/technical_architecture.md` plus `docs/modules/<modul>/...`
- Arbeit an einem Submodul: `docs/technical_architecture.md` plus `docs/modules/<modul>/<submodul>/...`
- `docs/modules/shared/...`: nur bei betroffener Shared-Schnittstelle oder moeglicher Wiederverwendung
- `docs/modules/system/...`: nur bei Runtime-, Queue-, Scheduler-, Trigger- oder Supervisor-Themen
- andere Module: nur bei explizitem Integrationsbedarf
## 6. Wiederverwendung und Redundanzverbot
Vor jeder Aenderung an App, Frontend, UI-Komponenten, UI-Patterns, API, Prozesslogik, Datenzugriff, Infrastruktur oder Dokumentation ist Wiederverwendung vor Neuerstellung zu pruefen.
Ziel ist eine Architektur ohne redundante Fachlogik, technische Inselloesungen, doppelte UI-Komponenten oder mehrfach gepflegte Varianten desselben Musters.
Verbindliche Regel:
- zuerst bestehende Muster, Komponenten, Services, Modul-Schnittstellen und `shared`-Bausteine pruefen
- vorhandene passende Muster muessen wiederverwendet, erweitert oder als zentrale Schnittstelle bereitgestellt werden
- fachliche Inhalte gehoeren immer in das owning Fachmodul und werden von dort ueber definierte Schnittstellen bereitgestellt
- fachliche Logik darf nicht in `shared`, `public/api/`, `admin`, Skripte oder fachfremde Bereiche ausgelagert oder dupliziert werden
- fachlich neutrale technische Logik gehoert nach `shared`, sofern sie wiederverwendbar ist oder Wiederverwendung absehbar ist
- Frontend-Darstellung, Interaktionslogik, Layout, Formatierung und Zustandsdarstellung sind als bestehende oder neue zentrale UI-Komponenten beziehungsweise UI-Patterns in `shared` zu pruefen
- neue `shared`-Bausteine sind nur zulaessig, wenn sie keine Fachwahrheit enthalten, keine fachliche Primaerverantwortung uebernehmen und wiederverwendbar sind
- technische Querschnittsbausteine duerfen Module entkoppeln, aber keine fachlichen Entscheidungen aus owning Modulen herausziehen
- neue Loesungen sind nur zulaessig, wenn kein passendes bestehendes Muster, keine passende Modul-Schnittstelle und kein geeigneter `shared`-Baustein existiert
- Quickfixes, Workarounds, Copy-Paste-Varianten, seitenlokale UI-Duplikate und isolierte Sonderlogik fuer Einzelstellen sind verboten
- Redundanzvermeidung muss bei Analyse, Umsetzung, Review und Refactoring aktiv nachgewiesen werden koennen
## 7. Verbotene Strukturen
Nicht zulaessig sind:
- Module als reine Ordner ohne Contract
- direkte Cross-Modul-Zugriffe auf DB-Tabellen, interne Implementierungen oder Daten anderer Module
- versteckte oder implizite Modulabhaengigkeiten
- Vermischung mehrerer Primaerverantwortungen innerhalb eines Moduls
- parallele Fachlogik ausserhalb des owning Moduls
- redundante fachliche, technische oder UI-Loesungen, obwohl ein bestehendes Modul, eine Schnittstelle, ein Pattern oder ein `shared`-Baustein erweitert werden koennte
- neue generische Frameworks oder Orchestrator-Umbauten ohne explizite Architekturentscheidung
- implizite Schutz-, Schreib- oder Datenbankschranken ohne Prozess- oder DB-Vertrag
## 8. Erweiterungsregeln
Neue Hauptmodule, Submodule oder zentrale Bausteine duerfen nur eingefuehrt werden, wenn bestehende Strukturen die Verantwortung nicht sauber tragen koennen.
Prueffragen vor jeder Erweiterung:
1. Welches bestehende Hauptmodul ist primaer betroffen?
2. Reicht eine neue Datei, ein neues Submodul oder eine neue Schnittstelle im owning Hauptmodul aus?
3. Handelt es sich um fachlich neutrale Wiederverwendung, die nach `shared` gehoert?
4. Entsteht wirklich eine neue Primaerverantwortung mit eigenen Owned Artefakten und Schreibrechten?
5. Bleibt der Handshake zum Rest des Systems auf minimale Scope-Identifier begrenzt?
6. Reduziert der neue Schnitt Kopplung und Redundanz, statt sie zu erhoehen?
Verbindliche Regel:
- neue Hauptmodule sind nur zulaessig, wenn eine neue fachliche oder technische Primaerverantwortung entsteht, die nicht sauber in `company`, `ratings`, `groups`, `lists`, `system` oder `shared` aufgeht
- neue Submodule sind nur zulaessig, wenn innerhalb eines bestehenden Hauptmoduls eine klar isolierbare Verantwortung mit eigener Schnittstelle entsteht
- neue Prozess- und Sub-Prozess-Dokumente werden im owning Modulpfad angelegt und folgen `component.subcomponent.process_name`
- `admin`, `public/api/`, `scripts/` oder `lib/` begruenden kein neues fachliches Hauptmodul
- Bequemlichkeit, Dateigroesse oder kurzfristiger Umbauaufwand rechtfertigen kein neues Hauptmodul
- vor jeder normativen Architektur- oder Modulerweiterung muss dieses Dokument aktualisiert werden
## 9. Aenderungsregel
Jede strukturelle Aenderung an Hauptmodulen, Modulgrenzen, Prozesszuordnungen, Frontend-Prinzipien, API-Grenzen, DB-Ownership oder primaeren Ownership-Prinzipien muss zuerst in diesem Dokument normativ festgelegt und danach in Detaildokumenten nachgezogen werden.
Verbindlich ist:
- `docs/technical_architecture.md` ist der einzige aktive Architektur-Master des Repositories
- andere Dokumente duerfen diese Architektur erklaeren, anwenden oder historisieren, aber keine konkurrierende Master-Struktur definieren
- konkrete Inventare wie Tabellenlisten, Submodullisten, Prozesslisten oder Runtime-Artefakte werden in Detaildokumenten gepflegt und nicht hier dupliziert
## 10. Projektspezifische Architekturvorgaben
Dieses Kapitel enthaelt Architekturentscheidungen, die fuer dieses Projekt
gelten und nicht Teil der allgemeinen, projektuebergreifend wiederverwendbaren
Modularitaet sind.
### 10.1 Fachliches Hauptmodul `market_management`
Dieses Projekt besitzt zusaetzlich das fachliche Hauptmodul
`market_management`. Seine fachliche Ownership, Datenstruktur und
Schnittstellen werden in den Modulvertraegen unter
`docs/modules/market_management/` dokumentiert.
### 10.2 GUI-/MCP-Paritaet
Die MCP-Schnittstelle ist im Grundsatz funktional paritaetisch zur GUI. Ein
MCP-Nutzer ist hinsichtlich der fachlichen Nutzung einem GUI-Nutzer
gleichzustellen.
Verbindliche Regel:
- jede fachliche Funktion, die technisch ueber das GUI-Interface verfuegbar
ist, muss auch ueber die MCP-Schnittstelle ausfuehrbar sein
- die Paritaet bezieht sich auf die technischen Moeglichkeiten des Interfaces,
nicht nur auf aktuell im GUI dargestellte Funktionen; technisch vorhandene,
im GUI nicht angezeigte Funktionen muessen trotzdem ueber MCP verfuegbar sein
- alle fachlichen Lese-, Anlege-, Aenderungs-, Archivierungs- und
Reaktivierungsmoeglichkeiten des GUI-Interfaces muessen im MCP-Interface
abgebildet sein
- GUI und MCP verwenden dieselben fachlichen Validierungen, Berechtigungen,
Statusregeln und Ownership-Grenzen; fachliche Logik darf nicht doppelt in
GUI und MCP implementiert werden
- jede Erweiterung oder Aenderung eines GUI-Anwendungsfalls erfordert zwingend
die gleichzeitige Pruefung und, falls erforderlich, Anpassung des MCP-Interfaces
- ein MCP-Nutzer darf gegenueber einem gleich berechtigten GUI-Nutzer weder
fachlich eingeschraenkt noch durch abweichende Regeln bevorzugt werden
- die Paritaet betrifft die fachliche Funktion und den Datenzugriff, nicht die
visuelle Darstellung oder den Interaktionsablauf der GUI
@@ -62,32 +62,6 @@ function shopify_delta_reconciliation_cutoff(PDO $pdo): string
return ($pendingDate < $stateDate ? $pendingDate : $stateDate)->format(DATE_ATOM);
}
function shopify_delta_load_pending_order_gids(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT DISTINCT shopify_order_gid
FROM shopify_webhook_event
WHERE status IN ('received', 'processing', 'failed')
AND shopify_order_gid IS NOT NULL
ORDER BY shopify_order_gid"
);
return array_values(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$stmt->fetchAll(PDO::FETCH_COLUMN)
), static fn (string $value): bool => $value !== ''));
}
function shopify_delta_mark_order_events_processed(PDO $pdo, string $orderGid): void
{
$stmt = $pdo->prepare(
"UPDATE shopify_webhook_event
SET status = 'processed', processed_at = NOW(), error_code = NULL, error_message = NULL
WHERE shopify_order_gid = :shopify_order_gid
AND status IN ('received', 'processing', 'failed')"
);
$stmt->execute([':shopify_order_gid' => $orderGid]);
}
function shopify_delta_store_watermark(PDO $pdo, string $cutoff): void
{
$stmt = $pdo->prepare(
@@ -146,7 +120,7 @@ GQL;
return $orders;
}
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, array $recoveryOrderGids = []): array
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun): array
{
$cutoffDate = new DateTimeImmutable($cutoff);
$searchQuery = 'created_at:>' . $cutoffDate->format('Y-m-d');
@@ -161,31 +135,14 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, a
'clarification_order_gids' => [],
];
$ordersByGid = [];
foreach (shopify_delta_load_orders($env, $searchQuery) as $summaryOrder) {
$orderGid = trim((string) ($summaryOrder['id'] ?? ''));
if ($orderGid !== '') {
$ordersByGid[$orderGid] = $summaryOrder;
}
}
foreach ($recoveryOrderGids as $recoveryOrderGid) {
if (!isset($ordersByGid[$recoveryOrderGid])) {
$ordersByGid[$recoveryOrderGid] = shopify_load_order_by_gid($env, $recoveryOrderGid);
}
}
$recoverySet = array_fill_keys($recoveryOrderGids, true);
foreach ($ordersByGid as $orderGid => $summaryOrder) {
$createdAt = new DateTimeImmutable((string) ($summaryOrder['createdAt'] ?? ''));
if (!isset($recoverySet[$orderGid]) && $createdAt <= $cutoffDate) {
if ($createdAt <= $cutoffDate) {
continue;
}
$counters['checked_orders']++;
if (($summaryOrder['cancelledAt'] ?? null) !== null || strtoupper((string) ($summaryOrder['displayFinancialStatus'] ?? '')) === 'REFUNDED') {
$counters['skipped_cancelled_or_refunded']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
continue;
}
@@ -209,14 +166,8 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, a
}
if (($result['status'] ?? '') === 'already_imported') {
$counters['already_imported']++;
if (!$dryRun && isset($recoverySet[(string) $summaryOrder['id']])) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
} else {
$counters['handed_off']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
}
} catch (Throwable $error) {
$counters['status'] = 'partial_success';
@@ -132,21 +132,19 @@ function shopify_webhook_worker_run(PDO $pdo, array $env, int $limit): array
}
}
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$argvLimit = 20;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--limit=')) {
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
}
}
try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
$argvLimit = 20;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--limit=')) {
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
}
}
try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
}
+15 -94
View File
@@ -62,57 +62,16 @@ try {
$adjustmentQty,
$outQty
);
$productId = (int) $updatedLot['product_id'];
if (($updatedLot['status'] ?? '') === 'open') {
sync_open_lot_activation_selection($pdo, $productId);
$updatedLot['next_active'] = is_open_lot_selected_for_activation($pdo, (int) $updatedLot['id']);
} else {
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
'Verrechnung nach Pflege der aktiven Charge'
);
}
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$forecast = get_lager_product_forecast($pdo, $productId);
$currentLot = get_current_lot_snapshot($pdo, $productId);
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $currentLot, 'balance' => [
'qty_net' => $currentLot['qty_net'],
'qty_in' => $currentLot['qty_in'],
'qty_out' => $currentLot['qty_out'],
], 'forecast' => $forecast]);
}
if ($action === 'delete_open_lot') {
$productId = delete_open_stock_lot(
$pdo,
(int) ($data['lotId'] ?? 0),
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown')
);
sync_open_lot_activation_selection($pdo, $productId);
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
'Verrechnung nach Loeschung einer offenen Charge'
);
json_response(200, ['ok' => true, 'productId' => $productId, 'currentLot' => get_current_lot_snapshot($pdo, $productId), 'availableQty' => get_lager_product_available_qty($pdo, $productId)]);
json_response(200, ['ok' => true, 'lot' => $updatedLot, 'balance' => get_stock_lot_balance($pdo, (int) ($data['lotId'] ?? 0))]);
}
if ($action === 'prepare_lot') {
$productId = (int) ($data['productId'] ?? 0);
$lotNumber = trim((string) ($data['lotNumber'] ?? ''));
if ($lotNumber === '') {
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
$lotId = (int) ($data['lotId'] ?? 0);
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId;
$manufacturerLotNumber = trim((string) ($data['manufacturerLotNumber'] ?? ''));
$mfgDate = trim((string) ($data['mfgDate'] ?? ''));
$expiryDate = trim((string) ($data['expiryDate'] ?? ''));
$purchaseDate = trim((string) ($data['purchaseDate'] ?? ''));
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
$targetQty = isset($data['targetQty']) && $data['targetQty'] !== '' ? (float) $data['targetQty'] : null;
$pdo->beginTransaction();
if ($lotId > 0) {
$lotStmt = $pdo->prepare(
"SELECT id
@@ -126,50 +85,26 @@ try {
throw new InvalidArgumentException('Offene Charge nicht gefunden');
}
} else {
$lotId = create_open_lot($pdo, $productId);
$lotId = get_or_create_open_lot($pdo, $productId);
}
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId;
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
$preparedLot = update_stock_lot_traceability(
$pdo,
$lotId,
$manufacturerPartyId,
$manufacturerLotNumber,
$mfgDate,
$expiryDate,
$purchaseDate,
isset($data['manufacturerLotNumber']) ? (string) $data['manufacturerLotNumber'] : null,
isset($data['mfgDate']) ? (string) $data['mfgDate'] : null,
isset($data['expiryDate']) ? (string) $data['expiryDate'] : null,
isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
$receiptQty,
0.0,
0.0,
$lotNumber,
$targetQty,
$lotNumber === ''
$lotNumber
);
sync_open_lot_activation_selection($pdo, $productId);
if (promote_open_lot_when_virtual_shortage_is_current($pdo, $productId, $lotId, (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'))) {
$preparedLot['status'] = 'current';
$preparedLot['next_active'] = false;
} else {
$preparedLot['next_active'] = is_open_lot_selected_for_activation($pdo, $lotId);
}
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$forecast = get_lager_product_forecast($pdo, $productId);
$pdo->commit();
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $preparedLot, 'balance' => get_stock_lot_balance($pdo, $lotId), 'availableQty' => get_lager_product_available_qty($pdo, $productId), 'forecast' => $forecast]);
}
if ($action === 'select_open_lot') {
$productId = (int) ($data['productId'] ?? 0);
$lotId = (int) ($data['lotId'] ?? 0);
select_open_lot_for_activation($pdo, $productId, $lotId);
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
'Verrechnung nach Auswahl der naechsten aktiven Charge'
);
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$currentLot = get_current_lot_snapshot($pdo, $productId);
json_response(200, ['ok' => true, 'productId' => $productId, 'selectedLotId' => $lotId, 'currentLot' => $currentLot, 'forecast' => get_lager_product_forecast($pdo, $productId)]);
json_response(200, ['ok' => true, 'lot' => $preparedLot, 'balance' => get_stock_lot_balance($pdo, $lotId)]);
}
json_response(422, ['ok' => false, 'error' => 'Unsupported action']);
@@ -178,19 +113,5 @@ try {
} catch (InvalidArgumentException $error) {
json_response(422, ['ok' => false, 'error' => $error->getMessage()]);
} catch (Throwable $error) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
try {
system_write_audit_log($pdo, 'lager_api', 'lager-charge.php', 'request_failed', $currentUser['username'] ?? $currentUser['email'] ?? 'unknown', [], [], [
'action' => $action ?? '',
'product_id' => $data['productId'] ?? null,
'lot_id' => $data['lotId'] ?? null,
'exception_class' => get_class($error),
'exception_message' => $error->getMessage(),
]);
} catch (Throwable) {
error_log('lager-charge API failure audit logging failed');
}
json_response(500, ['ok' => false, 'error' => 'Interner Serverfehler']);
json_response(500, ['ok' => false, 'error' => 'Internal server error: ' . $error->getMessage()]);
}
+7 -68
View File
@@ -35,25 +35,10 @@ function lager_forecast_load_current_lots(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
COALESCE(v.qty_net, 0) AS physical_qty,
next_sl.id AS next_lot_id,
COALESCE(next_v.qty_net, 0) AS next_physical_qty
COALESCE(v.qty_net, 0) AS physical_qty
FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
LEFT JOIN stock_lot next_sl
ON next_sl.product_id = p.id
AND next_sl.status = 'open'
AND next_sl.next_active = TRUE
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
AND next_sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
AND next_sl.mfg_date IS NOT NULL
AND next_sl.expiry_date IS NOT NULL
AND next_sl.purchase_date IS NOT NULL
LEFT JOIN v_stock_lot_balance next_v
ON next_v.stock_lot_id = next_sl.id
AND next_v.qty_net > 0
WHERE p.status = 'active'
ORDER BY p.id"
);
@@ -64,25 +49,10 @@ function lager_forecast_load_current_lot(PDO $pdo, int $productId): ?array
{
$stmt = $pdo->prepare(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
COALESCE(v.qty_net, 0) AS physical_qty,
next_sl.id AS next_lot_id,
COALESCE(next_v.qty_net, 0) AS next_physical_qty
COALESCE(v.qty_net, 0) AS physical_qty
FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
LEFT JOIN stock_lot next_sl
ON next_sl.product_id = p.id
AND next_sl.status = 'open'
AND next_sl.next_active = TRUE
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
AND next_sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
AND next_sl.mfg_date IS NOT NULL
AND next_sl.expiry_date IS NOT NULL
AND next_sl.purchase_date IS NOT NULL
LEFT JOIN v_stock_lot_balance next_v
ON next_v.stock_lot_id = next_sl.id
AND next_v.qty_net > 0
WHERE p.status = 'active' AND p.id = :product_id
LIMIT 1"
);
@@ -124,22 +94,15 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
return $sales;
}
function lager_forecast_calculate(
DateTimeImmutable $asOfDate,
float $physicalQty,
array $dailySales,
?DateTimeImmutable $forecastStartDate = null
): array
function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQty, array $dailySales): array
{
$firstSaleDate = empty($dailySales) ? null : min(array_keys($dailySales));
$lookbackDays = $firstSaleDate === null
? 0
: $asOfDate->diff(new DateTimeImmutable($firstSaleDate))->days + 1;
$lookbackDays = 365;
$halfLifeDays = 60;
$weightedUnits = 0.0;
$weightTotal = 0.0;
$salesUnits = 0.0;
$salesDays = 0;
$firstSaleDate = null;
$lastSaleDate = null;
for ($age = 0; $age < $lookbackDays; $age++) {
@@ -152,6 +115,7 @@ function lager_forecast_calculate(
if ($units > 0) {
$salesUnits += $units;
$salesDays++;
$firstSaleDate = $dateKey;
$lastSaleDate ??= $dateKey;
}
}
@@ -182,9 +146,8 @@ function lager_forecast_calculate(
? 'high'
: ($salesDays >= 10 ? 'medium' : 'low');
$daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand);
$forecastStartDate ??= $asOfDate;
return [
'forecast_date' => $forecastStartDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
'forecast_date' => $asOfDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
'forecast_status' => 'forecast', 'confidence' => $confidence, 'daily_demand' => $dailyDemand,
'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays,
'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays,
@@ -262,18 +225,6 @@ function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId):
$dailySales[$productId] ?? []
);
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
if ($lot['next_lot_id'] !== null && $forecast['forecast_date'] !== null) {
lager_forecast_persist($pdo, [
'lot_id' => $lot['next_lot_id'],
'product_id' => $productId,
'physical_qty' => $lot['next_physical_qty'],
], lager_forecast_calculate(
$asOfDate,
(float) $lot['next_physical_qty'],
$dailySales[$productId] ?? [],
new DateTimeImmutable($forecast['forecast_date'])
), $asOfDate);
}
}
function run_lager_stock_lot_forecast(PDO $pdo): array
@@ -299,18 +250,6 @@ function run_lager_stock_lot_forecast(PDO $pdo): array
try {
$forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []);
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
if ($lot['next_lot_id'] !== null && $forecast['forecast_date'] !== null) {
lager_forecast_persist($pdo, [
'lot_id' => $lot['next_lot_id'],
'product_id' => $productId,
'physical_qty' => $lot['next_physical_qty'],
], lager_forecast_calculate(
$asOfDate,
(float) $lot['next_physical_qty'],
$dailySales[$productId] ?? [],
new DateTimeImmutable($forecast['forecast_date'])
), $asOfDate);
}
$result['processed_products']++;
} catch (Throwable $error) {
$result['failed_products']++;
+33 -768
View File
@@ -75,8 +75,7 @@ function get_lager_product_overview(PDO $pdo): array
p.name,
p.sku,
p.uom,
COALESCE(v.qty_net, 0) AS available_qty,
COALESCE(v.qty_net, 0) AS current_qty,
COALESCE(SUM(v.qty_net), 0) AS available_qty,
COALESCE(v.qty_in, 0) AS qty_in,
sl.lot_number,
sl.id AS lot_id,
@@ -88,11 +87,6 @@ function get_lager_product_overview(PDO $pdo): array
sl.purchase_date,
sl.sellout_date,
sl.sellout_forecast_status,
next_sl.id AS next_lot_id,
COALESCE(next_v.qty_net, 0) AS next_qty,
next_sl.sellout_date AS next_sellout_date,
next_sl.sellout_forecast_status AS next_sellout_forecast_status,
next_sl.expiry_date AS next_expiry_date,
COALESCE((
SELECT SUM(a.qty)
FROM sales_order_line_lot_allocation a
@@ -116,25 +110,10 @@ function get_lager_product_overview(PDO $pdo): array
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v
ON v.stock_lot_id = sl.id
LEFT JOIN stock_lot next_sl
ON next_sl.product_id = p.id
AND next_sl.status = 'open'
AND next_sl.next_active = TRUE
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
AND next_sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
AND next_sl.mfg_date IS NOT NULL
AND next_sl.expiry_date IS NOT NULL
AND next_sl.purchase_date IS NOT NULL
LEFT JOIN v_stock_lot_balance next_v
ON next_v.stock_lot_id = next_sl.id
AND next_v.qty_net > 0
WHERE p.status = 'active'
GROUP BY p.id, p.name, p.sku, p.uom, sl.id, sl.lot_number, sl.manufacturer_party_id,
sl.manufacturer_lot_number, mp.name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
sl.sellout_date, sl.sellout_forecast_status, v.qty_net, v.qty_in,
next_sl.id, next_v.qty_net, next_sl.sellout_date,
next_sl.sellout_forecast_status, next_sl.expiry_date
sl.sellout_date, sl.sellout_forecast_status, v.qty_in
ORDER BY p.name ASC, p.id ASC"
);
@@ -145,7 +124,6 @@ function get_lager_product_overview(PDO $pdo): array
'sku' => (string) $row['sku'],
'uom' => (string) $row['uom'],
'available_qty' => (float) $row['available_qty'],
'current_qty' => (float) $row['current_qty'],
'qty_in' => (float) $row['qty_in'],
'lot_id' => $row['lot_id'] === null ? null : (int) $row['lot_id'],
'lot_number' => $row['lot_number'],
@@ -157,11 +135,6 @@ function get_lager_product_overview(PDO $pdo): array
'purchase_date' => $row['purchase_date'],
'sellout_date' => $row['sellout_date'],
'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'),
'next_lot_id' => $row['next_lot_id'] === null ? null : (int) $row['next_lot_id'],
'next_qty' => (float) ($row['next_qty'] ?? 0),
'next_sellout_date' => $row['next_sellout_date'],
'next_sellout_forecast_status' => (string) ($row['next_sellout_forecast_status'] ?? 'no_demand'),
'next_expiry_date' => $row['next_expiry_date'],
'virtual_shortage_qty' => (float) ($row['virtual_shortage_qty'] ?? 0),
'virtual_lot_number' => $row['virtual_lot_number'],
],
@@ -224,7 +197,6 @@ function get_lager_product_overview(PDO $pdo): array
sl.mfg_date,
sl.expiry_date,
sl.purchase_date,
sl.next_active,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_net, 0) AS qty_net
FROM stock_lot sl
@@ -243,10 +215,9 @@ function get_lager_product_overview(PDO $pdo): array
'manufacturer_name' => $openLot['manufacturer_name'],
'mfg_date' => $openLot['mfg_date'],
'expiry_date' => $openLot['expiry_date'],
'purchase_date' => $openLot['purchase_date'],
'qty_in' => (float) $openLot['qty_net'],
'qty_net' => (float) $openLot['qty_net'],
'next_active' => (bool) $openLot['next_active'],
'purchase_date' => $openLot['purchase_date'],
'qty_in' => (float) $openLot['qty_in'],
'qty_net' => (float) $openLot['qty_net'],
];
}
@@ -259,71 +230,6 @@ function get_lager_product_overview(PDO $pdo): array
return $products;
}
function get_lager_product_available_qty(PDO $pdo, int $productId): float
{
$stmt = $pdo->prepare(
"SELECT COALESCE(SUM(v.qty_net), 0)
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status IN ('current', 'open')"
);
$stmt->execute([':product_id' => $productId]);
return (float) $stmt->fetchColumn();
}
function get_lager_product_forecast(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT stock_lot.sellout_date, stock_lot.sellout_forecast_status, stock_lot.expiry_date,
next_lot.sellout_date AS next_sellout_date,
next_lot.sellout_forecast_status AS next_sellout_forecast_status,
next_lot.expiry_date AS next_expiry_date
FROM stock_lot
LEFT JOIN LATERAL (
SELECT sl.sellout_date, sl.sellout_forecast_status, sl.expiry_date
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = stock_lot.product_id
AND sl.status = 'open'
AND sl.next_active = TRUE
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
LIMIT 1
) next_lot ON TRUE
WHERE stock_lot.product_id = :product_id
AND stock_lot.status = 'current'
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$forecast = $stmt->fetch();
if (!is_array($forecast)) {
throw new RuntimeException('Keine aktuelle Charge fuer Forecast vorhanden');
}
return [
'date' => $forecast['sellout_date'],
'status' => (string) $forecast['sellout_forecast_status'],
'expiryDate' => $forecast['expiry_date'],
'current' => [
'date' => $forecast['sellout_date'],
'status' => (string) $forecast['sellout_forecast_status'],
'expiryDate' => $forecast['expiry_date'],
],
'next' => $forecast['next_sellout_date'] === null && $forecast['next_expiry_date'] === null
? null
: [
'date' => $forecast['next_sellout_date'],
'status' => (string) ($forecast['next_sellout_forecast_status'] ?? 'no_demand'),
'expiryDate' => $forecast['next_expiry_date'],
],
];
}
function update_stock_lot_traceability(
PDO $pdo,
int $lotId,
@@ -336,9 +242,7 @@ function update_stock_lot_traceability(
float $receiptQty = 0.0,
float $adjustmentQty = 0.0,
float $outQty = 0.0,
?string $lotNumber = null,
?float $targetQty = null,
bool $clearLotNumber = false
?string $lotNumber = null
): array {
if ($lotId <= 0) {
throw new InvalidArgumentException('Chargen-ID fehlt');
@@ -349,19 +253,13 @@ function update_stock_lot_traceability(
$manufacturerLotNumber = null;
}
$lotNumber = $lotNumber !== null ? trim($lotNumber) : null;
if ($lotNumber === '' && !$clearLotNumber) {
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
if ($lotNumber === '') {
$lotNumber = null;
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
if (!is_finite($receiptQty) || !is_finite($adjustmentQty) || !is_finite($outQty)) {
throw new InvalidArgumentException('Ungueltige Bewegungsmenge');
}
if ($targetQty !== null && (!is_finite($targetQty) || $targetQty < 0)) {
throw new InvalidArgumentException('Ungueltige Chargenmenge');
}
if ($receiptQty < 0 || $outQty < 0) {
throw new InvalidArgumentException('Zugang und Abgang duerfen nicht negativ sein');
}
@@ -409,9 +307,6 @@ function update_stock_lot_traceability(
if ((string) $before['status'] === 'virtual_shortage') {
throw new InvalidArgumentException('Virtuelle Fehlbestandschargen koennen nicht gepflegt werden');
}
if ($targetQty !== null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen mit einer Zielmenge bearbeitet werden');
}
if ($lotNumber !== null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen neu angelegt werden');
}
@@ -436,35 +331,13 @@ function update_stock_lot_traceability(
if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Hersteller-Chargennummer fehlt');
}
if ($targetQty !== null) {
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(SUM(CASE
WHEN move_type = 'in' THEN qty
WHEN move_type = 'out' THEN -qty
WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty
WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty
ELSE 0
END), 0)
FROM stock_move
WHERE lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => $lotId]);
$quantityDelta = $targetQty - (float) $balanceStmt->fetchColumn();
$receiptQty = $quantityDelta > 0 ? $quantityDelta : 0.0;
$outQty = 0.0;
$adjustmentQty = $quantityDelta < 0 ? $quantityDelta : 0.0;
}
if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current' && $targetQty === null) {
if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current') {
throw new InvalidArgumentException('Korrekturen und Abgaenge koennen nur auf der aktiven Charge gebucht werden');
}
$lotNumberAssignment = $clearLotNumber
? 'NULL'
: ($lotNumber === null ? 'lot_number' : 'COALESCE(:lot_number, lot_number)');
$updateStmt = $pdo->prepare(
"UPDATE stock_lot
SET lot_number = {$lotNumberAssignment},
SET lot_number = COALESCE(:lot_number, lot_number),
manufacturer_party_id = :manufacturer_party_id,
manufacturer_lot_number = :manufacturer_lot_number,
mfg_date = :mfg_date,
@@ -473,23 +346,18 @@ function update_stock_lot_traceability(
updated_at = NOW()
WHERE id = :id"
);
$updateParams = [
$updateStmt->execute([
':lot_number' => $lotNumber,
':manufacturer_party_id' => $manufacturerPartyId,
':manufacturer_lot_number' => $manufacturerLotNumber,
':mfg_date' => $dates['mfg_date'],
':expiry_date' => $dates['expiry_date'],
':purchase_date' => $dates['purchase_date'],
':id' => $lotId,
];
if (!$clearLotNumber && $lotNumber !== null) {
$updateParams[':lot_number'] = $lotNumber;
}
$updateStmt->execute($updateParams);
]);
$after = $before;
if ($clearLotNumber) {
$after['lot_number'] = null;
} elseif ($lotNumber !== null) {
if ($lotNumber !== null) {
$after['lot_number'] = $lotNumber;
}
$after['manufacturer_party_id'] = $manufacturerPartyId;
@@ -502,7 +370,7 @@ function update_stock_lot_traceability(
]);
if ($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) {
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty, $changedBy);
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty);
}
if ($startedTransaction) {
@@ -518,506 +386,6 @@ function update_stock_lot_traceability(
}
}
function is_qualified_open_lot(PDO $pdo, int $lotId): bool
{
$stmt = $pdo->prepare(
"SELECT EXISTS (
SELECT 1
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.id = :lot_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
)"
);
$stmt->execute([':lot_id' => $lotId]);
return (bool) $stmt->fetchColumn();
}
function is_open_lot_selected_for_activation(PDO $pdo, int $lotId): bool
{
$stmt = $pdo->prepare(
"SELECT next_active
FROM stock_lot
WHERE id = :lot_id
AND status = 'open'"
);
$stmt->execute([':lot_id' => $lotId]);
return (bool) $stmt->fetchColumn();
}
function sync_open_lot_activation_selection(PDO $pdo, int $productId): void
{
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lockStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
FOR UPDATE"
);
$lockStmt->execute([':product_id' => $productId]);
$qualifiedStmt = $pdo->prepare(
"SELECT sl.id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
ORDER BY sl.id"
);
$qualifiedStmt->execute([':product_id' => $productId]);
$qualifiedIds = array_map('intval', $qualifiedStmt->fetchAll(PDO::FETCH_COLUMN));
if (count($qualifiedIds) === 1) {
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
$selectStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = TRUE, updated_at = NOW()
WHERE id = :lot_id"
);
$selectStmt->execute([':lot_id' => $qualifiedIds[0]]);
} elseif ($qualifiedIds === []) {
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
} else {
$selectedStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
AND next_active = TRUE
LIMIT 1
FOR UPDATE"
);
$selectedStmt->execute([':product_id' => $productId]);
$selectedId = $selectedStmt->fetchColumn();
if ($selectedId === false || !in_array((int) $selectedId, $qualifiedIds, true)) {
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
}
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function promote_open_lot_when_virtual_shortage_is_current(PDO $pdo, int $productId, int $lotId, string $changedBy): bool
{
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$realCurrentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'current'
LIMIT 1
FOR UPDATE"
);
$realCurrentStmt->execute([':product_id' => $productId]);
$realCurrent = $realCurrentStmt->fetch();
if (is_array($realCurrent)) {
$realCurrentBalanceStmt = $pdo->prepare(
"SELECT COALESCE(qty_net, 0)
FROM v_stock_lot_balance
WHERE stock_lot_id = :lot_id"
);
$realCurrentBalanceStmt->execute([':lot_id' => (int) $realCurrent['id']]);
$realCurrentQty = (float) ($realCurrentBalanceStmt->fetchColumn() ?: 0);
} else {
$realCurrentQty = 0.0;
}
if ($realCurrentQty > 0.0000001) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
$currentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'virtual_shortage'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$currentStmt->execute([':product_id' => $productId]);
$virtualShortageLotId = $currentStmt->fetchColumn();
if (!is_qualified_open_lot($pdo, $lotId)) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
$lotStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE id = :lot_id
AND product_id = :product_id
AND status = 'open'
FOR UPDATE"
);
$lotStmt->execute([':lot_id' => $lotId, ':product_id' => $productId]);
if ($lotStmt->fetchColumn() === false) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
if (is_array($realCurrent)) {
$closeStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'closed', updated_at = NOW()
WHERE id = :lot_id"
);
$closeStmt->execute([':lot_id' => (int) $realCurrent['id']]);
}
$promoteStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'current', next_active = FALSE, updated_at = NOW()
WHERE id = :lot_id"
);
$promoteStmt->execute([':lot_id' => $lotId]);
if ($virtualShortageLotId !== false) {
reconcile_virtual_shortage_allocations(
$pdo,
(int) $virtualShortageLotId,
$lotId,
$changedBy,
'Automatische Verrechnung bei Aktivierung einer echten Charge'
);
}
if ($startedTransaction) {
$pdo->commit();
}
return true;
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function reconcile_virtual_shortage_allocations(
PDO $pdo,
int $virtualLotId,
int $realLotId,
string $changedBy,
string $reason
): void {
$allocationStmt = $pdo->prepare(
"SELECT id, qty
FROM sales_order_line_lot_allocation
WHERE lot_id = :lot_id
AND allocation_status = 'reserved'
ORDER BY id
FOR UPDATE"
);
$allocationStmt->execute([':lot_id' => $virtualLotId]);
$allocations = $allocationStmt->fetchAll();
foreach ($allocations as $allocation) {
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(qty_net, 0)
FROM v_stock_lot_balance
WHERE stock_lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => $realLotId]);
$availableQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($availableQty <= 0.0000001) {
break;
}
$replaceQty = min((float) $allocation['qty'], $availableQty);
replace_virtual_lot_allocation(
$pdo,
(int) $allocation['id'],
$realLotId,
$replaceQty,
$changedBy,
$reason
);
}
$remainingStmt = $pdo->prepare(
"SELECT COUNT(*)
FROM sales_order_line_lot_allocation
WHERE lot_id = :lot_id
AND allocation_status = 'reserved'"
);
$remainingStmt->execute([':lot_id' => $virtualLotId]);
if ((int) $remainingStmt->fetchColumn() !== 0) {
return;
}
$closeStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'closed', next_active = FALSE, updated_at = NOW()
WHERE id = :lot_id
AND status = 'virtual_shortage'"
);
$closeStmt->execute([':lot_id' => $virtualLotId]);
if ($closeStmt->rowCount() !== 1) {
throw new RuntimeException('Virtuelle Fehlbestandscharge konnte nicht aufgeloest werden');
}
system_write_audit_log($pdo, 'stock_lot', (string) $virtualLotId, 'closed', $changedBy, [
'status' => 'virtual_shortage',
], [
'status' => 'closed',
], [
'module' => 'erp.lager',
'reason' => $reason,
]);
}
function ensure_current_lot_and_reconcile_virtual_shortage(
PDO $pdo,
int $productId,
string $changedBy,
string $reason
): void {
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$currentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'current'
LIMIT 1
FOR UPDATE"
);
$currentStmt->execute([':product_id' => $productId]);
$currentLotId = $currentStmt->fetchColumn();
if ($currentLotId === false) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(qty_net, 0)
FROM v_stock_lot_balance
WHERE stock_lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => (int) $currentLotId]);
$currentQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($currentQty <= 0.0000001) {
sync_open_lot_activation_selection($pdo, $productId);
$selectedStmt = $pdo->prepare(
"SELECT sl.id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND sl.next_active = TRUE
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
LIMIT 1"
);
$selectedStmt->execute([':product_id' => $productId]);
$nextLotId = $selectedStmt->fetchColumn();
if ($nextLotId === false) {
$countStmt = $pdo->prepare(
"SELECT COUNT(*), MIN(sl.id)
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0"
);
$countStmt->execute([':product_id' => $productId]);
$candidate = $countStmt->fetch(PDO::FETCH_NUM);
if (is_array($candidate) && (int) $candidate[0] === 1) {
$nextLotId = $candidate[1];
}
}
if ($nextLotId !== false && $nextLotId !== null) {
promote_open_lot_when_virtual_shortage_is_current(
$pdo,
$productId,
(int) $nextLotId,
$changedBy
);
}
}
for ($iteration = 0; $iteration < 100; $iteration++) {
$currentStmt->execute([':product_id' => $productId]);
$currentLotId = $currentStmt->fetchColumn();
if ($currentLotId === false) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
$balanceStmt->execute([':lot_id' => (int) $currentLotId]);
$currentQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($currentQty <= 0.0000001) {
break;
}
$virtualStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'virtual_shortage'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$virtualStmt->execute([':product_id' => $productId]);
$virtualLotId = $virtualStmt->fetchColumn();
if ($virtualLotId === false) {
break;
}
reconcile_virtual_shortage_allocations(
$pdo,
(int) $virtualLotId,
(int) $currentLotId,
$changedBy,
$reason
);
$remainingStmt = $pdo->prepare(
"SELECT COUNT(*)
FROM sales_order_line_lot_allocation
WHERE lot_id = :lot_id
AND allocation_status = 'reserved'"
);
$remainingStmt->execute([':lot_id' => (int) $virtualLotId]);
if ((int) $remainingStmt->fetchColumn() === 0) {
break;
}
}
if ($iteration >= 100) {
throw new RuntimeException("Virtuelle Fehlbestandscharge fuer Produkt {$productId} konnte nicht vollstaendig verrechnet werden");
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function select_open_lot_for_activation(PDO $pdo, int $productId, int $lotId): void
{
if ($productId <= 0 || $lotId <= 0) {
throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden');
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lotStmt = $pdo->prepare(
"SELECT product_id, status
FROM stock_lot
WHERE id = :lot_id
FOR UPDATE"
);
$lotStmt->execute([':lot_id' => $lotId]);
$lot = $lotStmt->fetch();
if (!is_array($lot) || (int) $lot['product_id'] !== $productId || (string) $lot['status'] !== 'open') {
throw new InvalidArgumentException('Offene Charge nicht gefunden');
}
if (!is_qualified_open_lot($pdo, $lotId)) {
throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden');
}
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
$selectStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = TRUE, updated_at = NOW()
WHERE id = :lot_id"
);
$selectStmt->execute([':lot_id' => $lotId]);
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
@@ -1071,46 +439,6 @@ function get_stock_lot_balance(PDO $pdo, int $lotId): array
];
}
function get_current_lot_snapshot(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT sl.id, sl.product_id, sl.lot_number, sl.status, sl.next_active,
sl.manufacturer_party_id, sl.manufacturer_lot_number,
mp.name AS manufacturer_name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
COALESCE(v.qty_net, 0) AS qty_net,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_out, 0) AS qty_out
FROM stock_lot sl
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'current'
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$row = $stmt->fetch();
if (!is_array($row)) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
return [
'id' => (int) $row['id'],
'product_id' => (int) $row['product_id'],
'lot_number' => $row['lot_number'],
'status' => (string) $row['status'],
'next_active' => (bool) $row['next_active'],
'manufacturer_party_id' => $row['manufacturer_party_id'] === null ? null : (int) $row['manufacturer_party_id'],
'manufacturer_lot_number' => $row['manufacturer_lot_number'],
'manufacturer_name' => $row['manufacturer_name'],
'mfg_date' => $row['mfg_date'],
'expiry_date' => $row['expiry_date'],
'purchase_date' => $row['purchase_date'],
'qty_net' => (float) $row['qty_net'],
'qty_in' => (float) $row['qty_in'],
'qty_out' => (float) $row['qty_out'],
];
}
function insert_stock_move_out(
PDO $pdo,
int $productId,
@@ -1219,8 +547,7 @@ function book_stock_lot_movements(
int $lotId,
float $receiptQty,
float $adjustmentQty,
float $outQty,
string $changedBy
float $outQty
): void {
$locations = get_default_location_ids($pdo);
$balanceStmt = $pdo->prepare(
@@ -1255,12 +582,6 @@ function book_stock_lot_movements(
insert_stock_move_adjustment($pdo, $productId, $lotId, $correctionQty, $locations['storage'], null, 'Manuelle Bestandskorrektur via Chargenformular');
}
$balance += $adjustmentQty;
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
$changedBy,
'Verrechnung nach manueller Bestandskorrektur'
);
}
if ($outQty > $balance) {
@@ -1268,12 +589,6 @@ function book_stock_lot_movements(
}
if ($outQty > 0) {
insert_stock_move_out($pdo, $productId, $lotId, $outQty, $locations['storage'], $locations['dispatch'], 'Manueller Lagerabgang via Chargenformular');
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
$changedBy,
'Verrechnung nach manuellem Lagerabgang'
);
}
}
@@ -1425,8 +740,23 @@ function get_or_create_virtual_shortage_lot(PDO $pdo, int $productId): int
return (int) $newLotId;
}
function create_open_lot(PDO $pdo, int $productId): int
function get_or_create_open_lot(PDO $pdo, int $productId): int
{
$select = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$select->execute([':product_id' => $productId]);
$lotId = $select->fetchColumn();
if ($lotId !== false) {
return (int) $lotId;
}
$insert = $pdo->prepare(
"INSERT INTO stock_lot (product_id, status, created_at, updated_at)
VALUES (:product_id, 'open', NOW(), NOW())
@@ -1441,60 +771,6 @@ function create_open_lot(PDO $pdo, int $productId): int
return (int) $newLotId;
}
function delete_open_stock_lot(PDO $pdo, int $lotId, string $changedBy): int
{
if ($lotId <= 0 || trim($changedBy) === '') {
throw new InvalidArgumentException('Offene Charge und Bediener sind erforderlich');
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lotStmt = $pdo->prepare(
"SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number,
mfg_date, expiry_date, purchase_date, status
FROM stock_lot
WHERE id = :id
FOR UPDATE"
);
$lotStmt->execute([':id' => $lotId]);
$lot = $lotStmt->fetch();
if (!is_array($lot)) {
throw new InvalidArgumentException('Charge nicht gefunden');
}
if ((string) $lot['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen geloescht werden');
}
system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'deleted', $changedBy, $lot, [], [
'module' => 'erp.lager',
]);
$moveStmt = $pdo->prepare('DELETE FROM stock_move WHERE lot_id = :lot_id');
$moveStmt->execute([':lot_id' => $lotId]);
$deleteStmt = $pdo->prepare('DELETE FROM stock_lot WHERE id = :id AND status = \'open\'');
$deleteStmt->execute([':id' => $lotId]);
if ($deleteStmt->rowCount() !== 1) {
throw new RuntimeException('Offene Charge konnte nicht geloescht werden');
}
$productId = (int) $lot['product_id'];
if ($startedTransaction) {
$pdo->commit();
}
return $productId;
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function replace_virtual_lot_allocation(
PDO $pdo,
int $allocationId,
@@ -1553,7 +829,8 @@ function replace_virtual_lot_allocation(
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(v.qty_net, 0)
FROM v_stock_lot_balance v
WHERE v.stock_lot_id = :lot_id"
WHERE v.stock_lot_id = :lot_id
FOR UPDATE"
);
$balanceStmt->execute([':lot_id' => $realLotId]);
$available = (float) ($balanceStmt->fetchColumn() ?: 0);
@@ -1676,12 +953,6 @@ function allocate_components_for_line(
throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}");
}
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
'system',
"Verrechnung vor Verkaufsallokation: order={$orderId}:line={$lineNo}"
);
$current = get_current_lot_balance_for_update($pdo, $productId);
$lotId = $current['lot_id'];
$available = $current['qty_net'];
@@ -1703,12 +974,6 @@ function allocate_components_for_line(
(int) $locations['dispatch'],
"{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}"
);
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
'system',
"Verrechnung nach Verkaufsabgang: order={$orderId}:line={$lineNo}"
);
$allocationStatus = 'allocated';
}
-229
View File
@@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/service.php';
function market_management_mcp_token(array $env): string
{
$token = env_value('MARKET_MCP_TOKEN', $env);
if ($token === '') {
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
}
return $token;
}
function market_management_mcp_authorize(array $env): string
{
$header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (!preg_match('/^Bearer\s+(.+)$/i', trim($header), $matches)) {
throw new RuntimeException('Bearer-Token fehlt.');
}
$token = trim($matches[1]);
$expected = market_management_mcp_token($env);
if ($token === '' || !hash_equals($expected, $token)) {
throw new RuntimeException('Bearer-Token ist ungültig.');
}
return 'bearer:' . hash('sha256', $token);
}
function market_management_mcp_tools(): array
{
return [
['name' => 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object']],
['name' => 'market_management.search_occurrences', 'description' => 'Durchführungen suchen und auflisten', 'inputSchema' => ['type' => 'object']],
['name' => 'market_management.get_market', 'description' => 'Markt mit Durchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]],
['name' => 'market_management.create_market', 'description' => 'Markt mit bestehender Kategorie und optionaler Website-URL anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id'], 'properties' => ['name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]],
['name' => 'market_management.update_market', 'description' => 'Markt inklusive Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]],
['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]],
['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id']]],
['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object']],
['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object']],
['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object']],
['name' => 'market_management.create_occurrence', 'description' => 'Durchführung mit mindestens einem Tag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days']]],
['name' => 'market_management.get_occurrence', 'description' => 'Durchführung mit Tagen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]],
['name' => 'market_management.update_occurrence', 'description' => 'Durchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]],
['name' => 'market_management.archive_occurrence', 'description' => 'Durchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]],
['name' => 'market_management.reactivate_occurrence', 'description' => 'Durchführung reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]],
['name' => 'market_management.create_day', 'description' => 'Durchführungstag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date']]],
['name' => 'market_management.update_day', 'description' => 'Durchführungstag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id']]],
['name' => 'market_management.delete_day', 'description' => 'Durchführungstag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id']]],
['name' => 'market_management.set_organizer', 'description' => 'Veranstalter einer Durchführung setzen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'organization_contact_id', 'contact_person_id']]],
['name' => 'market_management.delete_organizer', 'description' => 'Veranstalter einer Durchführung entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id']]],
['name' => 'market_management.create_cost_item', 'description' => 'Kostenposition anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'cost_type', 'gross_amount', 'tax_status']]],
['name' => 'market_management.update_cost_item', 'description' => 'Kostenposition ändern', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id']]],
['name' => 'market_management.delete_cost_item', 'description' => 'Kostenposition entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id']]],
];
}
function market_management_mcp_is_write_tool(string $tool): bool
{
return in_array($tool, [
'market_management.create_market', 'market_management.update_market',
'market_management.archive_market', 'market_management.reactivate_market',
'market_management.create_occurrence', 'market_management.update_occurrence',
'market_management.archive_occurrence', 'market_management.reactivate_occurrence',
'market_management.create_day', 'market_management.update_day', 'market_management.delete_day',
'market_management.set_organizer', 'market_management.delete_organizer',
'market_management.create_cost_item', 'market_management.update_cost_item',
'market_management.delete_cost_item',
], true);
}
function market_management_mcp_call_idempotent(PDO $pdo, string $userSubject, string $tool, array $arguments): array
{
if (!market_management_mcp_is_write_tool($tool)) {
return market_management_mcp_call($pdo, $tool, $arguments);
}
$idempotencyKey = trim((string) ($arguments['idempotency_key'] ?? ''));
if ($idempotencyKey === '') {
throw new InvalidArgumentException('Schreibende MCP-Aufrufe benötigen idempotency_key.');
}
$clientId = trim((string) ($arguments['client_id'] ?? 'hermes'));
if ($clientId === '') {
throw new InvalidArgumentException('client_id darf nicht leer sein.');
}
$businessArguments = $arguments;
unset($businessArguments['idempotency_key'], $businessArguments['client_id']);
$requestHash = hash('sha256', json_encode([
'tool' => $tool,
'arguments' => $businessArguments,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$pdo->beginTransaction();
try {
$existingStmt = $pdo->prepare(
'SELECT user_subject, request_hash, result_status, result_payload
FROM market_mcp_idempotency
WHERE client_id = :client_id AND idempotency_key = :idempotency_key
FOR UPDATE'
);
$existingStmt->execute([':client_id' => $clientId, ':idempotency_key' => $idempotencyKey]);
$existing = $existingStmt->fetch();
if (is_array($existing)) {
if ((string) $existing['user_subject'] !== $userSubject || (string) $existing['request_hash'] !== $requestHash) {
throw new InvalidArgumentException('Idempotency-Schlüssel wurde bereits für eine andere Anfrage verwendet.');
}
if ((string) $existing['result_status'] === 'succeeded' && $existing['result_payload'] !== null) {
$pdo->commit();
return (array) json_decode((string) $existing['result_payload'], true, 512, JSON_THROW_ON_ERROR);
}
throw new RuntimeException('Idempotency-Anfrage wird bereits verarbeitet oder ist fehlgeschlagen.');
}
$insert = $pdo->prepare(
'INSERT INTO market_mcp_idempotency
(user_subject, client_id, idempotency_key, tool_name, request_hash, result_status, expires_at)
VALUES (:user_subject, :client_id, :idempotency_key, :tool_name, :request_hash, \'processing\', NOW() + INTERVAL \'24 hours\')'
);
$insert->execute([
':user_subject' => $userSubject,
':client_id' => $clientId,
':idempotency_key' => $idempotencyKey,
':tool_name' => $tool,
':request_hash' => $requestHash,
]);
$result = market_management_mcp_call($pdo, $tool, $businessArguments);
$update = $pdo->prepare(
'UPDATE market_mcp_idempotency
SET result_status = \'succeeded\', result_payload = CAST(:result_payload AS JSONB)
WHERE client_id = :client_id AND idempotency_key = :idempotency_key'
);
$update->execute([
':result_payload' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
':client_id' => $clientId,
':idempotency_key' => $idempotencyKey,
]);
$pdo->commit();
return $result;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): array
{
return match ($tool) {
'market_management.search_markets' => market_management_search_markets($pdo, $arguments),
'market_management.search_occurrences' => market_management_search_occurrences($pdo, $arguments),
'market_management.get_market' => market_management_get_market(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
(bool) ($arguments['include_archived'] ?? false)
) ?? throw new RuntimeException('Markt nicht gefunden.'),
'market_management.create_market' => market_management_create_market($pdo, $arguments),
'market_management.update_market' => market_management_update_market(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
$arguments
),
'market_management.archive_market' => market_management_set_market_archive(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
true
),
'market_management.reactivate_market' => market_management_set_market_archive(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
false
),
'market_management.list_categories' => market_management_list_categories($pdo),
'market_management.list_reference_data' => market_management_list_reference_data($pdo, $arguments),
'market_management.get_overview' => [
'markets' => market_management_search_markets($pdo, $arguments),
'occurrences' => market_management_search_occurrences($pdo, $arguments),
],
'market_management.create_occurrence' => market_management_create_occurrence($pdo, $arguments),
'market_management.get_occurrence' => market_management_get_occurrence(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
(bool) ($arguments['include_archived'] ?? false)
) ?? throw new RuntimeException('Durchführung nicht gefunden.'),
'market_management.update_occurrence' => market_management_update_occurrence(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
$arguments
),
'market_management.archive_occurrence' => market_management_set_occurrence_archive(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
true
),
'market_management.reactivate_occurrence' => market_management_set_occurrence_archive(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
false
),
'market_management.create_day' => market_management_create_day($pdo, $arguments),
'market_management.update_day' => market_management_update_day(
$pdo,
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id'),
$arguments
),
'market_management.delete_day' => market_management_delete_day(
$pdo,
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id')
),
'market_management.set_organizer' => market_management_set_organizer($pdo, $arguments),
'market_management.delete_organizer' => market_management_delete_organizer(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id')
),
'market_management.create_cost_item' => market_management_create_cost_item($pdo, $arguments),
'market_management.update_cost_item' => market_management_update_cost_item(
$pdo,
market_management_require_positive_id($arguments['cost_item_id'] ?? null, 'cost_item_id'),
$arguments
),
'market_management.delete_cost_item' => market_management_delete_cost_item(
$pdo,
market_management_require_positive_id($arguments['cost_item_id'] ?? null, 'cost_item_id')
),
default => throw new InvalidArgumentException('Unbekanntes MCP-Tool: ' . $tool),
};
}
-762
View File
@@ -1,762 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../shared/db.php';
const MARKET_MANAGEMENT_STATUSES = [
'open',
'waitlist',
'registered',
'confirmation_pending',
'confirmed',
'cancelled',
'completed',
];
function market_management_require_positive_id(mixed $value, string $field): int
{
if (!is_int($value) && !ctype_digit((string) $value)) {
throw new InvalidArgumentException($field . ' muss eine positive Ganzzahl sein.');
}
$id = (int) $value;
if ($id <= 0) {
throw new InvalidArgumentException($field . ' muss eine positive Ganzzahl sein.');
}
return $id;
}
function market_management_optional_string(array $input, string $field): ?string
{
if (!array_key_exists($field, $input) || $input[$field] === null) {
return null;
}
$value = trim((string) $input[$field]);
return $value === '' ? null : $value;
}
function market_management_optional_bool(array $input, string $field): ?bool
{
if (!array_key_exists($field, $input) || $input[$field] === null) {
return null;
}
if (is_bool($input[$field])) {
return $input[$field];
}
if ($input[$field] === 'true' || $input[$field] === '1' || $input[$field] === 1) {
return true;
}
if ($input[$field] === 'false' || $input[$field] === '0' || $input[$field] === 0) {
return false;
}
throw new InvalidArgumentException($field . ' muss boolean sein.');
}
function market_management_optional_number(array $input, string $field): ?string
{
if (!array_key_exists($field, $input) || $input[$field] === null || $input[$field] === '') {
return null;
}
$value = parse_number($input[$field]);
if ($value === null || $value < 0) {
throw new InvalidArgumentException($field . ' muss eine nichtnegative Zahl sein.');
}
return number_format($value, 2, '.', '');
}
function market_management_optional_integer(array $input, string $field): ?int
{
if (!array_key_exists($field, $input) || $input[$field] === null || $input[$field] === '') {
return null;
}
$value = parse_number($input[$field]);
if ($value === null || $value < 0 || floor($value) !== $value) {
throw new InvalidArgumentException($field . ' muss eine nichtnegative Ganzzahl sein.');
}
return (int) $value;
}
function market_management_optional_url(array $input, string $field): ?string
{
$value = market_management_optional_string($input, $field);
if ($value === null) {
return null;
}
if (filter_var($value, FILTER_VALIDATE_URL) === false || !preg_match('/^https?:\/\//i', $value)) {
throw new InvalidArgumentException($field . ' muss eine gültige HTTP(S)-URL sein.');
}
return $value;
}
function market_management_category_exists(PDO $pdo, int $categoryId): bool
{
$stmt = $pdo->prepare('SELECT 1 FROM market_category WHERE id = :id AND is_active = TRUE');
$stmt->execute([':id' => $categoryId]);
return $stmt->fetchColumn() !== false;
}
function market_management_create_market(PDO $pdo, array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
$categoryId = market_management_require_positive_id($input['category_id'] ?? null, 'category_id');
$websiteUrl = market_management_optional_url($input, 'website_url');
if ($name === '') {
throw new InvalidArgumentException('name ist erforderlich.');
}
if (!market_management_category_exists($pdo, $categoryId)) {
throw new InvalidArgumentException('category_id verweist nicht auf eine aktive Kategorie.');
}
$stmt = $pdo->prepare(
'INSERT INTO market_definition (name, category_id, website_url)
VALUES (:name, :category_id, :website_url)
RETURNING id, name, category_id, website_url, archived_at, created_at, updated_at'
);
$stmt->execute([':name' => $name, ':category_id' => $categoryId, ':website_url' => $websiteUrl]);
return (array) $stmt->fetch();
}
function market_management_search_markets(PDO $pdo, array $input): array
{
$search = market_management_optional_string($input, 'search');
$includeArchived = (bool) ($input['include_archived'] ?? false);
$categoryId = array_key_exists('category_id', $input)
? market_management_require_positive_id($input['category_id'], 'category_id')
: null;
$status = array_key_exists('status', $input) ? market_management_validate_status($input['status']) : null;
$place = market_management_optional_string($input, 'place');
$municipalityId = array_key_exists('municipality_reference_id', $input)
? market_management_require_positive_id($input['municipality_reference_id'], 'municipality_reference_id')
: null;
$dateFrom = market_management_optional_string($input, 'date_from');
$dateTo = market_management_optional_string($input, 'date_to');
$limit = min(100, max(1, (int) ($input['limit'] ?? 50)));
$offset = max(0, (int) ($input['offset'] ?? 0));
$sort = (string) ($input['sort'] ?? 'name');
$sortDirection = strtolower((string) ($input['sort_direction'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC';
$sortColumn = match ($sort) {
'earliest_date' => 'MIN(d.date)',
'updated_at' => 'm.updated_at',
default => 'm.name',
};
$where = [];
$params = [':limit' => $limit, ':offset' => $offset];
$occurrenceArchivePredicate = $includeArchived ? 'TRUE' : 'o.archived_at IS NULL';
if (!$includeArchived) {
$where[] = 'm.archived_at IS NULL';
}
if ($search !== null) {
$where[] = 'm.name ILIKE :search';
$params[':search'] = '%' . $search . '%';
}
if ($categoryId !== null) {
$where[] = 'm.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
if ($status !== null) {
$where[] = 'EXISTS (SELECT 1 FROM market_occurrence os WHERE os.market_definition_id = m.id AND os.status = :status AND ' . ($includeArchived ? 'TRUE' : 'os.archived_at IS NULL') . ')';
$params[':status'] = $status;
}
if ($place !== null) {
$where[] = 'EXISTS (SELECT 1 FROM market_occurrence op WHERE op.market_definition_id = m.id AND op.place ILIKE :place AND ' . ($includeArchived ? 'TRUE' : 'op.archived_at IS NULL') . ')';
$params[':place'] = '%' . $place . '%';
}
if ($municipalityId !== null) {
$where[] = 'EXISTS (SELECT 1 FROM market_occurrence om WHERE om.market_definition_id = m.id AND om.municipality_reference_id = :municipality_id AND ' . ($includeArchived ? 'TRUE' : 'om.archived_at IS NULL') . ')';
$params[':municipality_id'] = $municipalityId;
}
if ($dateFrom !== null || $dateTo !== null) {
$dateConditions = ['od.occurrence_id = odate.id'];
if ($dateFrom !== null) {
$dateConditions[] = 'od.date >= :date_from';
$params[':date_from'] = $dateFrom;
}
if ($dateTo !== null) {
$dateConditions[] = 'od.date <= :date_to';
$params[':date_to'] = $dateTo;
}
$where[] = 'EXISTS (SELECT 1 FROM market_occurrence odate INNER JOIN market_occurrence_day od ON ' . implode(' AND ', $dateConditions) . ' WHERE odate.market_definition_id = m.id AND ' . ($includeArchived ? 'TRUE' : 'odate.archived_at IS NULL') . ')';
}
$stmt = $pdo->prepare(
'SELECT m.id, m.name, m.category_id, m.website_url, c.code AS category_code, c.name AS category_name,
m.archived_at, m.created_at, m.updated_at,
MIN(d.date) AS earliest_date, MAX(d.date) AS latest_date,
COUNT(DISTINCT o.id) AS occurrence_count
FROM market_definition m
INNER JOIN market_category c ON c.id = m.category_id
LEFT JOIN market_occurrence o ON o.market_definition_id = m.id AND ' . $occurrenceArchivePredicate . '
LEFT JOIN market_occurrence_day d ON d.occurrence_id = o.id
WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . '
GROUP BY m.id, m.category_id, c.code, c.name
ORDER BY ' . $sortColumn . ' ' . $sortDirection . ', m.id ASC
LIMIT :limit OFFSET :offset'
);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, in_array($key, [':limit', ':offset', ':category_id', ':municipality_id'], true) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stmt->execute();
return $stmt->fetchAll();
}
function market_management_get_market(PDO $pdo, int $marketId, bool $includeArchived = false): ?array
{
$marketArchivePredicate = $includeArchived ? 'TRUE' : 'm.archived_at IS NULL';
$stmt = $pdo->prepare(
'SELECT m.id, m.name, m.category_id, m.website_url, c.code AS category_code, c.name AS category_name,
m.archived_at, m.created_at, m.updated_at
FROM market_definition m
INNER JOIN market_category c ON c.id = m.category_id
WHERE m.id = :id AND ' . $marketArchivePredicate
);
$stmt->execute([':id' => $marketId]);
$market = $stmt->fetch();
if (!is_array($market)) {
return null;
}
$occurrenceArchivePredicate = $includeArchived ? 'TRUE' : 'archived_at IS NULL';
$occurrences = $pdo->prepare(
'SELECT *
FROM market_occurrence
WHERE market_definition_id = :market_id
AND ' . $occurrenceArchivePredicate . '
ORDER BY id ASC'
);
$occurrences->execute([':market_id' => $marketId]);
$market['occurrences'] = $occurrences->fetchAll();
foreach ($market['occurrences'] as &$occurrence) {
$occurrenceId = (int) $occurrence['id'];
$organizer = $pdo->prepare('SELECT * FROM market_occurrence_organizer WHERE occurrence_id = :occurrence_id');
$organizer->execute([':occurrence_id' => $occurrenceId]);
$occurrence['organizer'] = $organizer->fetch() ?: null;
$days = $pdo->prepare('SELECT * FROM market_occurrence_day WHERE occurrence_id = :occurrence_id ORDER BY date ASC, id ASC');
$days->execute([':occurrence_id' => $occurrenceId]);
$occurrence['days'] = $days->fetchAll();
$costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC');
$costs->execute([':occurrence_id' => $occurrenceId]);
$occurrence['cost_items'] = $costs->fetchAll();
}
unset($occurrence);
return $market;
}
function market_management_search_occurrences(PDO $pdo, array $input): array
{
$where = [];
$params = [':limit' => min(100, max(1, (int) ($input['limit'] ?? 50))), ':offset' => max(0, (int) ($input['offset'] ?? 0))];
$includeArchived = (bool) ($input['include_archived'] ?? false);
$search = market_management_optional_string($input, 'search');
$sort = (string) ($input['sort'] ?? 'earliest_date');
$sortDirection = strtolower((string) ($input['sort_direction'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC';
$sortColumn = match ($sort) {
'market_name' => 'm.name',
'place' => 'o.place',
'status' => 'o.status',
default => 'MIN(d.date)',
};
if (!$includeArchived) {
$where[] = 'o.archived_at IS NULL';
}
if ($search !== null) {
$where[] = '(m.name ILIKE :search_name OR o.place ILIKE :search_place OR o.status ILIKE :search_status OR CASE o.status WHEN \'open\' THEN \'Offen\' WHEN \'waitlist\' THEN \'Warteliste\' WHEN \'registered\' THEN \'Angemeldet\' WHEN \'confirmation_pending\' THEN \'Bestätigung ausstehend\' WHEN \'confirmed\' THEN \'Bestätigt\' WHEN \'cancelled\' THEN \'Abgesagt\' WHEN \'completed\' THEN \'Abgeschlossen\' ELSE o.status END ILIKE :search_status_label OR EXISTS (SELECT 1 FROM market_occurrence_day sd WHERE sd.occurrence_id = o.id AND TO_CHAR(sd.date, \'DD.MM.YYYY\') ILIKE :search_date))';
$searchValue = '%' . $search . '%';
$params[':search_name'] = $searchValue;
$params[':search_place'] = $searchValue;
$params[':search_status'] = $searchValue;
$params[':search_status_label'] = $searchValue;
$params[':search_date'] = $searchValue;
}
if (array_key_exists('market_definition_id', $input)) {
$where[] = 'o.market_definition_id = :market_definition_id';
$params[':market_definition_id'] = market_management_require_positive_id($input['market_definition_id'], 'market_definition_id');
}
if (array_key_exists('status', $input)) {
$where[] = 'o.status = :status';
$params[':status'] = market_management_validate_status($input['status']);
}
foreach (['place' => 'o.place', 'address' => 'o.address'] as $key => $column) {
$value = market_management_optional_string($input, $key);
if ($value !== null) {
$where[] = $column . ' ILIKE :' . $key;
$params[':' . $key] = '%' . $value . '%';
}
}
if (array_key_exists('municipality_reference_id', $input)) {
$where[] = 'o.municipality_reference_id = :municipality_reference_id';
$params[':municipality_reference_id'] = market_management_require_positive_id($input['municipality_reference_id'], 'municipality_reference_id');
}
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$value = market_management_optional_string($input, $key);
if ($value !== null) {
$where[] = 'EXISTS (SELECT 1 FROM market_occurrence_day fd WHERE fd.occurrence_id = o.id AND fd.date ' . $operator . ' :' . $key . ')';
$params[':' . $key] = $value;
}
}
$stmt = $pdo->prepare(
'SELECT o.*, m.name AS market_name, m.category_id, c.code AS category_code, c.name AS category_name,
MIN(d.date) AS earliest_date, MAX(d.date) AS latest_date, COUNT(d.id) AS day_count
FROM market_occurrence o
INNER JOIN market_definition m ON m.id = o.market_definition_id
INNER JOIN market_category c ON c.id = m.category_id
LEFT JOIN market_occurrence_day d ON d.occurrence_id = o.id
WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . '
GROUP BY o.id, m.name, m.category_id, c.code, c.name
ORDER BY ' . $sortColumn . ' ' . $sortDirection . ' NULLS LAST, o.id ASC
LIMIT :limit OFFSET :offset'
);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, in_array($key, [':limit', ':offset', ':market_definition_id', ':municipality_reference_id'], true) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stmt->execute();
return $stmt->fetchAll();
}
function market_management_list_reference_data(PDO $pdo, array $input): array
{
$result = ['categories' => market_management_list_categories($pdo)];
$municipalities = $pdo->query(
'SELECT id, bfs_municipality_number, name, canton_code, data_version
FROM market_municipality_reference WHERE is_active = TRUE ORDER BY name ASC, id ASC'
)->fetchAll();
$result['municipalities'] = $municipalities;
$result['cantons'] = array_values(array_map(
static fn (array $row): array => ['code' => (string) $row['canton_code']],
array_values(array_reduce($municipalities, static function (array $carry, array $row): array {
$carry[(string) $row['canton_code']] = $row;
return $carry;
}, []))
));
return $result;
}
function market_management_update_market(PDO $pdo, int $marketId, array $input): array
{
$sets = [];
$params = [':id' => $marketId];
if (array_key_exists('name', $input)) {
$name = trim((string) $input['name']);
if ($name === '') {
throw new InvalidArgumentException('name darf nicht leer sein.');
}
$sets[] = 'name = :name';
$params[':name'] = $name;
}
if (array_key_exists('category_id', $input)) {
$categoryId = market_management_require_positive_id($input['category_id'], 'category_id');
if (!market_management_category_exists($pdo, $categoryId)) {
throw new InvalidArgumentException('category_id verweist nicht auf eine aktive Kategorie.');
}
$sets[] = 'category_id = :category_id';
$params[':category_id'] = $categoryId;
}
if (array_key_exists('website_url', $input)) {
$sets[] = 'website_url = :website_url';
$params[':website_url'] = market_management_optional_url($input, 'website_url');
}
if ($sets === []) {
throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.');
}
$sets[] = 'updated_at = NOW()';
$stmt = $pdo->prepare(
'UPDATE market_definition SET ' . implode(', ', $sets) . ' WHERE id = :id
RETURNING id, name, category_id, website_url, archived_at, created_at, updated_at'
);
$stmt->execute($params);
$market = $stmt->fetch();
if (!is_array($market)) {
throw new RuntimeException('Markt nicht gefunden.');
}
return $market;
}
function market_management_set_market_archive(PDO $pdo, int $marketId, bool $archive): array
{
$stmt = $pdo->prepare(
'UPDATE market_definition
SET archived_at = ' . ($archive ? 'COALESCE(archived_at, NOW())' : 'NULL') . ', updated_at = NOW()
WHERE id = :id
RETURNING id, name, category_id, website_url, archived_at, created_at, updated_at'
);
$stmt->execute([':id' => $marketId]);
$market = $stmt->fetch();
if (!is_array($market)) {
throw new RuntimeException('Markt nicht gefunden.');
}
return $market;
}
function market_management_list_categories(PDO $pdo): array
{
return $pdo->query(
'SELECT id, code, name FROM market_category WHERE is_active = TRUE ORDER BY name ASC, id ASC'
)->fetchAll();
}
function market_management_validate_status(mixed $status): string
{
$value = trim((string) $status);
if (!in_array($value, MARKET_MANAGEMENT_STATUSES, true)) {
throw new InvalidArgumentException('Ungültiger Durchführungsstatus.');
}
return $value;
}
function market_management_write_fields(array $input, array $definitions): array
{
$fields = [];
$params = [];
foreach ($definitions as $field => $type) {
if (!array_key_exists($field, $input)) {
continue;
}
$value = match ($type) {
'string' => market_management_optional_string($input, $field),
'bool' => market_management_optional_bool($input, $field),
'number' => market_management_optional_number($input, $field),
'integer' => market_management_optional_integer($input, $field),
'id' => market_management_require_positive_id($input[$field], $field),
default => throw new LogicException('Unbekannter Feldtyp.'),
};
$fields[] = $field . ' = :' . $field;
$params[':' . $field] = $value;
}
return [$fields, $params];
}
function market_management_create_occurrence(PDO $pdo, array $input): array
{
$marketId = market_management_require_positive_id($input['market_definition_id'] ?? null, 'market_definition_id');
if (market_management_get_market($pdo, $marketId, true) === null) {
throw new InvalidArgumentException('market_definition_id verweist nicht auf einen Markt.');
}
$status = array_key_exists('status', $input) ? market_management_validate_status($input['status']) : 'open';
$days = $input['days'] ?? null;
if (!is_array($days) || count($days) < 1) {
throw new InvalidArgumentException('Mindestens ein Durchführungstag ist erforderlich.');
}
$definitions = [
'place' => 'string', 'address' => 'string', 'municipality_reference_id' => 'id', 'notes' => 'string',
'registration_deadline' => 'string', 'request_or_registration_date' => 'string', 'confirmation_date' => 'string',
'payment_deadline' => 'string', 'stand_size' => 'string', 'table_available' => 'bool',
'table_rentable' => 'bool', 'own_table_required' => 'bool', 'shelter_available' => 'bool',
'power_available' => 'bool', 'water_available' => 'bool', 'lighting_available' => 'bool',
'other_infrastructure' => 'string', 'own_stand_requirements' => 'string', 'travel_address' => 'string',
'travel_distance_km' => 'number', 'travel_duration_minutes' => 'integer', 'parking_options' => 'string',
'delivery_access' => 'string', 'direct_unload_possible' => 'bool', 'storage_options' => 'string',
'travel_notes' => 'string', 'payment_status' => 'string', 'payment_date' => 'string',
'paid_amount' => 'number', 'payment_method_code' => 'string', 'cancellation_terms' => 'string',
'revenue_share_terms' => 'string', 'other_participation_conditions' => 'string', 'contract_folder_url' => 'string',
];
[$fields, $params] = market_management_write_fields($input, $definitions);
if (isset($params[':payment_status']) && !in_array($params[':payment_status'], ['paid', 'unpaid'], true)) {
throw new InvalidArgumentException('Ungültiger Zahlungsstatus.');
}
array_unshift($fields, 'market_definition_id = :market_definition_id', 'status = :status');
$params[':market_definition_id'] = $marketId;
$params[':status'] = $status;
$columns = array_map(static fn (string $field): string => explode(' = ', $field, 2)[0], $fields);
$values = array_map(static fn (string $field): string => explode(' = ', $field, 2)[1], $fields);
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$stmt = $pdo->prepare(
'INSERT INTO market_occurrence (' . implode(', ', $columns) . ')
VALUES (' . implode(', ', $values) . ')
RETURNING *'
);
$stmt->execute($params);
$occurrence = (array) $stmt->fetch();
foreach ($days as $day) {
if (!is_array($day)) {
throw new InvalidArgumentException('Jeder Durchführungstag muss ein Objekt sein.');
}
$day['occurrence_id'] = (int) $occurrence['id'];
market_management_create_day($pdo, $day);
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $e) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
return market_management_get_occurrence($pdo, (int) $occurrence['id'], true) ?? $occurrence;
}
function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): ?array
{
$archivePredicate = $includeArchived ? 'TRUE' : 'archived_at IS NULL';
$stmt = $pdo->prepare(
'SELECT * FROM market_occurrence
WHERE id = :id AND ' . $archivePredicate
);
$stmt->execute([':id' => $occurrenceId]);
$occurrence = $stmt->fetch();
if (!is_array($occurrence)) {
return null;
}
$days = $pdo->prepare(
'SELECT * FROM market_occurrence_day
WHERE occurrence_id = :occurrence_id ORDER BY date ASC, id ASC'
);
$days->execute([':occurrence_id' => $occurrenceId]);
$occurrence['days'] = $days->fetchAll();
$organizer = $pdo->prepare('SELECT * FROM market_occurrence_organizer WHERE occurrence_id = :occurrence_id');
$organizer->execute([':occurrence_id' => $occurrenceId]);
$occurrence['organizer'] = $organizer->fetch() ?: null;
$costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC');
$costs->execute([':occurrence_id' => $occurrenceId]);
$occurrence['cost_items'] = $costs->fetchAll();
return $occurrence;
}
function market_management_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array
{
$definitions = [
'status' => 'string', 'place' => 'string', 'address' => 'string', 'municipality_reference_id' => 'id',
'notes' => 'string', 'registration_deadline' => 'string', 'request_or_registration_date' => 'string',
'confirmation_date' => 'string', 'payment_deadline' => 'string', 'stand_size' => 'string',
'table_available' => 'bool', 'table_rentable' => 'bool', 'own_table_required' => 'bool',
'shelter_available' => 'bool', 'power_available' => 'bool', 'water_available' => 'bool',
'lighting_available' => 'bool', 'other_infrastructure' => 'string', 'own_stand_requirements' => 'string',
'travel_address' => 'string', 'travel_distance_km' => 'number', 'travel_duration_minutes' => 'integer',
'parking_options' => 'string', 'delivery_access' => 'string', 'direct_unload_possible' => 'bool',
'storage_options' => 'string', 'travel_notes' => 'string', 'payment_status' => 'string',
'payment_date' => 'string', 'paid_amount' => 'number', 'payment_method_code' => 'string',
'cancellation_terms' => 'string', 'revenue_share_terms' => 'string',
'other_participation_conditions' => 'string', 'contract_folder_url' => 'string',
];
[$fields, $params] = market_management_write_fields($input, $definitions);
if (array_key_exists('status', $input)) {
$params[':status'] = market_management_validate_status($input['status']);
}
if (isset($params[':payment_status']) && !in_array($params[':payment_status'], ['paid', 'unpaid'], true)) {
throw new InvalidArgumentException('Ungültiger Zahlungsstatus.');
}
if ($fields === []) {
throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.');
}
$fields[] = 'updated_at = NOW()';
$params[':id'] = $occurrenceId;
$stmt = $pdo->prepare(
'UPDATE market_occurrence SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *'
);
$stmt->execute($params);
$occurrence = $stmt->fetch();
if (!is_array($occurrence)) {
throw new RuntimeException('Durchführung nicht gefunden.');
}
return $occurrence;
}
function market_management_create_day(PDO $pdo, array $input): array
{
$occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id');
if (market_management_get_occurrence($pdo, $occurrenceId, true) === null) {
throw new InvalidArgumentException('occurrence_id verweist nicht auf eine Durchführung.');
}
$date = trim((string) ($input['date'] ?? ''));
if ($date === '') {
throw new InvalidArgumentException('date ist erforderlich.');
}
$definitions = [
'stand_start_time' => 'string', 'stand_end_time' => 'string', 'setup_start_time' => 'string',
'setup_end_time' => 'string', 'delivery_start_time' => 'string', 'delivery_end_time' => 'string',
'observations' => 'string',
];
[$fields, $params] = market_management_write_fields($input, $definitions);
array_unshift($fields, 'occurrence_id = :occurrence_id', 'date = :date');
$params[':occurrence_id'] = $occurrenceId;
$params[':date'] = $date;
$columns = array_map(static fn (string $field): string => explode(' = ', $field, 2)[0], $fields);
$values = array_map(static fn (string $field): string => explode(' = ', $field, 2)[1], $fields);
$stmt = $pdo->prepare(
'INSERT INTO market_occurrence_day (' . implode(', ', $columns) . ')
VALUES (' . implode(', ', $values) . ') RETURNING *'
);
$stmt->execute($params);
return (array) $stmt->fetch();
}
function market_management_update_day(PDO $pdo, int $dayId, array $input): array
{
$definitions = [
'date' => 'string', 'stand_start_time' => 'string', 'stand_end_time' => 'string',
'setup_start_time' => 'string', 'setup_end_time' => 'string', 'delivery_start_time' => 'string',
'delivery_end_time' => 'string', 'observations' => 'string',
];
[$fields, $params] = market_management_write_fields($input, $definitions);
if ($fields === []) {
throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.');
}
$fields[] = 'updated_at = NOW()';
$params[':id'] = $dayId;
$stmt = $pdo->prepare(
'UPDATE market_occurrence_day SET ' . implode(', ', $fields) . ' WHERE id = :id RETURNING *'
);
$stmt->execute($params);
$day = $stmt->fetch();
if (!is_array($day)) {
throw new RuntimeException('Durchführungstag nicht gefunden.');
}
return $day;
}
function market_management_set_occurrence_archive(PDO $pdo, int $occurrenceId, bool $archive): array
{
$stmt = $pdo->prepare(
'UPDATE market_occurrence
SET archived_at = ' . ($archive ? 'COALESCE(archived_at, NOW())' : 'NULL') . ', updated_at = NOW()
WHERE id = :id RETURNING *'
);
$stmt->execute([':id' => $occurrenceId]);
$occurrence = $stmt->fetch();
if (!is_array($occurrence)) {
throw new RuntimeException('Durchführung nicht gefunden.');
}
return $occurrence;
}
function market_management_delete_day(PDO $pdo, int $dayId): array
{
$stmt = $pdo->prepare('DELETE FROM market_occurrence_day WHERE id = :id RETURNING id, occurrence_id, date');
$stmt->execute([':id' => $dayId]);
$day = $stmt->fetch();
if (!is_array($day)) {
throw new RuntimeException('Durchführungstag nicht gefunden.');
}
return $day;
}
function market_management_set_organizer(PDO $pdo, array $input): array
{
$occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id');
$organizationId = market_management_require_positive_id($input['organization_contact_id'] ?? null, 'organization_contact_id');
$contactPersonId = market_management_require_positive_id($input['contact_person_id'] ?? null, 'contact_person_id');
$stmt = $pdo->prepare(
'INSERT INTO market_occurrence_organizer (occurrence_id, organization_contact_id, contact_person_id)
VALUES (:occurrence_id, :organization_contact_id, :contact_person_id)
ON CONFLICT (occurrence_id) DO UPDATE
SET organization_contact_id = EXCLUDED.organization_contact_id,
contact_person_id = EXCLUDED.contact_person_id,
updated_at = NOW()
RETURNING *'
);
$stmt->execute([
':occurrence_id' => $occurrenceId,
':organization_contact_id' => $organizationId,
':contact_person_id' => $contactPersonId,
]);
return (array) $stmt->fetch();
}
function market_management_delete_organizer(PDO $pdo, int $occurrenceId): array
{
$stmt = $pdo->prepare('DELETE FROM market_occurrence_organizer WHERE occurrence_id = :occurrence_id RETURNING id, occurrence_id');
$stmt->execute([':occurrence_id' => $occurrenceId]);
$organizer = $stmt->fetch();
if (!is_array($organizer)) {
throw new RuntimeException('Veranstalter nicht gefunden.');
}
return $organizer;
}
function market_management_create_cost_item(PDO $pdo, array $input): array
{
$occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id');
$costType = trim((string) ($input['cost_type'] ?? ''));
$grossAmount = market_management_optional_number($input, 'gross_amount');
$taxStatus = trim((string) ($input['tax_status'] ?? ''));
$taxRate = market_management_optional_number($input, 'tax_rate') ?? '0.00';
if ($costType === '' || $grossAmount === null || !in_array($taxStatus, ['tax_free', 'taxable'], true)) {
throw new InvalidArgumentException('cost_type, gross_amount und tax_status sind erforderlich.');
}
if (($taxStatus === 'tax_free' && $taxRate !== '0.00') || ($taxStatus === 'taxable' && (float) $taxRate <= 0)) {
throw new InvalidArgumentException('tax_rate passt nicht zu tax_status.');
}
$stmt = $pdo->prepare(
'INSERT INTO market_occurrence_cost_item
(occurrence_id, cost_type, description, gross_amount, tax_status, tax_rate)
VALUES (:occurrence_id, :cost_type, :description, :gross_amount, :tax_status, :tax_rate)
RETURNING *'
);
$stmt->execute([
':occurrence_id' => $occurrenceId,
':cost_type' => $costType,
':description' => market_management_optional_string($input, 'description'),
':gross_amount' => $grossAmount,
':tax_status' => $taxStatus,
':tax_rate' => $taxRate,
]);
return (array) $stmt->fetch();
}
function market_management_delete_cost_item(PDO $pdo, int $costItemId): array
{
$stmt = $pdo->prepare('DELETE FROM market_occurrence_cost_item WHERE id = :id RETURNING id, occurrence_id');
$stmt->execute([':id' => $costItemId]);
$item = $stmt->fetch();
if (!is_array($item)) {
throw new RuntimeException('Kostenposition nicht gefunden.');
}
return $item;
}
function market_management_update_cost_item(PDO $pdo, int $costItemId, array $input): array
{
$sets = [];
$params = [':id' => $costItemId];
if (array_key_exists('cost_type', $input)) {
$costType = trim((string) $input['cost_type']);
if ($costType === '') {
throw new InvalidArgumentException('cost_type darf nicht leer sein.');
}
$sets[] = 'cost_type = :cost_type';
$params[':cost_type'] = $costType;
}
foreach (['description' => 'string', 'gross_amount' => 'number', 'tax_status' => 'string', 'tax_rate' => 'number'] as $field => $type) {
if (!array_key_exists($field, $input)) {
continue;
}
$sets[] = $field . ' = :' . $field;
$params[':' . $field] = $type === 'number'
? market_management_optional_number($input, $field)
: market_management_optional_string($input, $field);
}
if (isset($params[':tax_status']) && !in_array($params[':tax_status'], ['tax_free', 'taxable'], true)) {
throw new InvalidArgumentException('Ungültiger Steuerstatus.');
}
if ($sets === []) {
throw new InvalidArgumentException('Mindestens ein änderbares Feld ist erforderlich.');
}
$sets[] = 'updated_at = NOW()';
$stmt = $pdo->prepare(
'UPDATE market_occurrence_cost_item SET ' . implode(', ', $sets) . ' WHERE id = :id RETURNING *'
);
$stmt->execute($params);
$item = $stmt->fetch();
if (!is_array($item)) {
throw new RuntimeException('Kostenposition nicht gefunden.');
}
return $item;
}
+183 -357
View File
@@ -50,115 +50,6 @@ function auth_format_bestellung_total(?float $value): string
return 'CHF ' . number_format($value, 2, '.', '\'');
}
function auth_format_market_date(?string $earliest, ?string $latest): string
{
if ($earliest === null || $earliest === '') {
return '';
}
$start = (new DateTimeImmutable($earliest))->format('d.m.Y');
if ($latest === null || $latest === '' || $latest === $earliest) {
return $start;
}
return $start . '–' . (new DateTimeImmutable($latest))->format('d.m.Y');
}
function auth_market_status_label(string $status): string
{
return [
'open' => 'Offen',
'waitlist' => 'Warteliste',
'registered' => 'Angemeldet',
'confirmation_pending' => 'Bestätigung ausstehend',
'confirmed' => 'Bestätigt',
'cancelled' => 'Abgesagt',
'completed' => 'Abgeschlossen',
][$status] ?? $status;
}
function auth_market_status_signal_class(string $status): string
{
return [
'waitlist' => 'market-status--red',
'registered' => 'market-status--yellow',
'confirmation_pending' => 'market-status--yellow',
'confirmed' => 'market-status--green',
][$status] ?? '';
}
function auth_render_markets_large_table(array $marketTable): string
{
$rows = $marketTable['rows'] ?? $marketTable;
$search = (string) ($marketTable['search'] ?? '');
$sortColumn = (string) ($marketTable['sort_column'] ?? 'earliest_date');
$sortDirection = strtoupper((string) ($marketTable['sort_direction'] ?? 'ASC'));
$limit = (int) ($marketTable['limit'] ?? 20);
$pageSize = (int) ($marketTable['page_size'] ?? 20);
$hasMore = (bool) ($marketTable['has_more'] ?? (count($rows) >= $limit));
$sortColumns = [
'market_name' => 'Name der Veranstaltung',
'earliest_date' => 'Durchführung',
'place' => 'Ort',
'status' => 'Status',
];
$html = [];
$html[] = '<section class="sg-card-list-page" aria-label="Märkte">';
$html[] = '<article class="sg-card sg-large-table sg-large-table--markets" data-component="large-table" data-markets-large-table="true" data-markets-search="' . auth_escape_html($search) . '" data-markets-sort-column="' . auth_escape_html($sortColumn) . '" data-markets-sort-direction="' . auth_escape_html($sortDirection) . '" data-markets-limit="' . $limit . '" data-markets-page-size="' . $pageSize . '" role="table" aria-label="Marktdurchführungen">';
$html[] = '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header">';
$html[] = '<div class="sg-strong">Marktdurchführungen</div>';
$html[] = '<span class="sg-search-field-row" data-component="search-field">';
$html[] = '<span class="sg-input-single-line-wrap" data-has-value="' . ($search !== '' ? 'true' : 'false') . '" data-component="single-line-input" data-component-state="' . ($search !== '' ? 'active' : 'inactive-selectable') . '">';
$html[] = '<input class="sg-interaction-element sg-input-single-line sg-search-field-input sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" placeholder="Suche" aria-label="Marktdurchführungen durchsuchen" value="' . auth_escape_html($search) . '" data-markets-search-input>';
$html[] = '<button class="sg-input-clear-button" type="button" aria-label="Eingabe löschen" data-markets-search-clear>×</button>';
$html[] = '</span></span></div>';
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--header" role="row" data-component-part="large-table-header-row">';
$headerIndex = 0;
foreach ($sortColumns as $column => $label) {
$isActive = $column === $sortColumn;
$headerDirection = $isActive ? strtolower($sortDirection) : 'none';
$iconDirection = $isActive ? strtolower($sortDirection) : 'ascending';
$targetDirection = $isActive ? ($sortDirection === 'ASC' ? 'DESC' : 'ASC') : ($column === 'earliest_date' ? 'ASC' : 'ASC');
$ariaLabel = $isActive ? $label . ' ' . ($sortDirection === 'ASC' ? 'aufsteigend sortiert' : 'absteigend sortiert') : $label . ' sortieren';
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="' . $headerDirection . '" data-sort-key="' . $headerIndex . '"><button class="sg-large-table__sort-button" type="button" aria-label="' . auth_escape_html($ariaLabel) . '" data-markets-sort-button="true" data-markets-sort-column="' . auth_escape_html($column) . '" data-markets-sort-direction="' . auth_escape_html($targetDirection) . '"><span class="sg-large-table__sort-label">' . auth_escape_html($label) . '</span><span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="' . auth_escape_html($iconDirection) . '"></span></button></div>';
$headerIndex++;
}
$html[] = '</div>';
foreach ($rows as $index => $row) {
$rowClass = 'sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row';
if ($index % 2 === 1) {
$rowClass .= ' sg-large-table__row--striped-light';
}
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row">';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) ($row['market_name'] ?? '')) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-market-drawer-open="true" data-market-occurrence-id="' . (int) ($row['id'] ?? 0) . '">' . auth_escape_html(auth_format_market_date($row['earliest_date'] ?? null, $row['latest_date'] ?? null)) . '</a></div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) ($row['place'] ?? '')) . '</div>';
$status = (string) ($row['status'] ?? '');
$statusSignalClass = trim(auth_market_status_signal_class($status));
$statusValue = '<span' . ($statusSignalClass !== '' ? ' class="' . auth_escape_html($statusSignalClass) . '"' : '') . '>' . auth_escape_html(auth_market_status_label($status)) . '</span>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . $statusValue . '</div>';
$html[] = '</div>';
}
if ($hasMore) {
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--load-more" role="row" data-component-part="large-table-row"><div class="sg-large-table__cell sg-large-table__cell--load-more" role="cell"><div class="sg-navigation-card-layout sg-large-table__load-more-layout"><div class="sg-navigation-card-block"><article class="sg-card" data-component="card" data-pattern="navigation-card" aria-label="Mehr laden"><div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-pattern-part="navigation-card-segment"><div class="sg-navigation-card-center"><a class="sg-hyperlink" href="#" data-component="hyperlink" data-markets-load-more-trigger="true">Mehr laden</a></div></div></article></div></div></div></div>';
}
$html[] = '</article></section>';
return implode('', $html);
}
function auth_render_markets_content(array $marketTable): string
{
return '<div data-markets-content="true" data-market-csrf-token="' . auth_escape_html(auth_csrf_token()) . '">' . auth_render_markets_large_table($marketTable)
. '<div class="sg-card-list-page-drawer-backdrop" aria-hidden="true" data-market-drawer-backdrop="true" data-open="false"></div>'
. '<aside class="sg-card-list-page-drawer" aria-label="Marktdurchführung-Details" aria-hidden="true" data-market-drawer="true" data-open="false">'
. '<div class="sg-card-list-page-drawer__header"><h2 class="sg-heading-h2 sg-card-list-page-drawer__title" data-market-drawer-title>Marktdurchführung</h2></div>'
. '<div class="sg-card-list-page-drawer__content" data-market-drawer-content><p class="sg-body">Marktdetails werden geladen …</p></div>'
. '</aside></div>';
}
function auth_render_bestellungen_large_table(array $bestellungenTable): string
{
$rows = $bestellungenTable['rows'] ?? [];
@@ -298,30 +189,6 @@ function auth_render_bestellungen_content(array $bestellungenTable): string
function auth_render_lager_table(string $title, array $rows, array $columns, string $tableKey): string
{
$renderForecast = static function (?string $date, string $status, ?string $expiryDate, bool $available): array {
if (!$available) {
return ['text' => '-', 'warning' => false, 'mhdWarning' => false];
}
if ($date === null || $date === '') {
return ['text' => 'Keine Prognose', 'warning' => false, 'mhdWarning' => false];
}
$dateObject = new DateTimeImmutable($date);
$text = $dateObject->format('d.m.Y');
if ($status === 'depleted') {
return ['text' => $text . ' (BESTAND ENDE)', 'warning' => true, 'mhdWarning' => false];
}
$warning = false;
$mhdWarning = false;
if ($expiryDate !== null && $expiryDate !== '') {
$expiryObject = new DateTimeImmutable($expiryDate);
$warning = $dateObject >= $expiryObject->modify('-3 months') && $dateObject <= $expiryObject;
$mhdWarning = $dateObject > $expiryObject;
if ($warning || $mhdWarning) {
$text .= ' (MHD: ' . $expiryObject->format('d.m.Y') . ')';
}
}
return ['text' => $text, 'warning' => $warning, 'mhdWarning' => $mhdWarning];
};
$html = [];
$html[] = '<article class="sg-card sg-large-table sg-large-table--lager" data-component="large-table" data-lager-table="true" data-lager-table-key="' . auth_escape_html($tableKey) . '" role="table" aria-label="' . auth_escape_html($title) . '">';
$html[] = '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header"><div class="sg-strong">' . auth_escape_html($title) . '</div></div>';
@@ -342,32 +209,41 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
$value = $tableKey === 'products'
? (string) max(0, (int) round((float) ($row['available_qty'] ?? 0)))
: (string) ($row['item_code'] ?? '');
$currentForecast = ['text' => '', 'warning' => false, 'mhdWarning' => false];
$nextForecast = ['text' => '-', 'warning' => false, 'mhdWarning' => false];
$forecastText = '';
$forecastWarning = false;
$forecastMhdWarning = false;
if ($tableKey === 'products') {
$currentForecast = $renderForecast(
$row['sellout_date'] ?? null,
(string) ($row['sellout_forecast_status'] ?? 'no_demand'),
$row['expiry_date'] ?? null,
true
);
$nextForecast = $renderForecast(
$row['next_sellout_date'] ?? null,
(string) ($row['next_sellout_forecast_status'] ?? 'no_demand'),
$row['next_expiry_date'] ?? null,
$row['next_lot_id'] !== null
);
$forecastStatus = (string) ($row['sellout_forecast_status'] ?? 'no_demand');
$forecastDate = $row['sellout_date'] ?? null;
if ($forecastDate !== null && $forecastDate !== '') {
$forecastDateObject = new DateTimeImmutable((string) $forecastDate);
$forecastText = $forecastDateObject->format('d.m.Y');
if ($forecastStatus === 'depleted') {
$forecastWarning = true;
$forecastText .= ' (CHARGE ENDE)';
} elseif (!empty($row['expiry_date'])) {
$expiryDate = new DateTimeImmutable((string) $row['expiry_date']);
$forecastWarning = $forecastDateObject >= $expiryDate->modify('-3 months')
&& $forecastDateObject <= $expiryDate;
$forecastMhdWarning = $forecastDateObject > $expiryDate;
if ($forecastWarning || $forecastMhdWarning) {
$forecastText .= ' (MHD: ' . $expiryDate->format('d.m.Y') . ')';
}
}
} else {
$forecastText = 'Keine Prognose';
}
}
$nameData = ' data-lager-drawer-name="' . auth_escape_html($name) . '"';
$valueData = ' data-lager-drawer-value="' . auth_escape_html($value) . '"';
$typeData = ' data-lager-drawer-type="' . auth_escape_html($tableKey === 'products' ? 'Produkt' : 'Artikel') . '"';
$detailData = '';
if ($tableKey === 'products') {
$detailData = ' data-lager-drawer-forecast="' . auth_escape_html($currentForecast['text']) . '"'
. ' data-lager-drawer-forecast-status="' . auth_escape_html((string) ($row['sellout_forecast_status'] ?? 'no_demand')) . '"'
. ' data-lager-drawer-forecast-warning="' . ($currentForecast['warning'] ? 'true' : 'false') . '"'
. ' data-lager-drawer-forecast-mhd-warning="' . ($currentForecast['mhdWarning'] ? 'true' : 'false') . '"'
. ' data-lager-drawer-current-qty="' . auth_escape_html(auth_format_lager_quantity((float) ($row['current_qty'] ?? 0))) . '"'
$detailData = ' data-lager-drawer-forecast="' . auth_escape_html($forecastText) . '"'
. ' data-lager-drawer-forecast-status="' . auth_escape_html($forecastStatus) . '"'
. ' data-lager-drawer-forecast-warning="' . ($forecastWarning ? 'true' : 'false') . '"'
. ' data-lager-drawer-forecast-mhd-warning="' . ($forecastMhdWarning ? 'true' : 'false') . '"'
. ' data-lager-drawer-current-qty="' . auth_escape_html(auth_format_lager_quantity((float) ($row['available_qty'] ?? 0))) . '"'
. ' data-lager-drawer-qty-in="' . auth_escape_html(auth_format_lager_quantity((float) ($row['qty_in'] ?? 0))) . '"'
. ' data-lager-drawer-virtual-shortage-qty="' . auth_escape_html(auth_format_lager_quantity((float) ($row['virtual_shortage_qty'] ?? 0))) . '"'
. ' data-lager-drawer-lot-id="' . auth_escape_html((string) ($row['lot_id'] ?? '')) . '"'
@@ -391,20 +267,12 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row" data-lager-row="true" data-lager-id="' . $id . '">';
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($name) . '</a></div>';
$valueCell = $tableKey === 'products'
? '<div class="sg-large-table__cell" role="cell">' . auth_escape_html($value) . '</div>'
: '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($value) . '</a></div>';
$html[] = $valueCell;
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($value) . '</a></div>';
if ($tableKey === 'products') {
$forecastClass = $currentForecast['warning']
$forecastClass = $forecastWarning
? ' sg-strong lager-forecast-warning'
: ($currentForecast['mhdWarning'] ? ' sg-strong lager-forecast-mhd-warning' : '');
$nextForecastClass = $nextForecast['warning']
? ' sg-strong lager-forecast-warning'
: ($nextForecast['mhdWarning'] ? ' sg-strong lager-forecast-mhd-warning' : '');
$html[] = '<div class="sg-large-table__cell' . $forecastClass . '" role="cell">' . auth_escape_html($currentForecast['text']) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) max(0, (int) round((float) ($row['next_qty'] ?? 0)))) . '</div>';
$html[] = '<div class="sg-large-table__cell' . $nextForecastClass . '" role="cell">' . auth_escape_html($nextForecast['text']) . '</div>';
: ($forecastMhdWarning ? ' sg-strong lager-forecast-mhd-warning' : '');
$html[] = '<div class="sg-large-table__cell' . $forecastClass . '" role="cell">' . auth_escape_html($forecastText) . '</div>';
}
$html[] = '</div>';
}
@@ -434,7 +302,7 @@ function auth_render_lager_historical_lots(array $lagerProducts): string
$html = [];
foreach ($lagerProducts as $product) {
$productId = (int) ($product['id'] ?? 0);
$html[] = '<div data-lager-historical-product="' . $productId . '" hidden><div class="sg-content-group-box" data-component="content-group-box">';
$html[] = '<div data-lager-historical-product="' . $productId . '" hidden>';
foreach (($product['historical_lots'] ?? []) as $lot) {
$qtyNet = (float) ($lot['qty_net'] ?? 0);
$emptyDate = $qtyNet <= 0 ? auth_format_lager_date($lot['last_out_at'] ?? null) : 'Nicht eindeutig';
@@ -452,7 +320,7 @@ function auth_render_lager_historical_lots(array $lagerProducts): string
. '<strong>Hersteller-Chargennummer:</strong> ' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '</p>'
. '</div></article>';
}
$html[] = '</div></div>';
$html[] = '</div>';
}
return implode('', $html);
@@ -463,22 +331,12 @@ function auth_render_lager_open_lots(array $lagerProducts, string $manufacturerO
$html = [];
foreach ($lagerProducts as $product) {
$productId = (int) ($product['id'] ?? 0);
$html[] = '<div data-lager-open-product="' . $productId . '" hidden><div class="sg-content-group-box" data-component="content-group-box" data-lager-open-lot-list>';
$html[] = '<div data-lager-open-product="' . $productId . '" hidden>';
foreach (($product['open_lots'] ?? []) as $lot) {
$lotId = (int) ($lot['lot_id'] ?? 0);
$selectedClass = !empty($lot['next_active']) ? ' sg-lager-open-lot--next-active' : '';
$selectedText = !empty($lot['next_active']) ? 'Als nächste aktive Charge ausgewählt' : 'Nicht ausgewählt';
$isQualified = trim((string) ($lot['lot_number'] ?? '')) !== ''
&& (int) ($lot['manufacturer_party_id'] ?? 0) > 0
&& trim((string) ($lot['manufacturer_lot_number'] ?? '')) !== ''
&& trim((string) ($lot['mfg_date'] ?? '')) !== ''
&& trim((string) ($lot['expiry_date'] ?? '')) !== ''
&& trim((string) ($lot['purchase_date'] ?? '')) !== ''
&& (float) ($lot['qty_net'] ?? 0) > 0;
$selectButton = '<button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-select data-lager-open-selectable="' . ($isQualified ? 'true' : 'false') . '"' . ($isQualified && empty($lot['next_active']) ? '' : ' disabled aria-disabled="true"') . '>Als nächste aktive auswählen</button>';
$html[] = '<div class="sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host' . $selectedClass . '" data-pattern="overlay-card" data-dialog-open="false" data-lager-open-card-wrapper="' . $lotId . '"><article class="sg-card sg-card--content-card sg-delete-confirmation-pattern__target" data-component="content-card" data-pattern="overlay-card" aria-label="Offene Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Offene Charge</div><div class="sg-component-row">' . $selectButton . '<button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning" data-lager-open-delete>Löschen</button><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-edit-open>Bearbeiten</button></div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-open-card="' . (int) ($lot['lot_id'] ?? 0) . '"><p class="sg-body"><strong>Auswahl:</strong> <span data-lager-open-card-next-active>' . auth_escape_html($selectedText) . '</span><br><strong>Naurua-Chargennummer:</strong> <span data-lager-open-card-lot-number>' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '</span><br>'
$html[] = '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-open-card-wrapper="' . $lotId . '" aria-label="Offene Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Offene Charge</div><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-edit-open>Bearbeiten</button></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-open-card="' . (int) ($lot['lot_id'] ?? 0) . '"><p class="sg-body"><strong>Naurua-Chargennummer:</strong> <span data-lager-open-card-lot-number>' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '</span><br>'
. '<strong>Status:</strong> Vorbereitet<br>'
. '<strong>Bestand:</strong> <span data-lager-open-card-qty-net>' . auth_format_lager_quantity((float) ($lot['qty_net'] ?? 0)) . '</span><br>'
. '<strong>Eingangsmenge:</strong> <span data-lager-open-card-qty-in>' . auth_format_lager_quantity((float) ($lot['qty_in'] ?? 0)) . '</span><br>'
@@ -489,18 +347,16 @@ function auth_render_lager_open_lots(array $lagerProducts, string $manufacturerO
. '<strong>Einkaufsdatum:</strong> <span data-lager-open-card-purchase-date>' . auth_format_lager_date($lot['purchase_date'] ?? null) . '</span></p></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-open-edit-form hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Offene Charge bearbeiten"><form class="sg-form-sections-card" data-lager-open-edit-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Offene Charge bearbeiten</h2>'
. '<section class="sg-form-sections-card__chapter" aria-labelledby="lager-open-charge-data-' . $lotId . '"><h2 id="lager-open-charge-data-' . $lotId . '" class="sg-strong sg-form-sections-card__chapter-title">Chargendaten</h2>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-open-lot-number value="' . auth_escape_html((string) ($lot['lot_number'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-open-lot-number required value="' . auth_escape_html((string) ($lot['lot_number'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller</span><select class="sg-interaction-element sg-input-single-line" data-lager-open-manufacturer>' . str_replace('value="' . (int) ($lot['manufacturer_party_id'] ?? 0) . '"', 'value="' . (int) ($lot['manufacturer_party_id'] ?? 0) . '" selected', $manufacturerOptions) . '</select></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-open-manufacturer-lot value="' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Herstellungsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-mfg-date value="' . auth_escape_html((string) ($lot['mfg_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">MHD</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-expiry-date value="' . auth_escape_html((string) ($lot['expiry_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Einkaufsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-purchase-date value="' . auth_escape_html((string) ($lot['purchase_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Menge</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="1" inputmode="numeric" data-lager-open-target-qty value="' . auth_escape_html((string) ($lot['qty_net'] ?? 0)) . '"></label></div>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-open-edit-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-open-edit-status aria-live="polite"></p></div></form></div></div></article>'
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Leere Felder bestätigen" role="dialog" aria-modal="true" aria-labelledby="lager-open-confirmation-title-' . $lotId . '" data-lager-open-confirmation-card hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-open-confirmation-title-' . $lotId . '"><strong>Leere Felder speichern?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Die folgenden Felder werden leer gespeichert:</p><div class="sg-delete-confirmation-pattern__list" data-lager-open-confirmation-empty-fields aria-label="Leer zu speichernde Felder"></div><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-confirmation-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process" type="button" data-lager-open-confirmation-submit>Speichern</button></div></div></article>'
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Löschbestätigung" role="dialog" aria-modal="true" aria-labelledby="lager-open-delete-title-' . $lotId . '" data-overlay-dialog="delete" data-lager-open-delete-confirmation hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-open-delete-title-' . $lotId . '"><strong>Möchtest du diese offene Charge wirklich löschen?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von <span class="sg-delete-confirmation-pattern__code">DELETE</span>.</p><label class="sg-labeled-input-row sg-delete-confirmation-pattern__input-row"><span class="sg-label">Bestätigung</span><input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" placeholder="DELETE" aria-label="Löschbestätigung durch DELETE" data-lager-open-delete-confirmation-input></label><p class="sg-body" data-lager-open-delete-status aria-live="polite"></p><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-delete-confirmation-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive" type="button" disabled aria-disabled="true" data-lager-open-delete-confirmation-submit>Löschen</button></div></div></article></div>';
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Zugang buchen</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="1" inputmode="numeric" data-lager-open-receipt-qty placeholder="Menge"></label></div>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-open-edit-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-open-edit-status aria-live="polite"></p></div></form></div></div></article>';
}
$html[] = '</div></div>';
$html[] = '</div>';
}
return implode('', $html);
@@ -514,24 +370,8 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
}
$tables = $sectionTitle === 'Lager'
? auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Lager aktuell', 'Verkauf bis aktuell', 'Lager neu', 'Verkauf bis neu'], 'products')
? auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products')
: auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles');
$openLotCardTemplate = auth_render_lager_open_lots([[
'id' => 0,
'open_lots' => [[
'lot_id' => 0,
'lot_number' => null,
'manufacturer_party_id' => null,
'manufacturer_name' => null,
'manufacturer_lot_number' => null,
'mfg_date' => null,
'expiry_date' => null,
'purchase_date' => null,
'qty_net' => 0,
'qty_in' => 0,
'next_active' => false,
]],
]], $manufacturerOptions);
return '<div data-lager-content="true" data-lager-csrf-token="' . auth_escape_html($csrfToken) . '">'
. '<section class="sg-card-list-page" aria-label="' . auth_escape_html($sectionTitle) . '">'
@@ -539,11 +379,11 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
. '</section>'
. '<div class="sg-card-list-page-drawer-backdrop" aria-hidden="true" data-lager-drawer-backdrop="true" data-open="false"></div>'
. '<aside class="sg-card-list-page-drawer" aria-label="Lager-Details" aria-hidden="true" data-lager-drawer="true" data-open="false">'
. '<div class="sg-card-list-page-drawer__header"><h2 class="sg-heading-h2 sg-card-list-page-drawer__title" data-lager-drawer-title>Lager-Details</h2></div>'
. '<div class="sg-card-list-page-drawer__header"><h2 class="sg-heading-h2 sg-card-list-page-drawer__title" data-lager-drawer-title>Lager-Details</h2><button class="sg-interaction-element sg-hyperlink" type="button" data-lager-drawer-close>schliessen</button></div>'
. '<div class="sg-card-list-page-drawer__content">'
. '<article class="sg-card sg-vsf-drawer-card" data-component="card" data-pattern="card" aria-label="Lager-Details">'
. '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-vsf-drawer-card__header"><div class="sg-strong">Lager-Details</div></header>'
. '<div class="sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content"><p class="sg-body"><strong>Name:</strong> <span data-lager-drawer-name-value></span></p><p class="sg-body" data-lager-drawer-value-row><strong data-lager-drawer-value-label>Wert:</strong> <span data-lager-drawer-value-value></span><br><strong>Voraussichtlich leer:</strong> <span data-lager-drawer-forecast-value>Keine Prognose</span></p></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content"><p class="sg-body"><strong>Name:</strong> <span data-lager-drawer-name-value></span></p><p class="sg-body" data-lager-drawer-value-row><strong data-lager-drawer-value-label>Wert:</strong> <span data-lager-drawer-value-value></span></p></div>'
. '</article>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="warning" aria-label="Warnungen">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header" data-lager-drawer-warning-header><div class="sg-strong sg-text-on-dark" data-lager-drawer-warning-title>Keine Warnung</div></div>'
@@ -554,7 +394,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray"><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Neue Charge anlegen"><form class="sg-form-sections-card" data-lager-new-lot-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Neue Charge anlegen</h2>'
. '<div class="sg-notifications-pattern" data-lager-new-lot-notification-wrapper hidden><article class="sg-card sg-notifications-pattern__card" data-pattern="notification-small" data-component="notification-card"><div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green sg-notifications-pattern__text-segment sg-notifications-pattern__text-segment--small" data-component-part="card-header"><p class="sg-body" data-lager-new-lot-notification></p></div></article></div>'
. '<section class="sg-form-sections-card__chapter" aria-labelledby="lager-new-charge-data-title"><h2 id="lager-new-charge-data-title" class="sg-strong sg-form-sections-card__chapter-title">Chargendaten</h2>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-new-lot-number></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-new-lot-number required></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller</span><select class="sg-interaction-element sg-input-single-line" data-lager-new-manufacturer>' . $manufacturerOptions . '</select></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-new-manufacturer-lot></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Herstellungsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-new-mfg-date></label></div>'
@@ -562,9 +402,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Einkaufsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-new-purchase-date></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Eingangsmenge</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="1" inputmode="numeric" data-lager-new-receipt-qty placeholder="Menge"></label></div>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-new-lot-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-new-lot-status aria-live="polite"></p></div></form></div></div>'
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Leere Felder bestätigen" role="dialog" aria-modal="true" aria-labelledby="lager-new-confirmation-title" data-lager-new-confirmation-card hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-new-confirmation-title"><strong>Leere Felder speichern?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Die folgenden Felder werden leer gespeichert:</p><div class="sg-delete-confirmation-pattern__list" data-lager-new-confirmation-empty-fields aria-label="Leer zu speichernde Felder"></div><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-new-confirmation-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process" type="button" data-lager-new-confirmation-submit>Speichern</button></div></div></article>'
. '</article>'
. '<template data-lager-open-lot-template>' . $openLotCardTemplate . '</template>'
. '<div data-lager-drawer-topic="open-lots" aria-label="Offene Chargen">' . auth_render_lager_open_lots($lagerProducts, $manufacturerOptions) . '</div>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="lot" aria-label="Aktive Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Aktive Charge</div><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-edit-open>Bearbeiten</button></div>'
@@ -589,14 +427,14 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
. '<div data-lager-drawer-topic="historical-lots" aria-label="Vergangene Chargen">' . auth_render_lager_historical_lots($lagerProducts) . '</div>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="virtual-lot" aria-label="Virtuelle Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Virtuelle Charge</div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><p class="sg-body"><span data-lager-drawer-virtual-lot-value></span><br><span data-lager-drawer-virtual-lot-qty></span></p></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><p class="sg-body" data-lager-drawer-virtual-lot-value></p></div>'
. '</article>'
. '</div>'
. '</aside>'
. '</div>';
}
function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $marketOccurrencesTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void
function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void
{
$otcProductRows = '';
foreach ($otcProducts as $product) {
@@ -610,9 +448,9 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
$disabledAttribute = $hasSellableMapping ? '' : ' disabled aria-disabled="true"';
$otcProductRows .= '<div class="sg-form-sections-card__field-group">';
$otcProductRows .= '<label class="sg-labeled-input-row sg-otc-order-product-row"><span class="sg-label">' . $productName . ($productSku !== '' ? ' (' . $productSku . ')' : '') . $mappingNote . '</span>';
$otcProductRows .= '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="number" id="' . $inputId . '" min="0" step="1" data-otc-order-product data-product-id="' . $productId . '" data-title="' . $productName . '" data-available-qty="' . $availableQty . '"' . $disabledAttribute . '>';
$otcProductRows .= '</label>';
$otcProductRows .= '<label class="sg-label" for="' . $inputId . '">' . $productName . ($productSku !== '' ? ' (' . $productSku . ')' : '') . $mappingNote . '</label>';
$otcProductRows .= '<input class="sg-interaction-element sg-input-single-line" type="number" id="' . $inputId . '" min="0" step="1" data-otc-order-product data-product-id="' . $productId . '" data-title="' . $productName . '" data-available-qty="' . $availableQty . '"' . $disabledAttribute . '>';
$otcProductRows .= '<p class="sg-body sg-form-sections-card__sentence">Physischer Bestand: ' . $availableQty . '</p>';
$otcProductRows .= '</div>';
}
@@ -624,19 +462,16 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
[
'Übersicht' => [],
'ERP' => ['Bestellungen', 'Lager', 'Produkte', 'Artikel', 'Kontakte'],
'Märkte' => ['Märkte', 'Konfiguration'],
'Buchhaltung' => [],
'Kundenberatung' => [],
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$bestellungenContent = auth_render_bestellungen_content($bestellungenTable);
$marketsContent = auth_render_markets_content($marketOccurrencesTable);
$lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel, $lagerManufacturers, auth_csrf_token());
$moduleContentCards = json_encode(
[
'Bestellungen' => $bestellungenContent,
'Märkte' => $marketsContent,
'Lager' => $lagerContent,
'Artikel' => auth_render_lager_content([], $lagerArtikel, $lagerManufacturers, auth_csrf_token(), 'Artikel'),
],
@@ -646,7 +481,6 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
[
'Übersicht' => 'Willkommen bei Naurua Übersicht',
'ERP' => 'Willkommen bei Naurua ERP',
'Märkte' => 'Willkommen bei Naurua Märkte',
'Buchhaltung' => 'Willkommen bei Naurua Buchhaltung',
'Kundenberatung' => 'Willkommen bei Naurua Kundenberatung',
],
@@ -684,7 +518,6 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<nav class="sg-portal-header__tabs sg-tab-button-group" aria-label="Hauptnavigation" data-component="tab-navigation" data-component-size="large" data-component-context="portal-header" data-pattern-part="portal-header-navigation">';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Übersicht</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="true" data-component-part="tab-button" data-component-state="active">ERP</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Märkte</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Buchhaltung</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Kundenberatung</button>';
echo '</nav>';
@@ -722,14 +555,14 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '</section>';
echo '<section class="sg-otc-order-overlay" data-otc-order-overlay data-open="false" aria-hidden="true">';
echo '<div class="sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host sg-object-card" data-pattern="overlay-card" data-dialog-open="false" data-otc-confirmation-stage>';
echo '<article class="sg-card sg-scrollable-card sg-delete-confirmation-pattern__target sg-otc-order-overlay__panel" data-pattern="scrollable-card" aria-labelledby="otc-order-modal-title" role="dialog" aria-modal="true">';
echo '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-scrollable-card__header sg-object-card__header" data-pattern-part="scrollable-card-header">';
echo '<article class="sg-card sg-object-card sg-object-card--variable-height sg-otc-order-overlay__panel sg-delete-confirmation-pattern__target" data-pattern="object-card" aria-labelledby="otc-order-modal-title" role="dialog" aria-modal="true">';
echo '<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-object-card__header">';
echo '<h2 id="otc-order-modal-title" class="sg-strong sg-otc-order-overlay__title">Neuen OTC-Verkauf erfassen</h2>';
echo '<button class="sg-interaction-element sg-button sg-button--active sg-otc-order-overlay__close" type="button" data-otc-order-close>Schliessen</button>';
echo '</header>';
echo '<div class="sg-card-segment sg-card-segment--gray sg-scrollable-card__content sg-object-card__content" data-pattern-part="scrollable-card-content" tabindex="0" aria-label="Scrollbarer Formularinhalt">';
echo '<div class="sg-card-segment sg-card-segment--gray sg-object-card__content">';
echo '<div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Formular mit Abschnitten">';
echo '<form id="otc-order-form" class="sg-form-sections-card" action="#" method="post" data-otc-order-form aria-label="Neuen OTC-Verkauf erfassen">';
echo '<form class="sg-form-sections-card" action="#" method="post" data-otc-order-form aria-label="Neuen OTC-Verkauf erfassen">';
echo '<div class="sg-form-sections-card__body" data-pattern-part="form-body">';
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-products-title">';
echo '<h2 id="otc-products-title" class="sg-strong sg-form-sections-card__chapter-title">PRODUKTE</h2>';
@@ -738,23 +571,20 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-order-date-title">';
echo '<h2 id="otc-order-date-title" class="sg-strong sg-form-sections-card__chapter-title">Datum</h2>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Bestelldatum</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="date" id="otc-order-date" value="' . htmlspecialchars(date('Y-m-d'), ENT_QUOTES, 'UTF-8') . '" data-otc-order-date>';
echo '</label>';
echo '<label class="sg-label" for="otc-order-date">Bestelldatum</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="date" id="otc-order-date" value="' . htmlspecialchars(date('Y-m-d'), ENT_QUOTES, 'UTF-8') . '" data-otc-order-date>';
echo '</div>';
echo '</section>';
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-payment-title">';
echo '<h2 id="otc-payment-title" class="sg-strong sg-form-sections-card__chapter-title">Bezahlung</h2>';
echo '<div data-otc-payment-rows>';
echo '<div class="sg-form-sections-card__field-group" data-otc-payment-row>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Bezahlart</span>';
echo '<select class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable sg-pulldown" id="otc-payment-method-1" data-otc-payment-method>';
echo '<label class="sg-label" for="otc-payment-method-1">Bezahlart</label>';
echo '<select class="sg-interaction-element sg-input-single-line" id="otc-payment-method-1" data-otc-payment-method>';
echo '<option value="twint">Twint</option><option value="card">Kreditkarte</option><option value="cash">Barzahlung</option><option value="paypal">PayPal</option><option value="bank_transfer">Überweisung</option>';
echo '</select>';
echo '</label>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Betrag (CHF)</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="number" id="otc-payment-amount-1" min="0.01" step="0.01" value="0.00" data-otc-payment-amount>';
echo '</label>';
echo '<label class="sg-label" for="otc-payment-amount-1">Betrag (CHF)</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="number" id="otc-payment-amount-1" min="0.01" step="0.01" value="0.00" data-otc-payment-amount>';
echo '</div>';
echo '</div>';
echo '<div class="sg-form-sections-card__actions-segment">';
@@ -764,49 +594,43 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-billing-title">';
echo '<h2 id="otc-billing-title" class="sg-strong sg-form-sections-card__chapter-title">Rechnungsadresse</h2>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Vorname</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-first-name" value="Fabienne" data-otc-order-first-name>';
echo '</label>';
echo '<label class="sg-label" for="otc-first-name">Vorname</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-first-name" value="Fabienne" data-otc-order-first-name>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Nachname</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-last-name" value="Föhn" data-otc-order-last-name>';
echo '</label>';
echo '<label class="sg-label" for="otc-last-name">Nachname</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-last-name" value="Föhn" data-otc-order-last-name>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Strasse</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-street" value="Im Hochrain" data-otc-order-street>';
echo '</label>';
echo '<label class="sg-label" for="otc-street">Strasse</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-street" value="Im Hochrain" data-otc-order-street>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Hausnummer</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-house-number" value="2" data-otc-order-house-number>';
echo '</label>';
echo '<label class="sg-label" for="otc-house-number">Hausnummer</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-house-number" value="2" data-otc-order-house-number>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">PLZ</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-zip" value="8102" data-otc-order-zip>';
echo '</label>';
echo '<label class="sg-label" for="otc-zip">PLZ</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-zip" value="8102" data-otc-order-zip>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Ort</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-city" value="Oberengstringen" data-otc-order-city>';
echo '</label>';
echo '<label class="sg-label" for="otc-city">Ort</label>';
echo '<input class="sg-interaction-element sg-input-single-line" type="text" id="otc-city" value="Oberengstringen" data-otc-order-city>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<div class="sg-body sg-otc-order-form__status hidden" data-otc-order-success>Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert.</div>';
echo '<div class="sg-body sg-otc-order-form__status sg-otc-order-form__status--error hidden" data-otc-order-error></div>';
echo '</div>';
echo '</div>';
echo '<footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment">';
echo '<div class="sg-form-sections-card__actions" data-pattern-part="form-actions">';
echo '<button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-otc-order-close>Abbrechen</button>';
echo '<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive sg-form-sections-card__action" type="submit" disabled aria-disabled="true" data-otc-order-submit>Weiter</button>';
echo '</div>';
echo '</footer>';
echo '</form>';
echo '</div>';
echo '</div>';
echo '<footer class="sg-card-segment sg-card-segment--body sg-scrollable-card__actions-segment" data-pattern-part="scrollable-card-actions">';
echo '<div class="sg-scrollable-card__actions">';
echo '<button class="sg-interaction-element sg-button sg-button--active" type="button" form="otc-order-form" data-otc-order-close>Abbrechen</button>';
echo '<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive" type="submit" form="otc-order-form" disabled aria-disabled="true" data-otc-order-submit>Weiter</button>';
echo '</div>';
echo '</footer>';
echo '</article>';
echo '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Verkaufsbestätigung" role="dialog" aria-modal="true" aria-labelledby="otc-confirmation-title" data-otc-confirmation-card hidden>';
echo '<div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body">';
@@ -843,7 +667,6 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (contentTitle) { contentTitle.textContent = activeEntry; }";
echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[activeEntry] || ''; }";
echo " if (activeEntry === 'Bestellungen') { initBestellungenBindings(); }";
echo " if (activeEntry === 'Märkte') { initMarketsBindings(); }";
echo " if (activeEntry === 'Lager' || activeEntry === 'Artikel') { initLagerBindings(); }";
echo " menu.innerHTML = '';";
echo " entries.forEach((entry, index) => {";
@@ -865,7 +688,6 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (contentTitle) { contentTitle.textContent = entryName; }";
echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[entryName] || ''; }";
echo " if (entryName === 'Bestellungen') { initBestellungenBindings(); }";
echo " if (entryName === 'Märkte') { initMarketsBindings(); }";
echo " if (entryName === 'Lager' || entryName === 'Artikel') { initLagerBindings(); }";
echo " });";
echo " });";
@@ -953,13 +775,30 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " lagerBindingsInstalled = true;";
echo " let lagerMobileDetailHost = null;";
echo " let lagerMobileDetailOpen = false;";
echo " const renderLagerMobileDetail = (sourceDrawer, trigger) => { const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; sourceDrawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; sourceDrawer.querySelector('[data-lager-drawer-name-value]').textContent = name; sourceDrawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; sourceDrawer.querySelector('[data-lager-drawer-value-value]').textContent = value; sourceDrawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); const newLotForm = sourceDrawer.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); if (newLotForm) { newLotForm.hidden = true; } const newLotAction = sourceDrawer.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newLotAction) { newLotAction.hidden = false; } if (type === 'Produkt') { const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = sourceDrawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? (trigger.dataset.lagerDrawerForecast || 'Keine Prognose') : 'Keine akute Warnung.'; } const warningTitle = sourceDrawer.querySelector('[data-lager-drawer-warning-title]'); if (warningTitle) { warningTitle.textContent = getLagerWarningTitle(trigger); } sourceDrawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-current-qty]').textContent = trigger.dataset.lagerDrawerCurrentQty || '0'; sourceDrawer.querySelector('[data-lager-drawer-qty-in]').textContent = trigger.dataset.lagerDrawerQtyIn || '0'; sourceDrawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; sourceDrawer.querySelectorAll('[data-lager-historical-product]').forEach((product) => { product.hidden = product.dataset.lagerHistoricalProduct !== productId; }); sourceDrawer.querySelectorAll('[data-lager-open-product]').forEach((product) => { product.hidden = product.dataset.lagerOpenProduct !== productId; product.querySelectorAll('[data-lager-open-edit-form]').forEach((form) => { form.hidden = true; }); product.querySelectorAll('[data-lager-open-card]').forEach((readView) => { readView.hidden = false; }); }); resetLagerNewLotConfirmation(); resetLagerOpenLotConfirmations(); sourceDrawer.dataset.lagerProductId = productId; populateLagerActiveLotForm(sourceDrawer, trigger); } const host = document.createElement('div'); host.className = 'sg-lager-mobile-detail-host'; host.dataset.lagerMobileDetailHost = 'true'; host.innerHTML = '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\"><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button></div></div></article></div></div>'; const mobileDrawer = sourceDrawer; mobileDrawer.dataset.lagerMobileDrawer = 'true'; mobileDrawer.dataset.open = 'true'; mobileDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); return host; };";
echo " const escapeLagerHtml = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " const renderLagerMobileDetail = (trigger) => {";
echo " const type = trigger.dataset.lagerDrawerType || 'Lager';";
echo " const name = trigger.dataset.lagerDrawerName || '';";
echo " const value = trigger.dataset.lagerDrawerValue || '';";
echo " const isProduct = type === 'Produkt';";
echo " const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose';";
echo " const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true';";
echo " const warningText = warning ? forecast : 'Keine akute Warnung.';";
echo " const lotNumber = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt';";
echo " const manufacturerName = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt';";
echo " const manufacturerLotNumber = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt';";
echo " const mfgDate = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt';";
echo " const expiryDate = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt';";
echo " const purchaseDate = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt';";
echo " const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || '';";
echo " return ['<div class=\"sg-lager-mobile-detail-host\" data-lager-mobile-detail-host=\"true\">', '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\">', '<div class=\"sg-navigation-card-block\">', '<article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\">', '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\">', '<div class=\"sg-navigation-card-center\">', '<button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button>', '</div></div></article></div></div>', '<article class=\"sg-card sg-vsf-drawer-card\" data-component=\"card\" data-pattern=\"card\" aria-label=\"Lager-Details\">', '<header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkblue\"><div class=\"sg-strong\">' + escapeLagerHtml(type) + ': ' + escapeLagerHtml(name) + '</div></header>', '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\"><p class=\"sg-body\"><strong>Name:</strong> ' + escapeLagerHtml(name) + '</p><p class=\"sg-body\"><strong>' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + '</strong> ' + escapeLagerHtml(value) + '</p>' + (isProduct ? '<p class=\"sg-body\"><strong>Warnungen:</strong> ' + escapeLagerHtml(warningText) + '</p><p class=\"sg-body\"><strong>Naurua-Chargennummer:</strong> ' + escapeLagerHtml(lotNumber) + '<br><strong>Hersteller:</strong> ' + escapeLagerHtml(manufacturerName) + '<br><strong>Hersteller-Chargennummer:</strong> ' + escapeLagerHtml(manufacturerLotNumber) + '<br><strong>Herstellungsdatum:</strong> ' + escapeLagerHtml(mfgDate) + '<br><strong>MHD:</strong> ' + escapeLagerHtml(expiryDate) + '<br><strong>Einkaufsdatum:</strong> ' + escapeLagerHtml(purchaseDate) + '</p><p class=\"sg-body\"><strong>Virtuelle Charge:</strong> ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '</p>' : '') + '</div></article></div>'].join('');";
echo " };";
echo " const restoreLagerMobileDetail = () => {";
echo " if (lagerMobileDetailHost && lagerMobileDetailHost.parentNode) { const mobileDrawer = lagerMobileDetailHost.querySelector('[data-lager-mobile-drawer]'); if (mobileDrawer) { contentRoot.appendChild(mobileDrawer); mobileDrawer.removeAttribute('data-lager-mobile-drawer'); mobileDrawer.dataset.open = 'false'; mobileDrawer.setAttribute('aria-hidden', 'true'); } lagerMobileDetailHost.parentNode.removeChild(lagerMobileDetailHost); }";
echo " if (lagerMobileDetailHost && lagerMobileDetailHost.parentNode) { lagerMobileDetailHost.parentNode.removeChild(lagerMobileDetailHost); }";
echo " lagerMobileDetailHost = null; lagerMobileDetailOpen = false; document.body.classList.remove('sg-lager-mobile-detail-open');";
echo " };";
echo " const openLagerMobileDetail = (trigger) => {";
echo " restoreLagerMobileDetail(); const sourceDrawer = contentRoot.querySelector('[data-lager-drawer]'); lagerMobileDetailHost = sourceDrawer ? renderLagerMobileDetail(sourceDrawer, trigger) : null; const mobileDrawer = lagerMobileDetailHost?.querySelector('[data-lager-mobile-drawer]'); if (mobileDrawer) { const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; const virtualLotValue = mobileDrawer.querySelector('[data-lager-drawer-virtual-lot-value]'); if (virtualLotValue) { virtualLotValue.textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } }";
echo " restoreLagerMobileDetail(); const host = document.createElement('div'); host.innerHTML = renderLagerMobileDetail(trigger); lagerMobileDetailHost = host.firstElementChild;";
echo " if (lagerMobileDetailHost) { document.body.appendChild(lagerMobileDetailHost); document.body.classList.add('sg-lager-mobile-detail-open'); }";
echo " lagerMobileDetailOpen = true; window.history.pushState({ ...(window.history.state || {}), lagerView: 'detail' }, '', window.location.href);";
echo " };";
@@ -968,104 +807,38 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (window.history.state && window.history.state.lagerView === 'detail') { window.history.back(); return; }";
echo " restoreLagerMobileDetail();";
echo " };";
echo " const getDrawer = () => document.querySelector('[data-lager-mobile-drawer]') || contentRoot.querySelector('[data-lager-drawer]');";
echo " const populateLagerActiveLotForm = (drawer, trigger) => { if (!drawer || !trigger) { return; } const values = { '[data-lager-lot-number]': trigger.dataset.lagerDrawerLotNumber || '', '[data-lager-manufacturer]': trigger.dataset.lagerDrawerManufacturerPartyId || '', '[data-lager-manufacturer-lot]': trigger.dataset.lagerDrawerManufacturerLotNumber || '', '[data-lager-mfg-date]': trigger.dataset.lagerDrawerMfgDate || '', '[data-lager-expiry-date]': trigger.dataset.lagerDrawerExpiryDate || '', '[data-lager-purchase-date]': trigger.dataset.lagerDrawerPurchaseDate || '' }; Object.entries(values).forEach(([selector, value]) => { const field = drawer.querySelector(selector); if (field) { field.value = value; } }); drawer.dataset.lagerActiveLotId = trigger.dataset.lagerDrawerLotId || ''; };";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open][data-lager-drawer-type=\"Produkt\"]'); if (!trigger) { return; } const drawer = getDrawer(); const lotValue = drawer?.querySelector('[data-lager-drawer-virtual-lot-value]'); const qtyValue = drawer?.querySelector('[data-lager-drawer-virtual-lot-qty]'); if (lotValue) { lotValue.textContent = trigger.dataset.lagerDrawerVirtualLotNumber ? 'Virtuelle Charge: ' + trigger.dataset.lagerDrawerVirtualLotNumber : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } if (qtyValue) { qtyValue.textContent = 'Bereits virtuell verkauft: ' + (trigger.dataset.lagerDrawerVirtualShortageQty || '0'); } });";
echo " const getDrawer = () => contentRoot.querySelector('[data-lager-drawer]');";
echo " const setDrawerState = (open) => {";
echo " const drawer = getDrawer(); const backdrop = contentRoot.querySelector('[data-lager-drawer-backdrop]');";
echo " if (!drawer || !backdrop) { return; }";
echo " drawer.dataset.open = String(open); drawer.setAttribute('aria-hidden', String(!open)); backdrop.dataset.open = String(open); backdrop.setAttribute('aria-hidden', String(!open)); document.body.classList.toggle('sg-card-list-page-drawer-open', open);";
echo " };";
echo " const getLagerWarningTitle = (trigger) => trigger.dataset.lagerDrawerForecastStatus === 'depleted' ? 'Warnung: Bestand ist aufgebraucht' : (trigger.dataset.lagerDrawerForecastMhdWarning === 'true' ? 'Warnung: MHD wird vor Bestandsende erreicht' : (trigger.dataset.lagerDrawerForecastWarning === 'true' ? 'Warnung: Bestand wird innerhalb von drei Monaten aufgebraucht sein' : 'Keine Warnung'));";
echo " const getLagerWarningTitle = (trigger) => trigger.dataset.lagerDrawerForecastStatus === 'depleted' ? 'Warnung: Charge ist aufgebraucht' : (trigger.dataset.lagerDrawerForecastMhdWarning === 'true' ? 'Warnung: MHD wird vor Chargenende erreicht' : (trigger.dataset.lagerDrawerForecastWarning === 'true' ? 'Warnung: Charge wird innerhalb von drei Monaten aufgebraucht sein' : 'Keine Warnung'));";
echo " const closeDrawer = () => { setDrawerState(false); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((trigger) => trigger.setAttribute('aria-expanded', 'false')); };";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt') { return; } const drawer = getDrawer(); const forecast = drawer?.querySelector('[data-lager-drawer-forecast-value]'); if (forecast) { forecast.textContent = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; } });";
echo " const getLagerForecastWarningText = (forecast, trigger, text) => { if (forecast?.status === 'depleted' || trigger?.dataset.lagerDrawerForecastStatus === 'depleted') { return 'Der Bestand ist aufgebraucht. Voraussichtlich leer: ' + text + '.'; } if (trigger?.dataset.lagerDrawerForecastMhdWarning === 'true') { const expiry = trigger.dataset.lagerDrawerExpiryDate ? new Date(trigger.dataset.lagerDrawerExpiryDate + 'T00:00:00').toLocaleDateString('de-CH') : 'dem MHD'; const forecastDate = forecast?.date ? new Date(forecast.date + 'T00:00:00').toLocaleDateString('de-CH') : text.replace(/\\s+\\(MHD:.*\\)$/, ''); return 'Das MHD wird am ' + expiry + ' erreicht, bevor der Bestand leer ist. Voraussichtlich leer: ' + forecastDate + '.'; } if (trigger?.dataset.lagerDrawerForecastWarning === 'true') { return 'Voraussichtlich leer: ' + text + '.'; } return 'Keine akute Warnung.'; };";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt') { return; } const drawer = getDrawer(); const warningValue = drawer?.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = getLagerForecastWarningText(null, trigger, trigger.dataset.lagerDrawerForecast || 'Keine Prognose'); } });";
echo " const syncLagerForecast = (forecast, productId) => { const drawer = getDrawer(); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]') || contentRoot.querySelector('[data-lager-row][data-lager-id=\"' + (productId || '') + '\"] [data-lager-drawer-open]'); if (!forecast || !drawer) { return; } const forecastDate = forecast.date ? new Date(forecast.date + 'T00:00:00') : null; const expiryDate = forecast.expiryDate ? new Date(forecast.expiryDate + 'T00:00:00') : null; const mhdWarning = Boolean(forecastDate && expiryDate && forecastDate > expiryDate); const nearExpiryWarning = Boolean(forecastDate && expiryDate && forecastDate >= new Date(new Date(expiryDate).setMonth(expiryDate.getMonth() - 3)) && forecastDate <= expiryDate); const depleted = forecast.status === 'depleted'; const forecastWarning = depleted || nearExpiryWarning; const date = forecastDate ? forecastDate.toLocaleDateString('de-CH') : 'Keine Prognose'; const text = depleted ? date + ' (BESTAND ENDE)' : (nearExpiryWarning || mhdWarning ? date + ' (MHD: ' + expiryDate.toLocaleDateString('de-CH') + ')' : date); const value = drawer.querySelector('[data-lager-drawer-forecast-value]'); if (value) { value.textContent = text; } const header = drawer.querySelector('[data-lager-drawer-warning-header]'); const title = drawer.querySelector('[data-lager-drawer-warning-title]'); const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (trigger) { trigger.dataset.lagerDrawerForecast = text; trigger.dataset.lagerDrawerForecastStatus = forecast.status || 'no_demand'; trigger.dataset.lagerDrawerForecastWarning = forecastWarning ? 'true' : 'false'; trigger.dataset.lagerDrawerForecastMhdWarning = mhdWarning ? 'true' : 'false'; if (header) { header.classList.toggle('sg-card-segment--darkgreen', !forecastWarning && !mhdWarning); header.classList.toggle('sg-card-segment--signal-red', forecastWarning); header.classList.toggle('sg-card-segment--signal-yellow', mhdWarning); } if (title) { title.textContent = getLagerWarningTitle(trigger); } if (warningValue) { warningValue.textContent = forecastWarning || mhdWarning ? text : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', forecastWarning || mhdWarning); warningValue.classList.toggle('lager-forecast-warning', forecastWarning); warningValue.classList.toggle('lager-forecast-mhd-warning', mhdWarning); } const row = trigger.closest('[data-lager-row]'); const cells = row?.querySelectorAll('[role=\"cell\"]'); const forecastCell = cells?.[cells.length - 1]; if (forecastCell && cells.length === 3) { forecastCell.textContent = text; } } };";
echo " document.addEventListener('lager-save-complete', (event) => { const forecast = event.detail?.forecast; const productId = event.detail?.productId || ''; const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]') || contentRoot.querySelector('[data-lager-row][data-lager-id=\"' + productId + '\"] [data-lager-drawer-open]'); const drawer = getDrawer(); if (!forecast || !drawer) { return; } const date = forecast.date ? new Date(forecast.date + 'T00:00:00').toLocaleDateString('de-CH') : 'Keine Prognose'; const text = forecast.status === 'depleted' ? date + ' (BESTAND ENDE)' : date; const value = drawer.querySelector('[data-lager-drawer-forecast-value]'); if (value) { value.textContent = text; } if (trigger) { trigger.dataset.lagerDrawerForecast = text; const row = trigger.closest('[data-lager-row]'); const cells = row?.querySelectorAll('[role=\"cell\"]'); const forecastCell = cells?.[cells.length - 1]; if (forecastCell && cells.length === 3) { forecastCell.textContent = text; } } });";
echo " document.addEventListener('click', (event) => {";
echo " contentRoot.addEventListener('click', (event) => {";
echo " const trigger = event.target.closest('[data-lager-drawer-open]');";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; populateLagerActiveLotForm(drawer, trigger); const editForm = drawer.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); if (manufacturer) { manufacturer.value = trigger.dataset.lagerDrawerManufacturerPartyId || ''; } const manufacturerLot = drawer.querySelector('[data-lager-manufacturer-lot]'); if (manufacturerLot) { manufacturerLot.value = trigger.dataset.lagerDrawerManufacturerLotNumber || ''; } const mfgDate = drawer.querySelector('[data-lager-mfg-date]'); if (mfgDate) { mfgDate.value = trigger.dataset.lagerDrawerMfgDate || ''; } const expiryDate = drawer.querySelector('[data-lager-expiry-date]'); if (expiryDate) { expiryDate.value = trigger.dataset.lagerDrawerExpiryDate || ''; } const purchaseDate = drawer.querySelector('[data-lager-purchase-date]'); if (purchaseDate) { purchaseDate.value = trigger.dataset.lagerDrawerPurchaseDate || ''; } const editForm = drawer.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } drawer.dataset.lagerActiveLotId = trigger.dataset.lagerDrawerLotId || ''; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (event.target.closest('[data-lager-drawer-close]') || event.target.closest('[data-lager-drawer-backdrop]')) { closeDrawer(); }";
echo " });";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const header = contentRoot.querySelector('[data-lager-drawer-warning-header]'); const title = contentRoot.querySelector('[data-lager-drawer-warning-title]'); if (!header || !title) { return; } const forecastWarning = trigger.dataset.lagerDrawerForecastWarning === 'true'; const mhdWarning = trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; title.textContent = getLagerWarningTitle(trigger); header.classList.toggle('sg-card-segment--darkgreen', !forecastWarning && !mhdWarning); header.classList.toggle('sg-card-segment--signal-red', forecastWarning); header.classList.toggle('sg-card-segment--signal-yellow', mhdWarning); });";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } setLagerEditMode(false); const drawer = getDrawer(); const currentQty = drawer?.querySelector('[data-lager-drawer-current-qty]'); const qtyIn = drawer?.querySelector('[data-lager-drawer-qty-in]'); const lotNumber = drawer?.querySelector('[data-lager-lot-number]'); if (currentQty) { currentQty.textContent = trigger.dataset.lagerDrawerCurrentQty || '0'; } if (qtyIn) { qtyIn.textContent = trigger.dataset.lagerDrawerQtyIn || '0'; } if (lotNumber) { lotNumber.value = trigger.dataset.lagerDrawerLotNumber || ''; } const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; contentRoot.querySelectorAll('[data-lager-historical-product]').forEach((product) => { product.hidden = product.dataset.lagerHistoricalProduct !== productId; }); });";
echo " const postLagerChange = async (payload) => { const response = await fetch('/api/lager-charge.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, csrfToken: contentRoot.querySelector('[data-lager-csrf-token]')?.dataset.lagerCsrfToken || '' }) }); const result = await response.json(); if (!response.ok || result.ok !== true) { throw new Error(result.error || 'Speichern fehlgeschlagen'); } syncLagerForecast(result.forecast, result.productId); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]') || contentRoot.querySelector('[data-lager-row][data-lager-id=\"' + (result.productId || '') + '\"] [data-lager-drawer-open]'); const drawer = getDrawer(); const warningValue = drawer?.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = getLagerForecastWarningText(result.forecast, trigger, trigger?.dataset.lagerDrawerForecast || 'Keine Prognose'); } return result; };";
echo " document.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-edit-open]'); if (editButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = false; } return; } const closeButton = event.target.closest('[data-lager-edit-close]'); if (closeButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } } });";
echo " contentRoot.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const header = contentRoot.querySelector('[data-lager-drawer-warning-header]'); const title = contentRoot.querySelector('[data-lager-drawer-warning-title]'); if (!header || !title) { return; } const forecastWarning = trigger.dataset.lagerDrawerForecastWarning === 'true'; const mhdWarning = trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; title.textContent = getLagerWarningTitle(trigger); header.classList.toggle('sg-card-segment--darkgreen', !forecastWarning && !mhdWarning); header.classList.toggle('sg-card-segment--signal-red', forecastWarning); header.classList.toggle('sg-card-segment--signal-yellow', mhdWarning); });";
echo " contentRoot.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } setLagerEditMode(false); const drawer = getDrawer(); const currentQty = drawer?.querySelector('[data-lager-drawer-current-qty]'); const qtyIn = drawer?.querySelector('[data-lager-drawer-qty-in]'); const lotNumber = drawer?.querySelector('[data-lager-lot-number]'); if (currentQty) { currentQty.textContent = trigger.dataset.lagerDrawerCurrentQty || '0'; } if (qtyIn) { qtyIn.textContent = trigger.dataset.lagerDrawerQtyIn || '0'; } if (lotNumber) { lotNumber.value = trigger.dataset.lagerDrawerLotNumber || ''; } const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; contentRoot.querySelectorAll('[data-lager-historical-product]').forEach((product) => { product.hidden = product.dataset.lagerHistoricalProduct !== productId; }); });";
echo " const postLagerChange = async (payload) => { const response = await fetch('/api/lager-charge.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, csrfToken: contentRoot.querySelector('[data-lager-csrf-token]')?.dataset.lagerCsrfToken || '' }) }); const result = await response.json(); if (!response.ok || result.ok !== true) { throw new Error(result.error || 'Speichern fehlgeschlagen'); } return result; };";
echo " contentRoot.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-edit-open]'); if (editButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = false; } return; } const closeButton = event.target.closest('[data-lager-edit-close]'); if (closeButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } } });";
echo " const setLagerEditMode = (editing) => { const drawer = getDrawer(); const readView = drawer?.querySelector('[data-lager-read-view]'); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (readView) { readView.hidden = editing; } if (editForm) { editForm.hidden = !editing; } };";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-lager-edit-open]')) { setLagerEditMode(true); } if (event.target.closest('[data-lager-edit-close]')) { clearLagerMovementInputs(); setLagerEditMode(false); } });";
echo " contentRoot.addEventListener('click', (event) => { if (event.target.closest('[data-lager-edit-open]')) { setLagerEditMode(true); } if (event.target.closest('[data-lager-edit-close]')) { clearLagerMovementInputs(); setLagerEditMode(false); } });";
echo " const syncLagerReadView = () => { const drawer = getDrawer(); if (!drawer) { return; } const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); const values = { '[data-lager-drawer-manufacturer-name]': manufacturer?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt', '[data-lager-drawer-manufacturer-lot-number]': drawer.querySelector('[data-lager-manufacturer-lot]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-mfg-date]': drawer.querySelector('[data-lager-mfg-date]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-expiry-date]': drawer.querySelector('[data-lager-expiry-date]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-purchase-date]': drawer.querySelector('[data-lager-purchase-date]')?.value || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = drawer.querySelector(selector); if (target) { target.textContent = value; } }); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (trigger) { trigger.dataset.lagerDrawerManufacturerName = values['[data-lager-drawer-manufacturer-name]']; trigger.dataset.lagerDrawerManufacturerLotNumber = values['[data-lager-drawer-manufacturer-lot-number]']; trigger.dataset.lagerDrawerMfgDate = values['[data-lager-drawer-mfg-date]']; trigger.dataset.lagerDrawerExpiryDate = values['[data-lager-drawer-expiry-date]']; trigger.dataset.lagerDrawerPurchaseDate = values['[data-lager-drawer-purchase-date]']; } };";
echo " const syncLagerBalanceView = (balance) => { const drawer = getDrawer(); if (!drawer || !balance) { return; } const currentQty = drawer.querySelector('[data-lager-drawer-current-qty]'); const qtyIn = drawer.querySelector('[data-lager-drawer-qty-in]'); if (currentQty) { currentQty.textContent = String(balance.qty_net ?? 0); } if (qtyIn) { qtyIn.textContent = String(balance.qty_in ?? 0); } const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (trigger) { trigger.dataset.lagerDrawerCurrentQty = String(balance.qty_net ?? 0); trigger.dataset.lagerDrawerQtyIn = String(balance.qty_in ?? 0); } };";
echo " const syncLagerActiveLotView = (lot) => { const drawer = getDrawer(); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (!drawer || !lot || !trigger) { return; } const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); if (manufacturer) { manufacturer.value = lot.manufacturer_party_id ?? ''; } const manufacturerName = manufacturer?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt'; const values = { '[data-lager-drawer-lot-number]': lot.lot_number || 'Nicht hinterlegt', '[data-lager-drawer-manufacturer-name]': manufacturerName, '[data-lager-drawer-manufacturer-lot-number]': lot.manufacturer_lot_number || 'Nicht hinterlegt', '[data-lager-drawer-mfg-date]': lot.mfg_date || 'Nicht hinterlegt', '[data-lager-drawer-expiry-date]': lot.expiry_date || 'Nicht hinterlegt', '[data-lager-drawer-purchase-date]': lot.purchase_date || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = drawer.querySelector(selector); if (target) { target.textContent = value; } }); const formValues = { '[data-lager-lot-number]': lot.lot_number || '', '[data-lager-manufacturer-lot]': lot.manufacturer_lot_number || '', '[data-lager-mfg-date]': lot.mfg_date || '', '[data-lager-expiry-date]': lot.expiry_date || '', '[data-lager-purchase-date]': lot.purchase_date || '' }; Object.entries(formValues).forEach(([selector, value]) => { const input = drawer.querySelector(selector); if (input) { input.value = value; } }); trigger.dataset.lagerDrawerLotId = String(lot.id || lot.lot_id || ''); trigger.dataset.lagerDrawerLotNumber = lot.lot_number || ''; trigger.dataset.lagerDrawerManufacturerPartyId = String(lot.manufacturer_party_id ?? ''); trigger.dataset.lagerDrawerManufacturerName = manufacturerName; trigger.dataset.lagerDrawerManufacturerLotNumber = lot.manufacturer_lot_number || ''; trigger.dataset.lagerDrawerMfgDate = lot.mfg_date || ''; trigger.dataset.lagerDrawerExpiryDate = lot.expiry_date || ''; trigger.dataset.lagerDrawerPurchaseDate = lot.purchase_date || ''; drawer.dataset.lagerActiveLotId = String(lot.id || lot.lot_id || ''); };";
echo " const clearLagerMovementInputs = () => { const drawer = getDrawer(); if (!drawer) { return; } drawer.querySelectorAll('[data-lager-receipt-qty], [data-lager-adjustment-qty], [data-lager-out-qty]').forEach((input) => { input.value = ''; }); };";
echo " const syncLagerOpenLotCard = (lot, balance, exactCard = null) => { const drawer = getDrawer(); const lotId = lot?.id || lot?.lot_id || ''; const card = exactCard || drawer?.querySelector('[data-lager-open-card=\"' + lotId + '\"]'); if (!card) { return; } const manufacturerSelect = card.querySelector('[data-lager-open-manufacturer]') || drawer?.querySelector('[data-lager-new-manufacturer]'); const qualified = Boolean(lot?.lot_number && lot?.manufacturer_party_id && lot?.manufacturer_lot_number && lot?.mfg_date && lot?.expiry_date && lot?.purchase_date && Number(balance?.qty_net || 0) > 0); const selected = lot?.next_active === true; const values = { '[data-lager-open-card-lot-number]': lot?.lot_number || 'Nicht hinterlegt', '[data-lager-open-card-qty-net]': String(balance?.qty_net ?? 0), '[data-lager-open-card-qty-in]': String(balance?.qty_in ?? 0), '[data-lager-open-card-manufacturer]': manufacturerSelect?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt', '[data-lager-open-card-manufacturer-lot]': lot?.manufacturer_lot_number || 'Nicht hinterlegt', '[data-lager-open-card-mfg-date]': lot?.mfg_date || 'Nicht hinterlegt', '[data-lager-open-card-expiry-date]': lot?.expiry_date || 'Nicht hinterlegt', '[data-lager-open-card-purchase-date]': lot?.purchase_date || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = card.querySelector(selector); if (target) { target.textContent = value; } }); card.classList.toggle('sg-lager-open-lot--next-active', selected); const selectedLabel = card.querySelector('[data-lager-open-card-next-active]'); if (selectedLabel) { selectedLabel.textContent = selected ? 'Als nächste aktive Charge ausgewählt' : 'Nicht ausgewählt'; } const selectButton = card.querySelector('[data-lager-open-select]'); if (selectButton) { selectButton.dataset.lagerOpenSelectable = String(qualified); selectButton.disabled = !qualified || selected; selectButton.setAttribute('aria-disabled', String(!qualified || selected)); } };";
echo " const appendLagerOpenLotCard = (lot, balance) => { if (lot?.status === 'current') { syncLagerActiveLotView(lot); syncLagerBalanceView(balance); return null; } const drawer = getDrawer(); const product = drawer?.querySelector('[data-lager-open-product=\"' + (drawer?.dataset.lagerProductId || '') + '\"]'); const list = product?.querySelector('[data-lager-open-lot-list]'); const templateProduct = drawer?.querySelector('[data-lager-open-lot-template]')?.content.querySelector('[data-lager-open-product]'); const card = templateProduct?.querySelector('[data-lager-open-card-wrapper]')?.cloneNode(true); const lotId = String(lot?.id || lot?.lot_id || ''); if (!list || !card || !lotId) { return null; } card.dataset.lagerOpenCardWrapper = lotId; const readView = card.querySelector('[data-lager-open-card]'); if (readView) { readView.dataset.lagerOpenCard = lotId; } const editTitle = card.querySelector('[id^=\"lager-open-charge-data-\"]'); if (editTitle) { editTitle.id = 'lager-open-charge-data-' + lotId; editTitle.closest('section')?.setAttribute('aria-labelledby', editTitle.id); } const confirmationTitle = card.querySelector('[id^=\"lager-open-confirmation-title-\"]'); if (confirmationTitle) { confirmationTitle.id = 'lager-open-confirmation-title-' + lotId; confirmationTitle.closest('[data-lager-open-confirmation-card]')?.setAttribute('aria-labelledby', confirmationTitle.id); } const deleteTitle = card.querySelector('[id^=\"lager-open-delete-title-\"]'); if (deleteTitle) { deleteTitle.id = 'lager-open-delete-title-' + lotId; deleteTitle.closest('[data-lager-open-delete-confirmation]')?.setAttribute('aria-labelledby', deleteTitle.id); } const setValue = (selector, value) => { const input = card.querySelector(selector); if (input) { input.value = value ?? ''; } }; setValue('[data-lager-open-lot-number]', lot.lot_number); setValue('[data-lager-open-manufacturer]', lot.manufacturer_party_id); setValue('[data-lager-open-manufacturer-lot]', lot.manufacturer_lot_number); setValue('[data-lager-open-mfg-date]', lot.mfg_date); setValue('[data-lager-open-expiry-date]', lot.expiry_date); setValue('[data-lager-open-purchase-date]', lot.purchase_date); setValue('[data-lager-open-target-qty]', balance?.qty_net ?? 0); const qualified = Boolean(lot.lot_number && lot.manufacturer_party_id && lot.manufacturer_lot_number && lot.mfg_date && lot.expiry_date && lot.purchase_date && Number(balance?.qty_net || 0) > 0); const selected = lot.next_active === true; card.classList.toggle('sg-lager-open-lot--next-active', selected); const selectedLabel = card.querySelector('[data-lager-open-card-next-active]'); if (selectedLabel) { selectedLabel.textContent = selected ? 'Als nächste aktive Charge ausgewählt' : 'Nicht ausgewählt'; } const selectButton = card.querySelector('[data-lager-open-select]'); if (selectButton) { selectButton.dataset.lagerOpenSelectable = String(qualified); selectButton.disabled = !qualified || selected; selectButton.setAttribute('aria-disabled', String(!qualified || selected)); } list.append(card); syncLagerOpenLotCard(lot, balance, card); return card; };";
echo " const resetLagerNewLotConfirmation = () => { const drawer = getDrawer(); const confirmation = drawer?.querySelector('[data-lager-new-confirmation-card]'); const list = drawer?.querySelector('[data-lager-new-confirmation-empty-fields]'); const form = drawer?.querySelector('[data-lager-new-lot-form-element]'); if (confirmation) { confirmation.hidden = true; } if (list) { list.replaceChildren(); } if (form) { form.dataset.confirmationAccepted = 'false'; } if (drawer) { drawer.dataset.dialogOpen = 'false'; } };";
echo " const resetLagerOpenLotConfirmations = () => { const drawer = getDrawer(); drawer?.querySelectorAll('[data-lager-open-card-wrapper]').forEach((card) => { const confirmation = card.querySelector('[data-lager-open-confirmation-card]'); const list = card.querySelector('[data-lager-open-confirmation-empty-fields]'); const form = card.querySelector('[data-lager-open-edit-form-element]'); if (confirmation) { confirmation.hidden = true; } if (list) { list.replaceChildren(); } if (form) { form.dataset.confirmationAccepted = 'false'; } card.dataset.dialogOpen = 'false'; }); };";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const drawer = getDrawer(); const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; const openProduct = drawer?.querySelector('[data-lager-open-product=\"' + productId + '\"]'); drawer?.querySelectorAll('[data-lager-open-product]').forEach((product) => { product.hidden = product !== openProduct; product.querySelectorAll('[data-lager-open-edit-form]').forEach((form) => { form.hidden = true; }); product.querySelectorAll('[data-lager-open-card]').forEach((readView) => { readView.hidden = false; }); }); resetLagerNewLotConfirmation(); resetLagerOpenLotConfirmations(); const newForm = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const newAction = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newForm) { newForm.hidden = true; } if (newAction) { newAction.hidden = false; } const notificationWrapper = drawer?.querySelector('[data-lager-new-lot-notification-wrapper]'); const notification = drawer?.querySelector('[data-lager-new-lot-notification]'); const shortageQty = trigger.dataset.lagerDrawerVirtualShortageQty || '0'; const hasShortage = shortageQty !== '0' && shortageQty !== '0,00'; if (notificationWrapper) { notificationWrapper.hidden = !hasShortage; } if (notification && hasShortage) { notification.textContent = 'Für dieses Produkt bestehen virtuelle Fehlbestände in Höhe von ' + shortageQty + '. Die Eingangsmenge wird nach Aktivierung der Charge zur Nachallokation verwendet.'; } const setValue = (selector, value) => { const field = drawer?.querySelector(selector); if (field) { field.value = value || ''; } }; setValue('[data-lager-new-lot-number]', ''); setValue('[data-lager-new-manufacturer]', ''); setValue('[data-lager-new-manufacturer-lot]', ''); setValue('[data-lager-new-mfg-date]', ''); setValue('[data-lager-new-expiry-date]', ''); setValue('[data-lager-new-purchase-date]', ''); setValue('[data-lager-new-receipt-qty]', ''); if (drawer) { drawer.dataset.lagerOpenLotId = ''; drawer.dataset.lagerProductId = productId; } });";
echo " document.addEventListener('click', (event) => { const openButton = event.target.closest('[data-lager-new-lot-open]'); if (openButton) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); resetLagerNewLotConfirmation(); if (form) { form.hidden = false; } if (action) { action.hidden = true; } return; } if (event.target.closest('[data-lager-new-lot-close]')) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); resetLagerNewLotConfirmation(); if (form) { form.hidden = true; } if (action) { action.hidden = false; } } });";
echo " document.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-open-edit-open]'); if (editButton) { const card = editButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); resetLagerOpenLotConfirmations(); if (form) { form.hidden = false; } if (readView) { readView.hidden = true; } return; } const closeButton = event.target.closest('[data-lager-open-edit-close]'); if (closeButton) { const card = closeButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); resetLagerOpenLotConfirmations(); if (form) { form.hidden = true; } if (readView) { readView.hidden = false; } } });";
echo " document.addEventListener('input', (event) => { const input = event.target.closest('[data-lager-open-delete-confirmation-input]'); if (!input) { return; } const card = input.closest('[data-lager-open-card-wrapper]'); const submit = card?.querySelector('[data-lager-open-delete-confirmation-submit]'); const valid = input.value === 'DELETE'; if (submit) { submit.disabled = !valid; submit.setAttribute('aria-disabled', String(!valid)); submit.classList.toggle('sg-button--process-inactive', !valid); } });";
echo " document.addEventListener('click', async (event) => { const selectButton = event.target.closest('[data-lager-open-select]'); if (!selectButton) { return; } const card = selectButton.closest('[data-lager-open-card-wrapper]'); const product = card?.closest('[data-lager-open-product]'); const drawer = getDrawer(); const productId = product?.dataset.lagerOpenProduct || drawer?.dataset.lagerProductId || ''; const lotId = card?.dataset.lagerOpenCardWrapper || ''; if (!productId || !lotId) { return; } selectButton.disabled = true; try { const result = await postLagerChange({ action: 'select_open_lot', productId, lotId }); if (result.currentLot?.id && String(result.currentLot.id) === String(lotId)) { card?.remove(); syncLagerActiveLotView(result.currentLot); syncLagerBalanceView({ qty_net: result.currentLot.qty_net, qty_in: result.currentLot.qty_in, qty_out: result.currentLot.qty_out }); return; } product.querySelectorAll('[data-lager-open-card-wrapper]').forEach((candidate) => { const selected = candidate === card; candidate.classList.toggle('sg-lager-open-lot--next-active', selected); const label = candidate.querySelector('[data-lager-open-card-next-active]'); if (label) { label.textContent = selected ? 'Als nächste aktive Charge ausgewählt' : 'Nicht ausgewählt'; } const button = candidate.querySelector('[data-lager-open-select]'); if (button) { button.disabled = selected || button.dataset.lagerOpenSelectable !== 'true'; button.setAttribute('aria-disabled', String(button.disabled)); } }); } catch (error) { const status = card?.querySelector('[data-lager-open-edit-status]'); if (status) { status.textContent = error instanceof Error ? error.message : 'Auswahl fehlgeschlagen'; } selectButton.disabled = false; selectButton.setAttribute('aria-disabled', 'false'); } });";
echo " document.addEventListener('click', async (event) => { const deleteButton = event.target.closest('[data-lager-open-delete]'); if (deleteButton) { const card = deleteButton.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); const input = card?.querySelector('[data-lager-open-delete-confirmation-input]'); const submit = card?.querySelector('[data-lager-open-delete-confirmation-submit]'); if (input) { input.value = ''; } if (submit) { submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); submit.classList.add('sg-button--process-inactive'); } if (confirmation) { confirmation.hidden = false; } if (card) { card.dataset.dialogOpen = 'true'; } return; } const cancel = event.target.closest('[data-lager-open-delete-confirmation-cancel]'); if (cancel) { const card = cancel.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } return; } const submit = event.target.closest('[data-lager-open-delete-confirmation-submit]'); if (!submit || submit.disabled) { return; } const card = submit.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); const status = card?.querySelector('[data-lager-open-delete-status]'); if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; try { const result = await postLagerChange({ action: 'delete_open_lot', lotId: card?.dataset.lagerOpenCardWrapper || '' }); if (result.currentLot) { syncLagerActiveLotView(result.currentLot); syncLagerBalanceView({ qty_net: result.currentLot.qty_net, qty_in: result.currentLot.qty_in, qty_out: result.currentLot.qty_out }); } const productId = card?.closest('[data-lager-open-product]')?.dataset.lagerOpenProduct || ''; contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((trigger) => { if (trigger.closest('[data-lager-row]')?.dataset.lagerId === productId) { trigger.dataset.lagerOpenLotId = ''; } }); const drawer = getDrawer(); if (drawer) { drawer.dataset.lagerOpenLotId = ''; } card?.remove(); } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Löschen fehlgeschlagen'; } submit.disabled = false; } });";
echo " document.addEventListener('click', (event) => { const cancel = event.target.closest('[data-lager-open-confirmation-cancel]'); if (cancel) { const card = cancel.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } const form = card?.querySelector('[data-lager-open-edit-form-element]'); if (form) { form.dataset.confirmationAccepted = 'false'; } return; } const confirm = event.target.closest('[data-lager-open-confirmation-submit]'); if (confirm) { const card = confirm.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); const form = card?.querySelector('[data-lager-open-edit-form-element]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } if (form) { form.dataset.confirmationAccepted = 'true'; form.requestSubmit(); } } });";
echo " document.addEventListener('submit', async (event) => { const openLotForm = event.target.closest('[data-lager-open-edit-form-element]'); if (!openLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const card = openLotForm.closest('[data-lager-open-card-wrapper]'); const editSection = card?.querySelector('[data-lager-open-edit-form]'); const fields = [{ label: 'Naurua-Chargennummer', input: openLotForm.querySelector('[data-lager-open-lot-number]') }, { label: 'Hersteller', input: openLotForm.querySelector('[data-lager-open-manufacturer]') }, { label: 'Hersteller-Chargennummer', input: openLotForm.querySelector('[data-lager-open-manufacturer-lot]') }, { label: 'Herstellungsdatum', input: openLotForm.querySelector('[data-lager-open-mfg-date]') }, { label: 'MHD', input: openLotForm.querySelector('[data-lager-open-expiry-date]') }, { label: 'Einkaufsdatum', input: openLotForm.querySelector('[data-lager-open-purchase-date]') }, { label: 'Menge', input: openLotForm.querySelector('[data-lager-open-target-qty]') }]; const emptyFields = fields.filter((field) => (field.input?.value || '').trim() === ''); if (emptyFields.length > 0 && openLotForm.dataset.confirmationAccepted !== 'true') { const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); const emptyFieldsList = card?.querySelector('[data-lager-open-confirmation-empty-fields]'); if (emptyFieldsList) { emptyFieldsList.replaceChildren(...emptyFields.map((field) => { const item = document.createElement('div'); item.className = 'sg-body'; item.textContent = field.label; return item; })); } if (confirmation) { confirmation.hidden = false; } if (card) { card.dataset.dialogOpen = 'true'; } return; } openLotForm.dataset.confirmationAccepted = 'false'; const status = openLotForm.querySelector('[data-lager-open-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const targetQtyInput = openLotForm.querySelector('[data-lager-open-target-qty]'); const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: card?.dataset.lagerOpenCardWrapper || '', lotNumber: openLotForm.querySelector('[data-lager-open-lot-number]')?.value || '', manufacturerPartyId: openLotForm.querySelector('[data-lager-open-manufacturer]')?.value || null, manufacturerLotNumber: openLotForm.querySelector('[data-lager-open-manufacturer-lot]')?.value || '', mfgDate: openLotForm.querySelector('[data-lager-open-mfg-date]')?.value || '', purchaseDate: openLotForm.querySelector('[data-lager-open-purchase-date]')?.value || '', expiryDate: openLotForm.querySelector('[data-lager-open-expiry-date]')?.value || '', targetQty: targetQtyInput?.value.trim() === '' ? '0' : targetQtyInput?.value || '' }); if (result.lot?.status === 'current') { card?.remove(); syncLagerActiveLotView(result.lot); syncLagerBalanceView(result.balance); } else { syncLagerOpenLotCard(result.lot, result.balance); } if (editSection) { editSection.hidden = true; } card?.querySelector('[data-lager-open-card]')?.removeAttribute('hidden'); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " document.addEventListener('click', (event) => { const cancel = event.target.closest('[data-lager-new-confirmation-cancel]'); if (cancel) { const drawer = getDrawer(); const confirmation = drawer?.querySelector('[data-lager-new-confirmation-card]'); if (confirmation) { confirmation.hidden = true; } if (drawer) { drawer.dataset.dialogOpen = 'false'; } const form = drawer?.querySelector('[data-lager-new-lot-form-element]'); if (form) { form.dataset.confirmationAccepted = 'false'; } return; } const confirm = event.target.closest('[data-lager-new-confirmation-submit]'); if (confirm) { const drawer = getDrawer(); const confirmation = drawer?.querySelector('[data-lager-new-confirmation-card]'); const form = drawer?.querySelector('[data-lager-new-lot-form-element]'); if (confirmation) { confirmation.hidden = true; } if (drawer) { drawer.dataset.dialogOpen = 'false'; } if (form) { form.dataset.confirmationAccepted = 'true'; form.requestSubmit(); } } });";
echo " document.addEventListener('submit', async (event) => { const newLotForm = event.target.closest('[data-lager-new-lot-form-element]'); if (!newLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const fields = [{ label: 'Naurua-Chargennummer', input: drawer?.querySelector('[data-lager-new-lot-number]') }, { label: 'Hersteller', input: drawer?.querySelector('[data-lager-new-manufacturer]') }, { label: 'Hersteller-Chargennummer', input: drawer?.querySelector('[data-lager-new-manufacturer-lot]') }, { label: 'Herstellungsdatum', input: drawer?.querySelector('[data-lager-new-mfg-date]') }, { label: 'MHD', input: drawer?.querySelector('[data-lager-new-expiry-date]') }, { label: 'Einkaufsdatum', input: drawer?.querySelector('[data-lager-new-purchase-date]') }, { label: 'Eingangsmenge', input: drawer?.querySelector('[data-lager-new-receipt-qty]') }]; const emptyFields = fields.filter((field) => (field.input?.value || '').trim() === ''); if (emptyFields.length > 0 && newLotForm.dataset.confirmationAccepted !== 'true') { const confirmation = drawer?.querySelector('[data-lager-new-confirmation-card]'); const emptyFieldsList = drawer?.querySelector('[data-lager-new-confirmation-empty-fields]'); if (emptyFieldsList) { emptyFieldsList.replaceChildren(...emptyFields.map((field) => { const item = document.createElement('div'); item.className = 'sg-body'; item.textContent = field.label; return item; })); } if (confirmation) { confirmation.hidden = false; } if (drawer) { drawer.dataset.dialogOpen = 'true'; } return; } newLotForm.dataset.confirmationAccepted = 'false'; const status = drawer?.querySelector('[data-lager-new-lot-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: '', lotNumber: drawer?.querySelector('[data-lager-new-lot-number]')?.value || '', manufacturerPartyId: drawer?.querySelector('[data-lager-new-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-new-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-new-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-new-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-new-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-new-receipt-qty]')?.value || '' }); appendLagerOpenLotCard(result.lot, result.balance); resetLagerNewLotConfirmation(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = true; } if (action) { action.hidden = false; } if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " document.addEventListener('submit', async (event) => { const editForm = event.target.closest('[data-lager-edit-form-element]'); if (!editForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'update_lot', lotId: drawer?.dataset.lagerActiveLotId || '', manufacturerPartyId: drawer?.querySelector('[data-lager-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-receipt-qty]')?.value || '', adjustmentQty: drawer?.querySelector('[data-lager-adjustment-qty]')?.value || '', outQty: drawer?.querySelector('[data-lager-out-qty]')?.value || '' }); syncLagerActiveLotView(result.lot); syncLagerBalanceView(result.balance); clearLagerMovementInputs(); setLagerEditMode(false); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " const syncLagerOpenLotCard = (lot, balance) => { const drawer = getDrawer(); const card = drawer?.querySelector('[data-lager-open-card=\"' + (lot?.id || '') + '\"]'); if (!card) { return; } const values = { '[data-lager-open-card-lot-number]': lot?.lot_number || 'Nicht hinterlegt', '[data-lager-open-card-qty-net]': String(balance?.qty_net ?? 0), '[data-lager-open-card-qty-in]': String(balance?.qty_in ?? 0), '[data-lager-open-card-manufacturer]': drawer.querySelector('[data-lager-new-manufacturer]')?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt', '[data-lager-open-card-manufacturer-lot]': lot?.manufacturer_lot_number || 'Nicht hinterlegt', '[data-lager-open-card-mfg-date]': lot?.mfg_date || 'Nicht hinterlegt', '[data-lager-open-card-expiry-date]': lot?.expiry_date || 'Nicht hinterlegt', '[data-lager-open-card-purchase-date]': lot?.purchase_date || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = card.querySelector(selector); if (target) { target.textContent = value; } }); };";
echo " contentRoot.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const drawer = getDrawer(); const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; const openProduct = drawer?.querySelector('[data-lager-open-product=\"' + productId + '\"]'); drawer?.querySelectorAll('[data-lager-open-product]').forEach((product) => { product.hidden = product !== openProduct; product.querySelectorAll('[data-lager-open-edit-form]').forEach((form) => { form.hidden = true; }); product.querySelectorAll('[data-lager-open-card]').forEach((readView) => { readView.hidden = false; }); }); const newForm = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const newAction = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newForm) { newForm.hidden = true; } if (newAction) { newAction.hidden = false; } const notificationWrapper = drawer?.querySelector('[data-lager-new-lot-notification-wrapper]'); const notification = drawer?.querySelector('[data-lager-new-lot-notification]'); const shortageQty = trigger.dataset.lagerDrawerVirtualShortageQty || '0'; const hasShortage = shortageQty !== '0' && shortageQty !== '0,00'; if (notificationWrapper) { notificationWrapper.hidden = !hasShortage; } if (notification && hasShortage) { notification.textContent = 'Für dieses Produkt bestehen virtuelle Fehlbestände in Höhe von ' + shortageQty + '. Die Eingangsmenge wird nach Aktivierung der Charge zur Nachallokation verwendet.'; } const setValue = (selector, value) => { const field = drawer?.querySelector(selector); if (field) { field.value = value || ''; } }; setValue('[data-lager-new-lot-number]', trigger.dataset.lagerOpenLotNumber); setValue('[data-lager-new-manufacturer]', trigger.dataset.lagerOpenManufacturerPartyId); setValue('[data-lager-new-manufacturer-lot]', trigger.dataset.lagerOpenManufacturerLotNumber); setValue('[data-lager-new-mfg-date]', trigger.dataset.lagerOpenMfgDate); setValue('[data-lager-new-expiry-date]', trigger.dataset.lagerOpenExpiryDate); setValue('[data-lager-new-purchase-date]', trigger.dataset.lagerOpenPurchaseDate); if (drawer) { drawer.dataset.lagerOpenLotId = trigger.dataset.lagerOpenLotId || ''; drawer.dataset.lagerProductId = productId; } });";
echo " contentRoot.addEventListener('click', (event) => { const openButton = event.target.closest('[data-lager-new-lot-open]'); if (openButton) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = false; } if (action) { action.hidden = true; } return; } if (event.target.closest('[data-lager-new-lot-close]')) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = true; } if (action) { action.hidden = false; } } });";
echo " contentRoot.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-open-edit-open]'); if (editButton) { const card = editButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); if (form) { form.hidden = false; } if (readView) { readView.hidden = true; } return; } const closeButton = event.target.closest('[data-lager-open-edit-close]'); if (closeButton) { const card = closeButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); if (form) { form.hidden = true; } if (readView) { readView.hidden = false; } } });";
echo " contentRoot.addEventListener('submit', async (event) => { const openLotForm = event.target.closest('[data-lager-open-edit-form-element]'); if (!openLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const card = openLotForm.closest('[data-lager-open-card-wrapper]'); const status = openLotForm.querySelector('[data-lager-open-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: card?.dataset.lagerOpenCardWrapper || '', lotNumber: openLotForm.querySelector('[data-lager-open-lot-number]')?.value || '', manufacturerPartyId: openLotForm.querySelector('[data-lager-open-manufacturer]')?.value || null, manufacturerLotNumber: openLotForm.querySelector('[data-lager-open-manufacturer-lot]')?.value || '', mfgDate: openLotForm.querySelector('[data-lager-open-mfg-date]')?.value || '', expiryDate: openLotForm.querySelector('[data-lager-open-expiry-date]')?.value || '', purchaseDate: openLotForm.querySelector('[data-lager-open-purchase-date]')?.value || '', receiptQty: openLotForm.querySelector('[data-lager-open-receipt-qty]')?.value || '' }); syncLagerOpenLotCard(result.lot, result.balance); openLotForm.querySelector('[data-lager-open-receipt-qty]').value = ''; openLotForm.setAttribute('hidden', 'hidden'); card?.querySelector('[data-lager-open-card]')?.removeAttribute('hidden'); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " contentRoot.addEventListener('submit', async (event) => { const newLotForm = event.target.closest('[data-lager-new-lot-form-element]'); if (!newLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-new-lot-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: drawer?.dataset.lagerOpenLotId || '', lotNumber: drawer?.querySelector('[data-lager-new-lot-number]')?.value || '', manufacturerPartyId: drawer?.querySelector('[data-lager-new-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-new-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-new-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-new-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-new-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-new-receipt-qty]')?.value || '' }); syncLagerOpenLotCard(result.lot, result.balance); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = true; } if (action) { action.hidden = false; } if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " contentRoot.addEventListener('submit', async (event) => { const editForm = event.target.closest('[data-lager-edit-form-element]'); if (!editForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'update_lot', lotId: drawer?.dataset.lagerActiveLotId || '', manufacturerPartyId: drawer?.querySelector('[data-lager-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-receipt-qty]')?.value || '', adjustmentQty: drawer?.querySelector('[data-lager-adjustment-qty]')?.value || '', outQty: drawer?.querySelector('[data-lager-out-qty]')?.value || '' }); syncLagerReadView(); syncLagerBalanceView(result.balance); clearLagerMovementInputs(); setLagerEditMode(false); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-lager-detail-back]')) { event.preventDefault(); closeLagerMobileDetail(); } });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); closeLagerMobileDetail(); } });";
echo " window.addEventListener('popstate', (event) => { if (lagerMobileDetailOpen && (!event.state || event.state.lagerView !== 'detail')) { restoreLagerMobileDetail(); } });";
echo " const syncLagerDisplayedTotal = () => { const drawer = getDrawer(); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (!drawer || !trigger || trigger.dataset.lagerDrawerType !== 'Produkt') { return; } const active = Number((drawer.querySelector('[data-lager-drawer-current-qty]')?.textContent || '0').replace(',', '.')) || 0; const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; const openProduct = drawer.querySelector('[data-lager-open-product=\"' + productId + '\"]'); const open = Array.from(openProduct?.querySelectorAll('[data-lager-open-card-qty-net]') || []).reduce((sum, node) => sum + (Number((node.textContent || '0').replace(',', '.')) || 0), 0); const total = active + open; const formatted = Number.isInteger(total) ? String(total) : total.toFixed(2).replace(/\.?0+$/, ''); const value = drawer.querySelector('[data-lager-drawer-value-value]'); if (value && value.textContent !== formatted) { value.textContent = formatted; } trigger.dataset.lagerDrawerValue = formatted; };";
echo " const lagerTotalObserver = new MutationObserver(syncLagerDisplayedTotal);";
echo " lagerTotalObserver.observe(contentRoot, { childList: true, subtree: true, characterData: true });";
echo "}";
echo "let marketsBindingsInstalled = false;";
echo "function initMarketsBindings() {";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
echo " if (!contentRoot || !contentRoot.querySelector('[data-markets-large-table]')) { return; }";
echo " const getTable = () => contentRoot.querySelector('[data-markets-large-table]');";
echo " let searchTimer = null;";
echo " const getSearchInput = () => getTable()?.querySelector('[data-markets-search-input]') || null;";
echo " const getSearchValue = () => (getSearchInput()?.value || '').trim();";
echo " const getSortColumn = () => getTable()?.dataset.marketsSortColumn || 'earliest_date';";
echo " const getSortDirection = () => (getTable()?.dataset.marketsSortDirection || 'ASC').toUpperCase();";
echo " const getCurrentLimit = () => parseInt(getTable()?.dataset.marketsLimit || '20', 10);";
echo " const getPageSize = () => parseInt(getTable()?.dataset.marketsPageSize || '20', 10);";
echo " const marketEscape = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " const marketDisplay = (value) => value === null || value === undefined || value === '' ? 'Nicht hinterlegt' : marketEscape(value);";
echo " const marketDate = (value) => { if (!value) { return 'Nicht hinterlegt'; } const parts = String(value).split('-'); return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' + parts[0] : marketEscape(value); };";
echo " const marketStatusLabel = (value) => ({ open: 'Offen', waitlist: 'Warteliste', registered: 'Angemeldet', confirmation_pending: 'Bestätigung ausstehend', confirmed: 'Bestätigt', cancelled: 'Abgesagt', completed: 'Abgeschlossen' }[value] || value || 'Nicht hinterlegt');";
echo " const marketStatusSegment = (value) => ({ waitlist: 'sg-card-segment--signal-red', registered: 'sg-card-segment--signal-yellow', confirmation_pending: 'sg-card-segment--signal-yellow', confirmed: 'sg-card-segment--signal-green' }[value] || 'sg-card-segment--darkblue');";
echo " const marketRow = (label, value, specific = false) => '<div class=\"market-detail-row\"><dt>' + marketEscape(label) + (specific ? ' <span class=\"sg-table-label market-day-specific\">(Tagesspezifisch)</span>' : '') + '</dt><dd>' + marketDisplay(value) + '</dd></div>';";
echo " const marketCard = (title, body, headerClass = 'sg-card-segment--darkgreen', actions = '') => '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\"><header class=\"sg-card-segment sg-card-segment--header ' + headerClass + '\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">' + title + '</div>' + actions + '</header><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\">' + body + '</div></article>';";
echo " const marketEditButton = (scope, id) => '<button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-market-edit=\"true\" data-market-edit-scope=\"' + scope + '\" data-market-edit-id=\"' + marketEscape(id) + '\">bearbeiten</button>';";
echo " const renderMarketDetail = (payload) => { const market = payload.market || {}; const occurrence = payload.occurrence || {}; const days = Array.isArray(occurrence.days) ? occurrence.days : []; const website = market.website_url ? '<a class=\"sg-hyperlink\" href=\"' + marketEscape(market.website_url) + '\" target=\"_blank\" rel=\"noopener\">' + marketEscape(market.website_url) + '</a>' : 'Nicht hinterlegt'; const overview = marketRow('Veranstaltung', market.name) + marketRow('Kategorie', market.category_name || market.category_code) + '<div class=\"market-detail-row\"><dt>Website</dt><dd>' + website + '</dd></div>' + marketRow('Ort', occurrence.place) + marketRow('Adresse', occurrence.address) + marketRow('Gemeinde', occurrence.municipality_reference_id); const dates = days.map((day) => marketDate(day.date)).join(' – '); const participation = marketRow('Anmeldung / Anfrage', occurrence.request_or_registration_date) + marketRow('Anmeldefrist', occurrence.registration_deadline) + marketRow('Bestätigung', occurrence.confirmation_date) + marketRow('Zahlungsfrist', occurrence.payment_deadline) + marketRow('Zahlungsstatus', occurrence.payment_status); const infrastructure = marketRow('Standgrösse', occurrence.stand_size) + marketRow('Tisch vorhanden', occurrence.table_available) + marketRow('Tisch mietbar', occurrence.table_rentable) + marketRow('Eigener Tisch erforderlich', occurrence.own_table_required) + marketRow('Überdachung', occurrence.shelter_available) + marketRow('Strom', occurrence.power_available) + marketRow('Wasser', occurrence.water_available) + marketRow('Beleuchtung', occurrence.lighting_available); const travel = marketRow('Reiseadresse', occurrence.travel_address) + marketRow('Distanz (km)', occurrence.travel_distance_km) + marketRow('Reisedauer (Minuten)', occurrence.travel_duration_minutes) + marketRow('Parkmöglichkeiten', occurrence.parking_options) + marketRow('Anlieferung', occurrence.delivery_access) + marketRow('Direktes Entladen', occurrence.direct_unload_possible) + marketRow('Lagerung', occurrence.storage_options); const notes = marketRow('Teilnahmebedingungen', occurrence.other_participation_conditions) + marketRow('Rücktrittsbedingungen', occurrence.cancellation_terms) + marketRow('Umsatzbeteiligung', occurrence.revenue_share_terms) + marketRow('Notizen', occurrence.notes); const dayTabs = days.map((day, index) => '<button class=\"sg-interaction-element sg-button sg-tab-button\" type=\"button\" role=\"tab\" aria-selected=\"' + (index === 0 ? 'true' : 'false') + '\" aria-controls=\"market-day-panel-' + day.id + '\" id=\"market-day-tab-' + day.id + '\" data-market-day-tab=\"true\">' + marketDate(day.date) + '</button>').join(''); const dayPanels = days.map((day, index) => '<div class=\"sg-content-block-card-group__panel\" id=\"market-day-panel-' + day.id + '\" role=\"tabpanel\" aria-labelledby=\"market-day-tab-' + day.id + '\"' + (index === 0 ? '' : ' hidden') + '><div class=\"sg-content-cards-group\" data-pattern=\"content-cards-group\" aria-label=\"Durchführungstag ' + marketDate(day.date) + '\"><article class=\"sg-card sg-card--content-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\"><div class=\"sg-strong sg-text-on-dark\">' + marketDate(day.date) + '</div>' + marketEditButton('day', day.id) + '</header><div class=\"sg-card-segment sg-card-segment--body\"><dl class=\"market-detail-grid\">' + marketRow('Datum', marketDate(day.date), true) + marketRow('Standzeit von', day.stand_start_time, true) + marketRow('Standzeit bis', day.stand_end_time, true) + marketRow('Aufbau von', day.setup_start_time, true) + marketRow('Aufbau bis', day.setup_end_time, true) + marketRow('Anlieferung von', day.delivery_start_time, true) + marketRow('Anlieferung bis', day.delivery_end_time, true) + marketRow('Beobachtungen', day.observations, true) + '</dl></div></article></div></div>').join(''); return '<article class=\"sg-card sg-card--content-card market-status-card\"><header class=\"sg-card-segment sg-card-segment--header ' + marketStatusSegment(occurrence.status) + '\"><div class=\"sg-strong sg-text-on-dark\">Status: ' + marketEscape(marketStatusLabel(occurrence.status)) + '</div>' + marketEditButton('occurrence', occurrence.id) + '</header><div class=\"sg-card-segment sg-card-segment--body\"><dl class=\"market-detail-grid\">' + marketRow('Datumsbereich', dates) + marketRow('Anzahl Tage', days.length) + '</dl></div></article>' + marketCard('Veranstaltung und Durchführung', '<dl class=\"market-detail-grid\">' + overview + '</dl>', 'sg-card-segment--darkgreen') + marketCard('Teilnahme und Zahlung', '<dl class=\"market-detail-grid\">' + participation + '</dl>', 'sg-card-segment--darkgreen') + marketCard('Infrastruktur', '<dl class=\"market-detail-grid\">' + infrastructure + '</dl>', 'sg-card-segment--darkgreen') + marketCard('Anreise und Logistik', '<dl class=\"market-detail-grid\">' + travel + '</dl>', 'sg-card-segment--darkgreen') + marketC Line truncated
echo " const marketEditForm = (scope, id, values) => { const fields = scope === 'day' ? [['date', 'Datum', 'date'], ['stand_start_time', 'Standzeit von', 'time'], ['stand_end_time', 'Standzeit bis', 'time'], ['setup_start_time', 'Aufbau von', 'time'], ['setup_end_time', 'Aufbau bis', 'time'], ['delivery_start_time', 'Anlieferung von', 'time'], ['delivery_end_time', 'Anlieferung bis', 'time'], ['observations', 'Beobachtungen', 'text']] : [['place', 'Ort', 'text'], ['address', 'Adresse', 'text'], ['registration_deadline', 'Anmeldefrist', 'date'], ['request_or_registration_date', 'Anmeldung / Anfrage', 'date'], ['confirmation_date', 'Bestätigung', 'date'], ['payment_deadline', 'Zahlungsfrist', 'date'], ['notes', 'Notizen', 'text']]; return '<form class=\"market-edit-form\" data-market-edit-form=\"true\" data-market-edit-scope=\"' + scope + '\" data-market-edit-id=\"' + id + '\"><dl class=\"market-detail-grid\">' + fields.map(([key, label, type]) => '<div class=\"market-detail-row\"><dt>' + marketEscape(label) + '</dt><dd><input class=\"sg-interaction-element sg-input-single-line\" type=\"' + type + '\" name=\"' + key + '\" value=\"' + marketEscape(values[key] || '') + '\"></dd></div>').join('') + '</dl><div class=\"sg-component-row\"><button class=\"sg-interaction-element sg-button sg-button--process\" type=\"submit\">speichern</button><button class=\"sg-interaction-element sg-button sg-button--active\" type=\"button\" data-market-edit-cancel>abbrechen</button><span class=\"sg-body\" data-market-edit-status></span></div></form>'; };";
echo " const buildUrl = (params) => { const url = new URL(window.location.href); Object.entries(params).forEach(([key, value]) => { if (value === null || value === undefined || value === '') { url.searchParams.delete(key); } else { url.searchParams.set(key, String(value)); } }); return url; };";
echo " const replaceFragment = (html) => { const doc = new DOMParser().parseFromString(html, 'text/html'); const fragment = doc.querySelector('[data-markets-content]'); if (!fragment) { return false; } contentRoot.innerHTML = fragment.innerHTML; return Boolean(getTable()); };";
echo " const loadFragment = async (params) => { if (searchTimer) { window.clearTimeout(searchTimer); searchTimer = null; } const input = getSearchInput(); const wasFocused = input ? document.activeElement === input : false; const response = await fetch(buildUrl({ ...params, market_fragment: '1' }).toString(), { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) { return; } const replaced = replaceFragment(await response.text()); if (!replaced) { return; } window.history.replaceState({}, '', buildUrl(params)); bindTable(); if (wasFocused) { const nextInput = getSearchInput(); nextInput?.focus({ preventScroll: true }); if (nextInput) { nextInput.setSelectionRange(nextInput.value.length, nextInput.value.length); } } };";
echo " const syncSearchState = () => { const input = getSearchInput(); const wrap = input?.closest('[data-component=\'single-line-input\']'); if (!input || !wrap) { return; } const hasValue = input.value.trim() !== ''; wrap.setAttribute('data-has-value', String(hasValue)); wrap.dataset.componentState = hasValue ? 'active' : 'inactive-selectable'; };";
echo " const bindTable = () => { const table = getTable(); if (!table) { return; } const input = getSearchInput(); const clearButton = table.querySelector('[data-markets-search-clear]'); if (input && input.dataset.marketsBound !== 'true') { input.dataset.marketsBound = 'true'; input.addEventListener('input', () => { syncSearchState(); if (searchTimer) { window.clearTimeout(searchTimer); } searchTimer = window.setTimeout(() => { void loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getPageSize() }); }, 500); }); } if (clearButton && clearButton.dataset.marketsBound !== 'true') { clearButton.dataset.marketsBound = 'true'; clearButton.addEventListener('click', () => { const currentInput = getSearchInput(); if (currentInput) { currentInput.value = ''; } syncSearchState(); void loadFragment({ market_search: '', market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getPageSize() }); }); } syncSearchState(); };";
echo " let marketMobileDetailHost = null; let marketOpeningMobileDetail = false; let marketDetailOpen = false;";
echo " const getMarketDrawer = () => document.querySelector('[data-market-mobile-drawer]') || contentRoot.querySelector('[data-market-drawer]');";
echo " const getMarketBackdrop = () => contentRoot.querySelector('[data-market-drawer-backdrop]');";
echo " const setMarketDrawerState = (open) => { const drawer = getMarketDrawer(); const backdrop = getMarketBackdrop(); if (!drawer || !backdrop) { return; } drawer.dataset.open = String(open); drawer.setAttribute('aria-hidden', String(!open)); backdrop.dataset.open = String(open); backdrop.setAttribute('aria-hidden', String(!open)); document.body.classList.toggle('sg-card-list-page-drawer-open', open); };";
echo " const applyMarketTabs = (root) => { root.querySelectorAll('[data-market-day-tab]').forEach((tab) => { if (tab.dataset.marketTabBound === 'true') { return; } tab.dataset.marketTabBound = 'true'; tab.addEventListener('click', () => { const group = tab.closest('[data-pattern=\"card-gruppe-mit-tastennavigation\"]'); group?.querySelectorAll('[data-market-day-tab]').forEach((candidate) => candidate.setAttribute('aria-selected', String(candidate === tab))); group?.querySelectorAll('[role=\"tabpanel\"]').forEach((panel) => { panel.hidden = panel.id !== tab.getAttribute('aria-controls'); }); tab.focus(); }); }); };";
echo " const renderMarketEdit = (scope, id, payload) => { const drawer = getMarketDrawer(); const source = scope === 'day' ? (payload.occurrence.days || []).find((day) => String(day.id) === String(id)) : payload.occurrence; const card = drawer?.querySelector('[data-market-edit=\"true\"][data-market-edit-scope=\"' + scope + '\"][data-market-edit-id=\"' + id + '\"]')?.closest('article'); if (card && source) { card.querySelector('[data-component-part=\"card-body\"]')?.replaceChildren(); card.querySelector('[data-component-part=\"card-body\"]').innerHTML = marketEditForm(scope, id, source); } };";
echo " const openMarketDrawer = async (trigger) => { const drawer = getMarketDrawer(); if (!drawer) { return; } const title = drawer.querySelector('[data-market-drawer-title]'); const content = drawer.querySelector('[data-market-drawer-content]'); setMarketDrawerState(true); if (title) { title.textContent = 'Marktdurchführung'; } if (content) { content.innerHTML = '<p class=\"sg-body\">Marktdetails werden geladen …</p>'; } try { const response = await fetch('/api/market-occurrence.php?id=' + encodeURIComponent(trigger.dataset.marketOccurrenceId || ''), { credentials: 'same-origin' }); const payload = await response.json(); if (!response.ok || !payload.ok) { throw new Error(payload.error || 'Marktdetails konnten nicht geladen werden.'); } drawer.dataset.marketPayload = JSON.stringify(payload); if (title) { title.textContent = payload.market?.name || 'Marktdurchführung'; } if (content) { content.innerHTML = renderMarketDetail(payload); applyMarketTabs(content); content.querySelectorAll('[data-market-edit]').forEach((button) => button.addEventListener('click', () => { const scope = button.dataset.marketEditScope || ''; const id = button.dataset.marketEditId || ''; const source = scope === 'day' ? (payload.occurrence.days || []).find((day) => String(day.id) === String(id)) : payload.occurrence; const card = button.closest('article'); const body = card?.querySelector('[data-component-part=\"card-body\"]'); if (body && source) { body.innerHTML = marketEditForm(scope, id, source); } })); } } catch (error) { if (content) { content.innerHTML = '<p class=\"sg-body\">' + marketEscape(error.message) + '</p>'; } } contentRoot.querySelectorAll('[data-market-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); };";
echo " const closeMarketDrawer = () => { setMarketDrawerState(false); contentRoot.querySelectorAll('[data-market-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', 'false')); };";
echo " const restoreMarketList = () => { if (marketMobileDetailHost?.parentNode) { const drawer = marketMobileDetailHost.querySelector('[data-market-mobile-drawer]'); if (drawer) { contentRoot.appendChild(drawer); drawer.removeAttribute('data-market-mobile-drawer'); drawer.dataset.open = 'false'; drawer.setAttribute('aria-hidden', 'true'); } marketMobileDetailHost.parentNode.removeChild(marketMobileDetailHost); } marketMobileDetailHost = null; marketDetailOpen = false; document.body.classList.remove('sg-market-mobile-detail-open'); bindTable(); };";
echo " const openMarketMobileDetail = (trigger) => { restoreMarketList(); const sourceDrawer = contentRoot.querySelector('[data-market-drawer]'); if (!sourceDrawer) { return; } const host = document.createElement('div'); host.className = 'sg-market-mobile-detail-host'; host.dataset.marketMobileDetailHost = 'true'; host.innerHTML = '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\"><div class=\"sg-card-segment sg-card-segment--body\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-market-detail-back>zurück</button></div></div></article></div></div>'; sourceDrawer.dataset.marketMobileDrawer = 'true'; sourceDrawer.dataset.open = 'true'; sourceDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); document.body.appendChild(host); marketMobileDetailHost = host; document.body.classList.add('sg-market-mobile-detail-open'); marketOpeningMobileDetail = true; void openMarketDrawer(trigger).finally(() => { marketOpeningMobileDetail = false; }); marketDetailOpen = true; window.history.pushState({ marketView: 'detail' }, '', window.location.href); };";
echo " if (!marketsBindingsInstalled) {";
echo " marketsBindingsInstalled = true;";
echo " document.addEventListener('click', (event) => { const sortButton = event.target.closest('[data-markets-sort-button]'); if (sortButton) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: sortButton.dataset.marketsSortColumn || getSortColumn(), market_dir: sortButton.dataset.marketsSortDirection || getSortDirection(), market_limit: getPageSize() }); return; } const loadMore = event.target.closest('[data-markets-load-more-trigger]'); if (loadMore) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() + getPageSize() }); return; } const trigger = event.target.closest('[data-market-drawer-open]'); if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches && !marketOpeningMobileDetail) { openMarketMobileDetail(trigger); } else { void openMarketDrawer(trigger); } return; } if (event.target.closest('[data-market-drawer-close]')) { closeMarketDrawer(); return; } if (event.target.closest('[data-market-detail-back]')) { if (window.history.state?.marketView === 'detail') { window.history.back(); } else { restoreMarketList(); } return; } const edit = event.target.closest('[data-market-edit]'); if (edit) { const drawer = getMarketDrawer(); const current = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; if (current) { const scope = edit.dataset.marketEditScope || ''; const id = edit.dataset.marketEditId || ''; const source = scope === 'day' ? (current.occurrence.days || []).find((day) => String(day.id) === String(id)) : current.occurrence; const card = edit.closest('article'); const body = card?.querySelector('[data-component-part=\"card-body\"]'); if (body && source) { body.innerHTML = marketEditForm(scope, id, source); } } return; } if (event.target.closest('[data-market-edit-cancel]')) { const drawer = getMarketDrawer(); if (drawer?.dataset.marketPayload) { const payload = JSON.parse(drawer.dataset.marketPayload); const content = drawer.querySelector('[data-market-drawer-content]'); content.innerHTML = renderMarketDetail(payload); applyMarketTabs(content); } } });";
echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-edit-form]'); if (!form) { return; } event.preventDefault(); const status = form.querySelector('[data-market-edit-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ scope: form.dataset.marketEditScope, id: form.dataset.marketEditId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-market-drawer-backdrop]')) { closeMarketDrawer(); } });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeMarketDrawer(); restoreMarketList(); } });";
echo " window.addEventListener('popstate', () => { if (marketDetailOpen) { restoreMarketList(); } });";
echo " }";
echo " bindTable();";
echo "}";
echo "function initBestellungenBindings() {";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
@@ -1105,7 +878,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const isMobileDetailMode = () => window.matchMedia('(max-width: 767px)').matches;";
echo " const escapeHtml = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " let bestellungenMobileDetailHost = null;";
echo " let bestellungenDetailOpen = false; let bestellungenOpeningMobileDetail = false;";
echo " let bestellungenDetailOpen = false;";
echo " const syncSearchState = () => {";
echo " const input = getSearchInput();";
echo " const wrap = getSearchWrap();";
@@ -1114,10 +887,47 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " wrap.setAttribute('data-has-value', String(hasValue));";
echo " wrap.dataset.componentState = hasValue ? 'active' : 'inactive-selectable';";
echo " };";
echo " const renderMobileDetail = () => {";
echo " return '<div class=\"sg-bestellungen-mobile-detail-host\" data-bestellungen-mobile-detail-host=\"true\"><div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\"><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-bestellungen-detail-back>zurück</button></div></div></article></div></div></div>';";
echo " };";
echo " const getDrawer = () => document.querySelector('[data-bestellungen-mobile-drawer]') || contentRoot.querySelector('[data-bestellungen-drawer]');";
echo " const renderMobileDetail = (trigger) => {";
echo " const orderNumber = trigger.dataset.orderNumber || '';";
echo " const orderDate = trigger.dataset.orderDate || '';";
echo " const total = trigger.dataset.orderTotal || '';";
echo " const street = trigger.dataset.orderStreet || '';";
echo " const houseNumber = trigger.dataset.orderHouseNumber || '';";
echo " const zip = trigger.dataset.orderZip || '';";
echo " const city = trigger.dataset.orderCity || '';";
echo " const country = trigger.dataset.orderCountry || '';";
echo " const formatDate = (value) => value ? new Date(value).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' }) : 'Nicht vorhanden';";
echo " const streetLine = [street, houseNumber].filter((part) => part && part.length > 0).join(' ').trim();";
echo " const cityLine = [zip, city].filter((part) => part && part.length > 0).join(' ').trim();";
echo " return [";
echo " '<div class=\"sg-bestellungen-mobile-detail-host\" data-bestellungen-mobile-detail-host=\"true\">',";
echo " '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\">',";
echo " '<div class=\"sg-navigation-card-block\">',";
echo " '<article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\">',";
echo " '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\">',";
echo " '<div class=\"sg-navigation-card-center\">',";
echo " '<button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-bestellungen-detail-back>zurück</button>',";
echo " '</div>',";
echo " '</div>',";
echo " '</article>',";
echo " '</div>',";
echo " '</div>',";
echo " '<div data-bestellungen-mobile-cards><article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Bestell-Details\">',";
echo " '<div class=\"sg-card-segment sg-card-segment--body sg-vsf-drawer-card__content\">',";
echo " '<p class=\"sg-body\"><strong>Bestellnummer:</strong> <span>' + escapeHtml(orderNumber) + '</span></p>',";
echo " '<p class=\"sg-body\"><strong>Bestelldatum:</strong> <span>' + escapeHtml(orderDate) + '</span></p>',";
echo " '<p class=\"sg-body\"><strong>Lieferadresse:</strong></p>',";
echo " '<p class=\"sg-body\">' + escapeHtml(streetLine) + '</p>',";
echo " '<p class=\"sg-body\">' + escapeHtml(cityLine) + '</p>',";
echo " '<p class=\"sg-body\">' + escapeHtml(country) + '</p>',";
echo " '<p class=\"sg-body\"><strong>Gesamtsumme:</strong> <span>' + escapeHtml(total) + '</span></p>',";
echo " '<p class=\"sg-body\">Detailinhalt wird noch definiert.</p>',";
echo " '</div>',";
echo " '</article></div>',";
echo " '</div>'";
echo " ].join('');";
echo " };";
echo " const getDrawer = () => contentRoot.querySelector('[data-bestellungen-drawer]');";
echo " const getDrawerBackdrop = () => contentRoot.querySelector('[data-bestellungen-drawer-backdrop]');";
echo " const setDrawerState = (open) => {";
echo " const drawer = getDrawer();";
@@ -1138,7 +948,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const openDrawer = (trigger) => {";
echo " const drawer = getDrawer();";
echo " if (!drawer) { return; }";
echo " if (isMobileDetailMode() && !bestellungenOpeningMobileDetail) {";
echo " if (isMobileDetailMode()) {";
echo " openMobileDetail(trigger);";
echo " return;";
echo " }";
@@ -1163,7 +973,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " });";
echo " };";
echo " const restoreBestellungenList = () => {";
echo " if (bestellungenMobileDetailHost && bestellungenMobileDetailHost.parentNode) { const mobileDrawer = bestellungenMobileDetailHost.querySelector('[data-bestellungen-mobile-drawer]'); if (mobileDrawer) { contentRoot.appendChild(mobileDrawer); mobileDrawer.removeAttribute('data-bestellungen-mobile-drawer'); mobileDrawer.dataset.open = 'false'; mobileDrawer.setAttribute('aria-hidden', 'true'); } document.body.classList.remove('sg-card-list-page-drawer-open');";
echo " if (bestellungenMobileDetailHost && bestellungenMobileDetailHost.parentNode) {";
echo " bestellungenMobileDetailHost.parentNode.removeChild(bestellungenMobileDetailHost);";
echo " }";
echo " bestellungenMobileDetailHost = null;";
@@ -1173,13 +983,14 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " };";
echo " const openMobileDetail = (trigger) => {";
echo " restoreBestellungenList();";
echo " const sourceDrawer = contentRoot.querySelector('[data-bestellungen-drawer]');";
echo " if (!sourceDrawer) { return; }";
echo " const host = document.createElement('div');";
echo " host.innerHTML = renderMobileDetail();";
echo " host.innerHTML = renderMobileDetail(trigger);";
echo " bestellungenMobileDetailHost = host.firstElementChild;";
echo " if (bestellungenMobileDetailHost) { const mobileDrawer = sourceDrawer; mobileDrawer.dataset.bestellungenMobileDrawer = 'true'; mobileDrawer.dataset.open = 'true'; mobileDrawer.setAttribute('aria-hidden', 'false'); bestellungenMobileDetailHost.appendChild(mobileDrawer); document.body.appendChild(bestellungenMobileDetailHost); document.body.classList.add('sg-bestellungen-mobile-detail-open'); }";
echo " bestellungenOpeningMobileDetail = true; openDrawer(trigger); bestellungenOpeningMobileDetail = false;";
echo " if (bestellungenMobileDetailHost) {";
echo " document.body.appendChild(bestellungenMobileDetailHost);";
echo " document.body.classList.add('sg-bestellungen-mobile-detail-open');";
echo " }";
echo " fetch('/api/bestellungen/detail.php?id=' + encodeURIComponent(trigger.dataset.orderId || ''), { credentials: 'same-origin' }).then((response) => response.ok ? response.json() : Promise.reject(new Error('Bestelldetails konnten nicht geladen werden.'))).then((payload) => { if (!payload.ok || !bestellungenMobileDetailHost) { throw new Error('Bestelldetails konnten nicht geladen werden.'); } const order = payload.order; const cards = bestellungenMobileDetailHost.querySelector('[data-bestellungen-mobile-cards]'); const lines = (order.lines || []).map((line) => '<p class=\"sg-body\"><strong>' + escapeHtml(line.item_name) + '</strong> · ' + escapeHtml(line.item_code) + ' · Menge: ' + escapeHtml(String(line.qty)) + ' · Status: ' + escapeHtml(line.line_status) + '</p>').join(''); const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '<p class=\"sg-body\"><strong>' + escapeHtml(item.product_name) + ':</strong> ' + escapeHtml(item.lot_number || 'Nicht zugeordnet') + ' · ' + escapeHtml(item.allocation_status || 'Nicht vorhanden') + '</p>')).join(''); if (cards) { cards.innerHTML = '<article class=\"sg-card sg-card--content-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\"><div class=\"sg-strong sg-text-on-dark\">Bestellübersicht</div></header><div class=\"sg-card-segment sg-card-segment--body\"><p class=\"sg-body\"><strong>Bestellnummer:</strong> ' + escapeHtml(order.external_ref) + '</p><p class=\"sg-body\"><strong>Bestelldatum:</strong> ' + escapeHtml(formatDate(order.order_date)) + '</p><p class=\"sg-body\"><strong>Verkaufskanal:</strong> ' + escapeHtml(order.order_source) + '</p><p class=\"sg-body\"><strong>Status:</strong> ' + escapeHtml(order.order_status) + '</p></div></article><article class=\"sg-card sg-card--content-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\"><div class=\"sg-strong sg-text-on-dark\">Bestellte Artikel</div></header><div class=\"sg-card-segment sg-card-segment--body\">' + (lines || '<p class=\"sg-body\">Keine Artikel vorhanden.</p>') + '</div></article><article class=\"sg-card sg-card--content-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\"><div class=\"sg-strong sg-text-on-dark\">Chargenzuordnung</div></header><div class=\"sg-card-segment sg-card-segment--body\">' + (allocations || '<p class=\"sg-body\">Keine Chargenzuordnung vorhanden.</p>') + '</div></article>'; } }).catch((error) => { const cards = bestellungenMobileDetailHost?.querySelector('[data-bestellungen-mobile-cards]'); if (cards) { cards.innerHTML = '<p class=\"sg-body\">' + escapeHtml(error.message) + '</p>'; } });";
echo " bestellungenDetailOpen = true;";
echo " window.history.pushState({ bestellungenView: 'detail', orderId: trigger.dataset.orderId || '' }, '', window.location.href);";
echo " };";
@@ -1333,7 +1144,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " closeMobileDetail();";
echo " }";
echo " });";
echo " document.addEventListener('click', (event) => {";
echo " contentRoot.addEventListener('click', (event) => {";
echo " const openTrigger = event.target.closest('[data-order-drawer-open]');";
echo " if (openTrigger) {";
echo " event.preventDefault();";
@@ -1407,7 +1218,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const houseNumberInput = form.querySelector('[data-otc-order-house-number]');";
echo " const zipInput = form.querySelector('[data-otc-order-zip]');";
echo " const cityInput = form.querySelector('[data-otc-order-city]');";
echo " const submitBtn = overlay.querySelector('[data-otc-order-submit]');";
echo " const submitBtn = form.querySelector('[data-otc-order-submit]');";
echo " const errorEl = form.querySelector('[data-otc-order-error]');";
echo " const successEl = form.querySelector('[data-otc-order-success]');";
echo " const successDefaultText = successEl ? successEl.textContent : '';";
@@ -1428,6 +1239,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const clearSuccess = () => {";
echo " if (successEl) { successEl.textContent = successDefaultText; successEl.classList.add('hidden'); }";
echo " };";
echo " let backdropPointerDown = false;";
echo " const getTodayValue = () => {";
echo " const now = new Date();";
echo " const year = now.getFullYear();";
@@ -1518,6 +1330,9 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " confirmationCard.hidden = false;";
echo " requestAnimationFrame(() => confirmationSubmit.focus());";
echo " };";
echo " overlay.addEventListener('pointerdown', (event) => {";
echo " backdropPointerDown = event.target === overlay;";
echo " });";
echo " document.addEventListener('click', (event) => {";
echo " const openTrigger = event.target.closest('[data-otc-order-open]');";
echo " if (openTrigger) {";
@@ -1531,6 +1346,17 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " toggleOverlay(false);";
echo " }";
echo " });";
echo " overlay.addEventListener('click', (event) => {";
echo " if (event.target === overlay && backdropPointerDown) {";
echo " toggleOverlay(false);";
echo " }";
echo " backdropPointerDown = false;";
echo " });";
echo " document.addEventListener('keydown', (event) => {";
echo " if (event.key === 'Escape' && overlay.dataset.open === 'true') {";
echo " toggleOverlay(false);";
echo " }";
echo " });";
echo " [orderDateInput, firstNameInput, lastNameInput, streetInput, houseNumberInput, zipInput, cityInput].forEach((input) => {";
echo " if (!input) { return; }";
echo " input.addEventListener('input', updateFormState);";
@@ -1551,7 +1377,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const row = document.createElement('div');";
echo " row.className = 'sg-form-sections-card__field-group';";
echo " row.dataset.otcPaymentRow = '';";
echo " row.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Bezahlart</span><select class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable sg-pulldown\" id=\"otc-payment-method-' + number + '\" data-otc-payment-method>' + paymentOptions + '</select></label><label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Betrag (CHF)</span><input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable\" type=\"number\" id=\"otc-payment-amount-' + number + '\" min=\"0.01\" step=\"0.01\" value=\"0.00\" data-otc-payment-amount></label>' ;";
echo " row.innerHTML = '<label class=\"sg-label\" for=\"otc-payment-method-' + number + '\">Bezahlart</label><select class=\"sg-interaction-element sg-input-single-line\" id=\"otc-payment-method-' + number + '\" data-otc-payment-method>' + paymentOptions + '</select><label class=\"sg-label\" for=\"otc-payment-amount-' + number + '\">Betrag (CHF)</label><input class=\"sg-interaction-element sg-input-single-line\" type=\"number\" id=\"otc-payment-amount-' + number + '\" min=\"0.01\" step=\"0.01\" value=\"0.00\" data-otc-payment-amount>';";
echo " row.querySelector('[data-otc-payment-method]').value = nextPaymentMethod;";
echo " paymentRows.appendChild(row);";
echo " bindPaymentRow(row);";
+1 -2
View File
@@ -26,10 +26,9 @@ try {
'erp.import-integration.shopify_webhook_worker' => static fn (): array => shopify_webhook_worker_run($pdo, $env, 100),
'erp.import-integration.shopify_delta_order_import' => static function () use ($pdo, $env, $reconciliationStartedAt): array {
$cutoff = shopify_delta_reconciliation_cutoff($pdo);
$recoveryOrderGids = shopify_delta_load_pending_order_gids($pdo);
$runId = shopify_delta_start_run($pdo, $cutoff, false);
try {
$result = run_shopify_delta($env, $pdo, $cutoff, false, $recoveryOrderGids);
$result = run_shopify_delta($env, $pdo, $cutoff, false);
$result['technical_run_id'] = $runId;
if (($result['status'] ?? '') === 'done') {
shopify_delta_store_watermark($pdo, $reconciliationStartedAt->format(DATE_ATOM));
-58
View File
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/market_management/mcp.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
try {
$userSubject = market_management_mcp_authorize($env);
$pdo = connect_database($env);
$body = file_get_contents('php://input');
$request = json_decode($body === false ? '' : $body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['jsonrpc'] ?? '') !== '2.0') {
throw new InvalidArgumentException('Ungültige JSON-RPC-Anfrage.');
}
$id = $request['id'] ?? null;
$method = (string) ($request['method'] ?? '');
$params = is_array($request['params'] ?? null) ? $request['params'] : [];
if ($method === 'initialize') {
$result = [
'protocolVersion' => (string) ($params['protocolVersion'] ?? '2025-03-26'),
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'naurua-market-management', 'version' => '1.0.0'],
];
} elseif ($method === 'notifications/initialized') {
http_response_code(202);
exit;
} elseif ($method === 'tools/list') {
$result = ['tools' => market_management_mcp_tools()];
} elseif ($method === 'tools/call') {
$tool = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
$result = [
'content' => [[
'type' => 'text',
'text' => json_encode(market_management_mcp_call_idempotent($pdo, $userSubject, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
]],
'isError' => false,
];
} else {
throw new InvalidArgumentException('Unbekannte JSON-RPC-Methode: ' . $method);
}
echo json_encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
http_response_code($e instanceof RuntimeException && str_contains($e->getMessage(), 'Token') ? 401 : 400);
echo json_encode([
'jsonrpc' => '2.0',
'id' => $request['id'] ?? null,
'error' => ['code' => -32600, 'message' => $e->getMessage()],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
-70
View File
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../modules/market_management/service.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Unauthorized']);
exit;
}
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($requestMethod === 'POST') {
$csrf = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!auth_validate_csrf_token($csrf)) {
http_response_code(419);
echo json_encode(['ok' => false, 'error' => 'Ungültiges Sicherheits-Token']);
exit;
}
$payload = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Ungültige Anfrage']);
exit;
}
$scope = (string) ($payload['scope'] ?? '');
$id = market_management_require_positive_id($payload['id'] ?? null, 'id');
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
if ($scope === 'occurrence') {
market_management_update_occurrence($pdo, $id, $fields);
} elseif ($scope === 'day') {
market_management_update_day($pdo, $id, $fields);
} else {
throw new InvalidArgumentException('Ungültiger Bearbeitungsbereich.');
}
echo json_encode(['ok' => true]);
exit;
}
$occurrenceId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!is_int($occurrenceId) || $occurrenceId <= 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid occurrence id']);
exit;
}
$occurrence = market_management_get_occurrence($pdo, $occurrenceId);
if ($occurrence === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Occurrence not found']);
exit;
}
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id']);
if ($market === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Market not found']);
exit;
}
echo json_encode(['ok' => true, 'market' => $market, 'occurrence' => $occurrence], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
-148
View File
@@ -660,13 +660,6 @@ section + section {
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -2107,147 +2100,6 @@ section + section {
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
+2 -2
View File
@@ -1,7 +1,7 @@
{
"styleguideVersion": "2026.05.18.1",
"styleguideCommit": "71734d6",
"syncedAtUtc": "2026-09-08T09:49:27Z",
"styleguideCommit": "fd73842",
"syncedAtUtc": "2026-09-06T13:32:07Z",
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
"mirroredDocsPath": "docs/styleguide"
}
-148
View File
@@ -660,13 +660,6 @@ section + section {
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -2107,147 +2100,6 @@ section + section {
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
+4 -127
View File
@@ -39,73 +39,6 @@ body.sg-card-list-page-drawer-open {
overflow: hidden;
}
.sg-market-mobile-detail-host {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
gap: var(--spacing-large);
padding: var(--spacing-small);
box-sizing: border-box;
overflow: auto;
background: var(--color-background-purple);
}
.sg-market-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
body.sg-market-mobile-detail-open > :not([data-market-mobile-detail-host]) {
display: none !important;
}
.market-detail-grid {
display: grid;
grid-template-columns: minmax(10rem, 0.8fr) minmax(0, 1.2fr);
margin: 0;
}
.market-detail-row {
display: contents;
}
.market-detail-row dt,
.market-detail-row dd {
margin: 0;
padding: var(--spacing-small) 0;
border-bottom: 1px solid var(--color-light-grey);
}
.market-detail-row dt {
color: var(--color-medium-grey);
}
.market-detail-row dd {
overflow-wrap: anywhere;
}
.market-day-specific {
color: var(--color-medium-grey);
}
.market-edit-form .sg-component-row {
margin-top: var(--spacing-medium);
align-items: center;
}
@media (max-width: 767px) {
.market-detail-grid {
grid-template-columns: minmax(8rem, 0.9fr) minmax(0, 1.1fr);
}
}
.sg-card-list-page-drawer-backdrop {
position: fixed;
inset: 0;
@@ -163,26 +96,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
width: 100%;
}
.sg-lager-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
.sg-bestellungen-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
.sg-mobile-detail-back-card,
.sg-mobile-detail-back-card * {
cursor: pointer;
@@ -204,8 +117,9 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-otc-order-overlay__panel {
--layout-scrollable-card-width: min(960px, 100%);
--layout-scrollable-card-height: min(600px, calc(100vh - (2 * var(--spacing-large))));
width: min(960px, 100%);
max-height: calc(100vh - (2 * var(--spacing-large)));
overflow: auto;
}
.sg-otc-order-overlay__title {
@@ -224,19 +138,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
color: var(--color-signal-red);
}
.sg-otc-order-overlay [data-otc-order-product] {
flex: 0 0 6rem;
width: 6rem;
max-width: 6rem;
margin-left: auto;
text-align: right;
}
.sg-otc-order-overlay .sg-otc-order-product-row .sg-label {
min-width: 0;
flex: 1 1 auto;
}
.sg-otc-order-overlay .sg-form-sections-card__field-group {
gap: var(--spacing-small);
}
@@ -253,14 +154,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
@media (max-width: 767px) {
.sg-otc-order-overlay [data-otc-order-product] {
flex: 1 1 auto;
width: 100%;
max-width: 100%;
margin-left: 0;
text-align: left;
}
.sg-large-table--bestellungen .sg-search-field-row {
width: min(100%, calc(var(--layout-search-field-width) * 0.75));
}
@@ -314,7 +207,7 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row {
grid-template-columns: 30% repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {
@@ -337,18 +230,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
color: var(--color-signal-yellow);
}
.sg-large-table--markets .market-status--red {
color: var(--color-signal-red);
}
.sg-large-table--markets .market-status--yellow {
color: var(--color-signal-yellow);
}
.sg-large-table--markets .market-status--green {
color: var(--color-signal-green);
}
.sg-card-list-page-drawer [data-lager-edit-form][hidden],
.sg-card-list-page-drawer [data-lager-read-view][hidden],
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-form"][hidden],
@@ -358,10 +239,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
display: none;
}
.sg-card-list-page-drawer [data-component="content-group-box"] {
margin-block: 0;
}
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-action"] {
display: flex;
align-items: center;
+1 -25
View File
@@ -8,7 +8,6 @@ require_once __DIR__ . '/../modules/erp/bestellungen/service.php';
require_once __DIR__ . '/../modules/erp/lager/service.php';
require_once __DIR__ . '/../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php';
require_once __DIR__ . '/../modules/market_management/service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
$pdo = connect_database($env);
@@ -59,34 +58,11 @@ if ($currentUser !== null) {
'limit' => (int) ($_GET['bestellungen_limit'] ?? 20),
'page_size' => 20,
]);
$marketSearch = (string) ($_GET['market_search'] ?? '');
$marketSort = (string) ($_GET['market_sort'] ?? 'earliest_date');
$marketDirection = strtoupper((string) ($_GET['market_dir'] ?? 'ASC'));
$marketLimit = min(100, max(1, (int) ($_GET['market_limit'] ?? 20)));
$marketOccurrencesTable = [
'rows' => market_management_search_occurrences($pdo, [
'search' => $marketSearch,
'sort' => $marketSort,
'sort_direction' => $marketDirection,
'limit' => $marketLimit,
'offset' => 0,
]),
'search' => $marketSearch,
'sort_column' => $marketSort,
'sort_direction' => $marketDirection,
'limit' => $marketLimit,
'page_size' => 20,
];
$marketOccurrencesTable['has_more'] = count($marketOccurrencesTable['rows']) >= $marketLimit;
if ((string) ($_GET['bestellungen_fragment'] ?? '') === '1') {
echo auth_render_bestellungen_content($bestellungenTable);
exit;
}
if ((string) ($_GET['market_fragment'] ?? '') === '1') {
echo auth_render_markets_content($marketOccurrencesTable);
exit;
}
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $marketOccurrencesTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
exit;
}