Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11cb39dc4 |
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -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 +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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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);";
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 */
|
||||
/* ========================================================= */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 */
|
||||
/* ========================================================= */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user