Compare commits
166
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1e840c97 | ||
|
|
764b6fa886 | ||
|
|
3ae9951b13 | ||
|
|
959586ecc9 | ||
|
|
77f6fee5ac | ||
|
|
8f7fb9875a | ||
|
|
400b352522 | ||
|
|
4a8936761b | ||
|
|
0e8df45548 | ||
|
|
5059bd741a | ||
|
|
8846a57b43 | ||
|
|
17efaee53f | ||
|
|
e69eda9953 | ||
|
|
801695ea89 | ||
|
|
ba65b05c08 | ||
|
|
15b391f1d6 | ||
|
|
f8f011f96c | ||
|
|
a9fa9955d2 | ||
|
|
f50cf944fe | ||
|
|
59c953d42f | ||
|
|
b92c92f5b5 | ||
|
|
7daf8b7096 | ||
|
|
51a5e630a8 | ||
|
|
3f15756596 | ||
|
|
9eb9cd6e0c | ||
|
|
34967b10cd | ||
|
|
f38b48dc53 | ||
|
|
d640f3656d | ||
|
|
f7dcbdf786 | ||
|
|
5903425f78 | ||
|
|
a422137997 | ||
|
|
f83195c9b3 | ||
|
|
b9a6d6d8cd | ||
|
|
520b68a433 | ||
|
|
51571f6224 | ||
|
|
6a2b163a51 | ||
|
|
bd219ac463 | ||
|
|
0b39acbf30 | ||
|
|
61fc46df6e | ||
|
|
59db50eccd | ||
|
|
c83741fca0 | ||
|
|
2b540b91ed | ||
|
|
241e4e961c | ||
|
|
f4b31af7b8 | ||
|
|
4ca6f2241e | ||
|
|
879129cb13 | ||
|
|
df3f2fa46c | ||
|
|
7d865e335a | ||
|
|
2c408f2706 | ||
|
|
6d8508bf3e | ||
|
|
bb5cd7d039 | ||
|
|
ae311e4612 | ||
|
|
485882536f | ||
|
|
f1631f5755 | ||
|
|
99ec2667da | ||
|
|
b3c84fae4c | ||
|
|
fdcea56c26 | ||
|
|
608d91f530 | ||
|
|
1b752c0830 | ||
|
|
a1f6e2edee | ||
|
|
0867ffa0fa | ||
|
|
8356b545c4 | ||
|
|
4dda727f03 | ||
|
|
7491e0b7c5 | ||
|
|
3a3de9da22 | ||
|
|
f130092d40 | ||
|
|
e9903eec19 | ||
|
|
0826560373 | ||
|
|
c75e308f45 | ||
|
|
0d03de8ff2 | ||
|
|
683327c240 | ||
|
|
382d9fe9df | ||
|
|
b8136764eb | ||
|
|
a31057f0c0 | ||
|
|
5b0a623f4b | ||
|
|
b3f52f2810 | ||
|
|
2bec934732 | ||
|
|
e47990dfe0 | ||
|
|
9d55a6d213 | ||
|
|
7c7c6da344 | ||
|
|
6837ebfa6b | ||
|
|
939c96f62d | ||
|
|
82162d5dff | ||
|
|
e98c5e6aa4 | ||
|
|
6bd2bf1b3f | ||
|
|
e62c7e11c2 | ||
|
|
9382647a87 | ||
|
|
668ebbc64a | ||
|
|
44faf75e28 | ||
|
|
49ecbfa17c | ||
|
|
aeab22b48a | ||
|
|
c076ec9889 | ||
|
|
de2df1a13b | ||
|
|
c8d9f03d7d | ||
|
|
28fa29f294 | ||
|
|
584d941c03 | ||
|
|
3a56a24b00 | ||
|
|
e6d4276445 | ||
|
|
19ec3d66d9 | ||
|
|
42f02d9441 | ||
|
|
16569a2357 | ||
|
|
1b783b6417 | ||
|
|
f6ff1d0f78 | ||
|
|
36c66eb0cc | ||
|
|
4fbd944ece | ||
|
|
20670b4666 | ||
|
|
2ee52b3672 | ||
|
|
2e78162e93 | ||
|
|
ca5cf5a03f | ||
|
|
e656841de2 | ||
|
|
7b25f4da0a | ||
|
|
501f32c35e | ||
|
|
af58b813ca | ||
|
|
a5ef8a4f43 | ||
|
|
89057c0c26 | ||
|
|
f21bf84e80 | ||
|
|
b1fdf99e91 | ||
|
|
88dbb008d2 | ||
|
|
1cc35e9b5a | ||
|
|
1362e94948 | ||
|
|
a526cb9f60 | ||
|
|
ba796f0a3f | ||
|
|
f739106ab9 | ||
|
|
307affbb67 | ||
|
|
85617b5b34 | ||
|
|
bd2ef4e799 | ||
|
|
bf875f6dea | ||
|
|
e24394866f | ||
|
|
23d670acca | ||
|
|
109df4f1f9 | ||
|
|
bc40316fdd | ||
|
|
e0cd3a456a | ||
|
|
d5d428e09c | ||
|
|
2a31a88c1d | ||
|
|
3957d57a3a | ||
|
|
24eec23dba | ||
|
|
10758c51b9 | ||
|
|
0e7c5e51d5 | ||
|
|
60e94c7560 | ||
|
|
43cb4b9885 | ||
|
|
0401a7ae2a | ||
|
|
5908f71a39 | ||
|
|
1e5ededa00 | ||
|
|
105a7df1ed | ||
|
|
c5b7b731ec | ||
|
|
81b657f53a | ||
|
|
d14b533b06 | ||
|
|
a5a1a44177 | ||
|
|
28fd56f258 | ||
|
|
14f8c7d433 | ||
|
|
d747600e1c | ||
|
|
ad5d23f6be | ||
|
|
28c4d8374f | ||
|
|
caa80b0505 | ||
|
|
efdbba144f | ||
|
|
0581b7aead | ||
|
|
f4cad97d39 | ||
|
|
575650a7f0 | ||
|
|
e43b5d34e0 | ||
|
|
19ce29c3d0 | ||
|
|
2aadce17bd | ||
|
|
f021aec239 | ||
|
|
d3fa2937a2 | ||
|
|
d16696a6fe | ||
|
|
03ea914d04 | ||
|
|
5dd41a4bb2 |
No files matched your search
@@ -14,6 +14,12 @@
|
||||
10. `db/migrations/0014_require_human_lot_activation.sql`
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE party_role (
|
||||
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
|
||||
role_code TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT pk_party_role PRIMARY KEY (party_id, role_code),
|
||||
CONSTRAINT chk_party_role_code CHECK (role_code IN ('manufacturer'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_party_role_code ON party_role(role_code);
|
||||
|
||||
ALTER TABLE stock_lot
|
||||
RENAME COLUMN supplier_lot_number TO manufacturer_lot_number;
|
||||
|
||||
ALTER TABLE stock_lot
|
||||
ADD COLUMN manufacturer_party_id BIGINT REFERENCES party(id) ON DELETE RESTRICT;
|
||||
|
||||
INSERT INTO party (type, name, status, created_at, updated_at)
|
||||
SELECT 'supplier', MIN(TRIM(supplier_name)), 'active', NOW(), NOW()
|
||||
FROM stock_lot
|
||||
WHERE supplier_name IS NOT NULL
|
||||
AND TRIM(supplier_name) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM party p
|
||||
WHERE LOWER(TRIM(p.name)) = LOWER(TRIM(stock_lot.supplier_name))
|
||||
)
|
||||
GROUP BY LOWER(TRIM(supplier_name));
|
||||
|
||||
INSERT INTO party_role (party_id, role_code)
|
||||
SELECT p.id, 'manufacturer'
|
||||
FROM party p
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM stock_lot sl
|
||||
WHERE sl.supplier_name IS NOT NULL
|
||||
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name))
|
||||
)
|
||||
ON CONFLICT (party_id, role_code) DO NOTHING;
|
||||
|
||||
UPDATE stock_lot sl
|
||||
SET manufacturer_party_id = p.id
|
||||
FROM party p
|
||||
WHERE sl.supplier_name IS NOT NULL
|
||||
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name));
|
||||
|
||||
ALTER TABLE stock_lot
|
||||
DROP COLUMN supplier_name;
|
||||
|
||||
CREATE INDEX idx_stock_lot_manufacturer_party ON stock_lot(manufacturer_party_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE shopify_delta_sync_state (
|
||||
process_name TEXT PRIMARY KEY,
|
||||
last_successful_cutoff TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO shopify_delta_sync_state (process_name, last_successful_cutoff)
|
||||
SELECT
|
||||
'erp.import-integration.shopify_delta_order_import',
|
||||
COALESCE(MAX(shopify_created_at), NOW())
|
||||
FROM sales_order
|
||||
WHERE shopify_order_gid IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS uq_stock_lot_one_open_per_product;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
@@ -11,6 +11,7 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte
|
||||
### `kontakte`
|
||||
|
||||
- `party`
|
||||
- `party_role`
|
||||
- `address`
|
||||
- `contact`
|
||||
- `party_external_identity`
|
||||
|
||||
+4
-3
@@ -8,18 +8,19 @@ Read-only-Abgleich von Shopify-Bestellungen nach dem ERP-Stichtag und identifier
|
||||
|
||||
## 2. Prozess-Einbettung
|
||||
|
||||
Manuell startbarer Initial-Deltalauf und später wiederverwendbarer Reconciliation-Prozess im Submodul `erp/import-integration`. Er ist vom Webhook-Empfang getrennt.
|
||||
Manuell startbarer Initial-Deltalauf und täglich über den zentralen ERP-Cron gestarteter Reconciliation-Prozess im Submodul `erp/import-integration`. Er ist vom Webhook-Empfang getrennt.
|
||||
|
||||
## 3. Input
|
||||
|
||||
- `cutoff_timestamp`
|
||||
- optional `dry_run`
|
||||
- beim täglichen Cron der persistierte letzte erfolgreiche Wasserstand sowie der älteste noch nicht erfolgreich verarbeitete Webhook-Eingang
|
||||
|
||||
Es werden keine vorbereiteten Shopify-Geschäftsdaten an nachgelagerte Module übergeben.
|
||||
|
||||
## 4. Exakter Prozessablauf
|
||||
|
||||
1. `cutoff_timestamp` und `dry_run` validieren.
|
||||
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.
|
||||
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.
|
||||
@@ -42,7 +43,7 @@ Write targets:
|
||||
|
||||
## 5. Batch, Betriebsmodell und Einbettung
|
||||
|
||||
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere Reconciliation-Läufe nutzen denselben Prozess mit einem definierten Wasserzeichen. 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 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.
|
||||
|
||||
## 6. Output
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Zentraler Kontaktstamm fuer Kunden, Lieferanten und sonstige Parteien.
|
||||
## Writes
|
||||
|
||||
- Kontaktstamm
|
||||
- Herstellerrollen im Parteien-Stamm
|
||||
- Adressen
|
||||
- Kommunikationsdaten
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen.
|
||||
- Warenabgang
|
||||
- Umlagerung
|
||||
- Chargenpflege
|
||||
- Verknüpfung von Naurua- und Hersteller-Chargen
|
||||
- Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation
|
||||
|
||||
## Reads
|
||||
@@ -42,4 +43,6 @@ Bestand, Charge, MHD und Bewegungen.
|
||||
|
||||
- `modules/erp/lager/service.php`
|
||||
|
||||
Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug.
|
||||
|
||||
Virtuelle Fehlbestandschargen tragen den Status `virtual_shortage`, sind nicht bestandswirksam und werden bei einer menschlich bestaetigten Nachallokation durch eine neue reale Chargenzuordnung ersetzt. Die virtuelle Ausgangszuordnung bleibt als Historie erhalten.
|
||||
@@ -4,31 +4,32 @@ Status: Verbindliche Prozess-Spezifikation
|
||||
|
||||
## 1. Zweck
|
||||
|
||||
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder aktuellen Charge aktiver Produkte.
|
||||
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.
|
||||
|
||||
## 2. Prozess-Einbettung
|
||||
|
||||
Direkt ausgeführter, täglich um 06:00 Uhr gestarteter Lager-Prozess über den zentralen ERP-Cron-Einstieg.
|
||||
Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolgreichen Verkaufsallokation in der zentralen Lagerschnittstelle für Shopify- und OTC-Verkäufe sowie täglich um 06:00 Uhr über den zentralen ERP-Cron-Einstieg.
|
||||
|
||||
## 3. Input
|
||||
|
||||
- keine externen Prozessparameter
|
||||
- aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand
|
||||
- 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
|
||||
|
||||
## 4. Exakter Prozessablauf
|
||||
|
||||
1. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden.
|
||||
2. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
|
||||
3. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
|
||||
4. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
|
||||
5. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
|
||||
6. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen.
|
||||
7. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren.
|
||||
8. Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
|
||||
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.
|
||||
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 mit einer Halbwertszeit von 60 Tagen berechnen.
|
||||
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.
|
||||
9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
|
||||
|
||||
## 5. Batch, Betriebsmodell und Einbettung
|
||||
|
||||
Ein täglicher Lauf um 06:00 Uhr Europe/Zurich verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Produktfehler sind batch-tolerant; technische Gesamtfehler schlagen den Lauf hart fehl.
|
||||
Der Cron läuft täglich um 06:00 Uhr Europe/Zurich und verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Verkaufstrigger aktualisieren die betroffenen Produkte synchron innerhalb der laufenden Verkaufs-Transaktion. Produktfehler im Cron sind batch-tolerant; technische Gesamtfehler schlagen den jeweiligen Lauf hart fehl.
|
||||
|
||||
## 6. Output
|
||||
|
||||
@@ -36,7 +37,8 @@ Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produ
|
||||
|
||||
## 7. Erfolgskriterien
|
||||
|
||||
- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot.
|
||||
- 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.
|
||||
- Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage.
|
||||
- Die vollständige Berechnungshistorie bleibt erhalten.
|
||||
- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen.
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<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>
|
||||
|
||||
@@ -843,7 +847,28 @@
|
||||
</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';
|
||||
|
||||
@@ -158,6 +158,8 @@
|
||||
<tr><td>input-field-max-width</td><td>600px</td><td>Maximale Breite von ein- und mehrzeiligen Eingabefeldern (ohne Suchfeld).</td></tr>
|
||||
<tr><td>email-body-max-width</td><td>600px</td><td>Maximale Breite des Inhaltsbereichs von E-Mail-Templates innerhalb des E-Mail-Viewports.</td></tr>
|
||||
<tr><td>object-card-height</td><td>600px</td><td>Fixe Höhe der Object Card im Desktop-Layout (u. a. für Company Card).</td></tr>
|
||||
<tr><td>scrollable-card-width</td><td>600px</td><td>Standardbreite der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
|
||||
<tr><td>scrollable-card-height</td><td>600px</td><td>Standardhöhe der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
|
||||
<tr><td>content-card-margin-top-desktop</td><td>100px</td><td>Oberer Außenabstand der Content Card auf Desktop.</td></tr>
|
||||
<tr><td>content-card-margin-top-mobile</td><td>1rem</td><td>Oberer Außenabstand der Content Card auf Mobile; entspricht spacing-large.</td></tr>
|
||||
<tr><td>notification-card-width</td><td>50%</td><td>Breite der Notification Card relativ zum übergeordneten Layout.</td></tr>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<li><a href="./patterns/portal-header.html">Portal Header</a></li>
|
||||
<li><a href="./patterns/options-row.html">Options Row</a></li>
|
||||
<li><a href="./patterns/object-card.html">Object Card</a></li>
|
||||
<li><a href="./patterns/scrollable-card.html">Scrollable Card</a></li>
|
||||
<li><a href="./patterns/object-group-card.html">Object Group Card</a></li>
|
||||
<li><a href="./patterns/navigation-card.html">Navigation Card</a></li>
|
||||
<li><a href="./patterns/left-navigation.html">Left Navigation</a></li>
|
||||
@@ -50,6 +51,7 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Styleguide – Pattern Scrollable Card</title>
|
||||
<link rel="stylesheet" href="../styleguide.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1 class="sg-main-heading">Pattern – Scrollable Card</h1>
|
||||
|
||||
<section id="pattern-scrollable-card">
|
||||
<p class="sg-preview-label">Pattern: Scrollable Card</p>
|
||||
<p class="sg-body">Die Scrollable Card besitzt eine fixe Breite und Höhe. Header und Aktionsbereich bleiben sichtbar; ausschließlich der Inhaltsbereich ist scrollbar. Das Pattern ist unabhängig von Overlay-Patterns und kann bei Bedarf von diesen verwendet werden.</p>
|
||||
|
||||
<article class="sg-card sg-scrollable-card" data-pattern="scrollable-card" aria-label="Scrollable Card">
|
||||
<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-scrollable-card__header" data-pattern-part="scrollable-card-header">
|
||||
<div class="sg-strong">Scrollable Card</div>
|
||||
</header>
|
||||
|
||||
<div class="sg-card-segment sg-card-segment--body sg-scrollable-card__content" data-pattern-part="scrollable-card-content" tabindex="0" aria-label="Scrollbarer Karteninhalt">
|
||||
<p class="sg-body">Der Karteninhalt bleibt innerhalb der definierten Kartenhöhe. Bei längeren Inhalten entsteht hier ein eigener vertikaler Scrollbereich, während Titel und Aktionen sichtbar bleiben.</p>
|
||||
<p class="sg-body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer luctus, turpis vel porttitor cursus, nibh justo feugiat sem, sit amet egestas lorem arcu sed augue.</p>
|
||||
<p class="sg-body">Donec sed lorem in urna sagittis aliquet. Suspendisse potenti. Curabitur blandit tempus porttitor. Maecenas faucibus mollis interdum.</p>
|
||||
<p class="sg-body">Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Aenean lacinia bibendum nulla sed consectetur.</p>
|
||||
<p class="sg-body">Zusätzlicher Inhalt zur Demonstration des unabhängigen Scrollbereichs innerhalb der Card.</p>
|
||||
<p class="sg-body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed posuere consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.</p>
|
||||
<p class="sg-body">Maecenas sed diam eget risus varius blandit sit amet non magna. Donec ullamcorper nulla non metus auctor fringilla.</p>
|
||||
<p class="sg-body">Nullam quis risus eget urna mollis ornare vel eu leo. Vestibulum id ligula porta felis euismod semper.</p>
|
||||
<p class="sg-body">Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Etiam porta sem malesuada magna mollis euismod.</p>
|
||||
<p class="sg-body">Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
|
||||
<p class="sg-body">Curabitur blandit tempus porttitor. Donec id elit non mi porta gravida at eget metus. Vestibulum ligula porta felis euismod semper.</p>
|
||||
<p class="sg-body">Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.</p>
|
||||
<p class="sg-body">Cras justo odio, dapibus ac facilisis in, egestas eget quam. Etiam porta sem malesuada magna mollis euismod.</p>
|
||||
<p class="sg-body">Morbi risus porta ac consectetur ac, vestibulum at eros. Sed posuere consectetur est at lobortis.</p>
|
||||
<p class="sg-body">Donec sed odio dui. Aenean lacinia bibendum nulla sed consectetur. Nulla vitae elit libero, a pharetra augue.</p>
|
||||
<p class="sg-body">Vestibulum id ligula porta felis euismod semper. Donec ullamcorper nulla non metus auctor fringilla.</p>
|
||||
<p class="sg-body">Der untere Aktionsbereich bleibt auch am Ende des Scrollvorgangs an seiner Position sichtbar.</p>
|
||||
</div>
|
||||
|
||||
<footer class="sg-card-segment sg-card-segment--body sg-scrollable-card__actions-segment" data-pattern-part="scrollable-card-actions">
|
||||
<div class="sg-scrollable-card__actions">
|
||||
<button class="sg-interaction-element sg-button sg-button--active" type="button">Abbrechen</button>
|
||||
<button class="sg-interaction-element sg-button sg-button--active" type="button">Speichern</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -63,6 +63,12 @@
|
||||
<tr><td>surface-object-card-lower-segment</td><td>color-white</td><td>Hintergrundfläche der unteren zwei Segmente der Object Card.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h3 class="sg-sub-heading sg-section-h3">Scrollable Card</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>layout-scrollable-card-width</td><td>dimension-scrollable-card-width</td><td>Fixe Breite der Scrollable Card; kann im konsumierenden Portal individuell gesetzt werden.</td></tr>
|
||||
<tr><td>layout-scrollable-card-height</td><td>dimension-scrollable-card-height</td><td>Fixe Höhe der Scrollable Card; kann im konsumierenden Portal individuell gesetzt werden.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h3 class="sg-sub-heading sg-section-h3">Object Group Card</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>layout-object-group-card-min-width</td><td>dimension-object-group-card-min-width</td><td>Mindestbreite der einzelnen Karten im Pattern Object Group Card.</td></tr>
|
||||
@@ -142,6 +148,14 @@
|
||||
<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,6 +2,7 @@
|
||||
@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";
|
||||
@@ -26,3 +27,4 @@
|
||||
@import "./styles/43-components-typography.css";
|
||||
@import "./styles/44-components-public-page-components.css";
|
||||
@import "./styles/45-patterns-public-page-patterns.css";
|
||||
@import "./styles/46-patterns-scrollable-card.css";
|
||||
@@ -175,6 +175,8 @@
|
||||
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
|
||||
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
|
||||
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
|
||||
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
|
||||
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
|
||||
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
|
||||
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
|
||||
--layout-notification-card-width: var(--dimension-notification-card-width);
|
||||
@@ -287,6 +289,8 @@
|
||||
--dimension-object-card-mobile-width: 100%;
|
||||
--dimension-object-card-mobile-height: auto;
|
||||
--dimension-object-card-desktop-breakpoint: 768px;
|
||||
--dimension-scrollable-card-width: 600px;
|
||||
--dimension-scrollable-card-height: 600px;
|
||||
--dimension-content-card-margin-top-desktop: 100px;
|
||||
--dimension-content-card-margin-top-mobile: 1rem;
|
||||
--dimension-object-group-card-min-width: 450px;
|
||||
|
||||
@@ -139,6 +139,13 @@
|
||||
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);
|
||||
@@ -1578,3 +1585,52 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* ========================================================= */
|
||||
/* Patterns: Scrollable Card */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-scrollable-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--layout-scrollable-card-width);
|
||||
max-width: 100%;
|
||||
height: var(--layout-scrollable-card-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__header,
|
||||
.sg-scrollable-card__actions-segment {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-small);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions > .sg-button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* ========================================================= */
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
@@ -129,10 +129,20 @@ function sellable_item_exists(PDO $pdo, int $sellableItemId): bool
|
||||
function find_sellable_item_for_product(PDO $pdo, int $productId): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT sellable_item_id
|
||||
FROM sellable_item_component
|
||||
WHERE product_id = :product_id
|
||||
ORDER BY id
|
||||
'SELECT sic.sellable_item_id
|
||||
FROM sellable_item_component sic
|
||||
INNER JOIN sellable_item si ON si.id = sic.sellable_item_id
|
||||
INNER JOIN product p ON p.id = sic.product_id
|
||||
WHERE sic.product_id = :product_id
|
||||
AND sic.qty_per_item = 1
|
||||
AND si.item_code = p.sku || \'.01\'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sellable_item_component other_sic
|
||||
WHERE other_sic.sellable_item_id = sic.sellable_item_id
|
||||
AND other_sic.id <> sic.id
|
||||
)
|
||||
ORDER BY sic.id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':product_id' => $productId]);
|
||||
|
||||
@@ -374,6 +374,150 @@ function get_sales_order_overview(PDO $pdo, array $filters = []): array
|
||||
];
|
||||
}
|
||||
|
||||
function get_sales_order_detail(PDO $pdo, int $orderId): ?array
|
||||
{
|
||||
$orderStmt = $pdo->prepare(
|
||||
"SELECT
|
||||
so.id,
|
||||
so.external_ref,
|
||||
so.order_date,
|
||||
so.order_source,
|
||||
so.order_status,
|
||||
so.payment_status,
|
||||
so.amount_net,
|
||||
so.amount_shipping,
|
||||
so.amount_tax,
|
||||
so.amount_discount,
|
||||
so.total_amount,
|
||||
so.currency,
|
||||
COALESCE(ad.first_name, '') AS first_name,
|
||||
COALESCE(ad.last_name, '') AS last_name,
|
||||
COALESCE(ad.street, '') AS street,
|
||||
COALESCE(ad.house_number, '') AS house_number,
|
||||
COALESCE(ad.zip, '') AS zip,
|
||||
COALESCE(ad.city, '') AS city,
|
||||
COALESCE(ad.country_name, '') AS country_name,
|
||||
COALESCE(pm.label, '') AS payment_method,
|
||||
COALESCE(sm.label, '') AS shipping_method
|
||||
FROM sales_order so
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT a.first_name, a.last_name, a.street, a.house_number, a.zip, a.city, a.country_name
|
||||
FROM address a
|
||||
WHERE a.party_id = so.party_id AND a.type IN ('shipping', 'billing')
|
||||
ORDER BY CASE WHEN a.type = 'shipping' THEN 0 ELSE 1 END, a.id DESC
|
||||
LIMIT 1
|
||||
) ad ON TRUE
|
||||
LEFT JOIN payment_method pm ON pm.id = so.payment_method_id
|
||||
LEFT JOIN shipping_method sm ON sm.id = so.shipping_method_id
|
||||
WHERE so.id = :order_id"
|
||||
);
|
||||
$orderStmt->execute([':order_id' => $orderId]);
|
||||
$order = $orderStmt->fetch();
|
||||
if (!is_array($order)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lineStmt = $pdo->prepare(
|
||||
"SELECT
|
||||
sol.id,
|
||||
sol.line_no,
|
||||
sol.sellable_item_id,
|
||||
COALESCE(si.item_code, sol.raw_external_article_number, '') AS item_code,
|
||||
COALESCE(si.display_name, sol.raw_external_title, '') AS item_name,
|
||||
COALESCE(sol.raw_external_article_number, '') AS raw_article_number,
|
||||
COALESCE(sol.raw_external_title, '') AS raw_title,
|
||||
sol.sku,
|
||||
sol.qty,
|
||||
sol.qty_cancelled,
|
||||
sol.line_status,
|
||||
sol.unit_price,
|
||||
sol.line_total,
|
||||
sic.product_id,
|
||||
p.name AS product_name,
|
||||
sic.qty_per_item,
|
||||
a.id AS allocation_id,
|
||||
a.qty AS allocated_qty,
|
||||
a.allocation_status,
|
||||
sl.lot_number,
|
||||
sl.status AS lot_status
|
||||
FROM sales_order_line sol
|
||||
LEFT JOIN sellable_item si ON si.id = sol.sellable_item_id
|
||||
LEFT JOIN sellable_item_component sic ON sic.sellable_item_id = sol.sellable_item_id
|
||||
LEFT JOIN product p ON p.id = sic.product_id
|
||||
LEFT JOIN sales_order_line_lot_allocation a
|
||||
ON a.sales_order_line_id = sol.id AND a.product_id = sic.product_id
|
||||
LEFT JOIN stock_lot sl ON sl.id = a.lot_id
|
||||
WHERE sol.sales_order_id = :order_id
|
||||
ORDER BY sol.line_no, p.name NULLS LAST, a.id"
|
||||
);
|
||||
$lineStmt->execute([':order_id' => $orderId]);
|
||||
|
||||
$lines = [];
|
||||
foreach ($lineStmt->fetchAll() as $row) {
|
||||
$lineId = (int) $row['id'];
|
||||
if (!isset($lines[$lineId])) {
|
||||
$lines[$lineId] = [
|
||||
'id' => $lineId,
|
||||
'line_no' => (int) $row['line_no'],
|
||||
'item_code' => (string) $row['item_code'],
|
||||
'item_name' => (string) $row['item_name'],
|
||||
'raw_article_number' => (string) $row['raw_article_number'],
|
||||
'raw_title' => (string) $row['raw_title'],
|
||||
'sku' => (string) ($row['sku'] ?? ''),
|
||||
'qty' => (float) $row['qty'],
|
||||
'qty_cancelled' => (float) $row['qty_cancelled'],
|
||||
'line_status' => (string) $row['line_status'],
|
||||
'unit_price' => $row['unit_price'] !== null ? (float) $row['unit_price'] : null,
|
||||
'line_total' => $row['line_total'] !== null ? (float) $row['line_total'] : null,
|
||||
'product_allocations' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($row['product_id'] !== null) {
|
||||
$lines[$lineId]['product_allocations'][] = [
|
||||
'product_id' => (int) $row['product_id'],
|
||||
'product_name' => (string) $row['product_name'],
|
||||
'requested_qty' => (float) $row['qty'] * (float) $row['qty_per_item'],
|
||||
'allocated_qty' => $row['allocated_qty'] !== null ? (float) $row['allocated_qty'] : null,
|
||||
'allocation_status' => $row['allocation_status'] !== null ? (string) $row['allocation_status'] : null,
|
||||
'lot_number' => $row['lot_number'] !== null ? (string) $row['lot_number'] : null,
|
||||
'lot_status' => $row['lot_status'] !== null ? (string) $row['lot_status'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$detail = [
|
||||
'id' => (int) $order['id'],
|
||||
'external_ref' => (string) $order['external_ref'],
|
||||
'order_date' => (string) $order['order_date'],
|
||||
'order_source' => (string) $order['order_source'],
|
||||
'order_status' => (string) $order['order_status'],
|
||||
'payment_status' => (string) $order['payment_status'],
|
||||
'payment_method' => (string) $order['payment_method'],
|
||||
'shipping_method' => (string) $order['shipping_method'],
|
||||
'customer' => [
|
||||
'first_name' => (string) $order['first_name'],
|
||||
'last_name' => (string) $order['last_name'],
|
||||
'street' => (string) $order['street'],
|
||||
'house_number' => (string) $order['house_number'],
|
||||
'zip' => (string) $order['zip'],
|
||||
'city' => (string) $order['city'],
|
||||
'country_name' => (string) $order['country_name'],
|
||||
],
|
||||
'amounts' => [
|
||||
'net' => $order['amount_net'] !== null ? (float) $order['amount_net'] : null,
|
||||
'shipping' => $order['amount_shipping'] !== null ? (float) $order['amount_shipping'] : null,
|
||||
'tax' => $order['amount_tax'] !== null ? (float) $order['amount_tax'] : null,
|
||||
'discount' => $order['amount_discount'] !== null ? (float) $order['amount_discount'] : null,
|
||||
'total' => $order['total_amount'] !== null ? (float) $order['total_amount'] : null,
|
||||
'currency' => (string) $order['currency'],
|
||||
],
|
||||
'lines' => array_values($lines),
|
||||
];
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
function get_sales_order_realtime_snapshot(PDO $pdo, int $limit = 1000): array
|
||||
{
|
||||
$limit = max(1, min(2000, $limit));
|
||||
|
||||
@@ -30,7 +30,7 @@ if (!is_array($data)) {
|
||||
json_response(400, ['ok' => false, 'error' => 'JSON object expected']);
|
||||
}
|
||||
|
||||
$required = ['products', 'totalPrice', 'orderDate', 'payments', 'billing'];
|
||||
$required = ['products', 'orderDate', 'payments', 'billing'];
|
||||
foreach ($required as $field) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
|
||||
@@ -38,7 +38,6 @@ foreach ($required as $field) {
|
||||
}
|
||||
|
||||
$products = $data['products'];
|
||||
$totalPrice = parse_number($data['totalPrice']);
|
||||
$orderDate = trim((string) $data['orderDate']);
|
||||
$payments = $data['payments'];
|
||||
$billing = is_array($data['billing']) ? $data['billing'] : [];
|
||||
@@ -51,10 +50,6 @@ if (!is_array($payments) || count($payments) === 0) {
|
||||
json_response(422, ['ok' => false, 'error' => 'No payment methods specified']);
|
||||
}
|
||||
|
||||
if ($totalPrice === null || $totalPrice <= 0) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
|
||||
}
|
||||
|
||||
$orderDateTime = DateTimeImmutable::createFromFormat('!Y-m-d', $orderDate);
|
||||
$orderDateErrors = DateTimeImmutable::getLastErrors();
|
||||
if ($orderDateTime === false) {
|
||||
@@ -109,10 +104,12 @@ foreach ($payments as $payment) {
|
||||
$paymentTotal = round($paymentTotal + (float) $amount, 2);
|
||||
}
|
||||
|
||||
if (abs($paymentTotal - (float) $totalPrice) > 0.005) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Payment amounts must equal total price']);
|
||||
if ($paymentTotal <= 0) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Payment total must be greater than zero']);
|
||||
}
|
||||
|
||||
$totalPrice = $paymentTotal;
|
||||
|
||||
try {
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
|
||||
@@ -33,6 +33,77 @@ function shopify_delta_start_run(PDO $pdo, string $cutoff, bool $dryRun): int
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function shopify_delta_reconciliation_cutoff(PDO $pdo): string
|
||||
{
|
||||
$stateStmt = $pdo->prepare(
|
||||
'SELECT last_successful_cutoff
|
||||
FROM shopify_delta_sync_state
|
||||
WHERE process_name = :process_name'
|
||||
);
|
||||
$stateStmt->execute([':process_name' => 'erp.import-integration.shopify_delta_order_import']);
|
||||
$state = $stateStmt->fetchColumn();
|
||||
if ($state === false || trim((string) $state) === '') {
|
||||
throw new RuntimeException('Shopify-Delta-Wasserstand fehlt');
|
||||
}
|
||||
|
||||
$pendingStmt = $pdo->query(
|
||||
"SELECT MIN(received_at)
|
||||
FROM shopify_webhook_event
|
||||
WHERE status IN ('received', 'processing', 'failed')
|
||||
AND shopify_order_gid IS NOT NULL"
|
||||
);
|
||||
$pending = $pendingStmt->fetchColumn();
|
||||
if ($pending === false || $pending === null) {
|
||||
return (string) $state;
|
||||
}
|
||||
|
||||
$stateDate = new DateTimeImmutable((string) $state);
|
||||
$pendingDate = new DateTimeImmutable((string) $pending);
|
||||
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(
|
||||
'UPDATE shopify_delta_sync_state
|
||||
SET last_successful_cutoff = :cutoff, updated_at = NOW()
|
||||
WHERE process_name = :process_name'
|
||||
);
|
||||
$stmt->execute([
|
||||
':cutoff' => $cutoff,
|
||||
':process_name' => 'erp.import-integration.shopify_delta_order_import',
|
||||
]);
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
throw new RuntimeException('Shopify-Delta-Wasserstand konnte nicht gespeichert werden');
|
||||
}
|
||||
}
|
||||
|
||||
function shopify_delta_finish_run(PDO $pdo, int $runId, string $status, array $result, ?Throwable $error = null): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
@@ -75,7 +146,7 @@ GQL;
|
||||
return $orders;
|
||||
}
|
||||
|
||||
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun): array
|
||||
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, array $recoveryOrderGids = []): array
|
||||
{
|
||||
$cutoffDate = new DateTimeImmutable($cutoff);
|
||||
$searchQuery = 'created_at:>' . $cutoffDate->format('Y-m-d');
|
||||
@@ -90,14 +161,31 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun):
|
||||
'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 ($createdAt <= $cutoffDate) {
|
||||
if (!isset($recoverySet[$orderGid]) && $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;
|
||||
}
|
||||
|
||||
@@ -121,8 +209,14 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun):
|
||||
}
|
||||
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,19 +132,21 @@ function shopify_webhook_worker_run(PDO $pdo, array $env, int $limit): array
|
||||
}
|
||||
}
|
||||
|
||||
$argvLimit = 20;
|
||||
foreach ($argv as $argument) {
|
||||
if (str_starts_with($argument, '--limit=')) {
|
||||
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -103,3 +103,85 @@ function upsert_addresses(PDO $pdo, int $partyId, array $data): void
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function get_manufacturer_options(PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT p.id, p.name, p.email, p.phone
|
||||
FROM party p
|
||||
INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer'
|
||||
WHERE p.status = 'active'
|
||||
ORDER BY p.name ASC, p.id ASC"
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): array => [
|
||||
'id' => (int) $row['id'],
|
||||
'name' => (string) $row['name'],
|
||||
'email' => (string) ($row['email'] ?? ''),
|
||||
'phone' => (string) ($row['phone'] ?? ''),
|
||||
],
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
function create_manufacturer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array
|
||||
{
|
||||
$name = trim($name);
|
||||
$email = $email !== null ? trim($email) : null;
|
||||
$phone = $phone !== null ? trim($phone) : null;
|
||||
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Herstellername fehlt');
|
||||
}
|
||||
|
||||
$existingStmt = $pdo->prepare(
|
||||
"SELECT p.id, p.name, p.email, p.phone
|
||||
FROM party p
|
||||
WHERE LOWER(TRIM(p.name)) = LOWER(:name)
|
||||
LIMIT 1
|
||||
FOR UPDATE"
|
||||
);
|
||||
$existingStmt->execute([':name' => $name]);
|
||||
$existing = $existingStmt->fetch();
|
||||
if (is_array($existing)) {
|
||||
$roleStmt = $pdo->prepare(
|
||||
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')
|
||||
ON CONFLICT (party_id, role_code) DO NOTHING"
|
||||
);
|
||||
$roleStmt->execute([':party_id' => (int) $existing['id']]);
|
||||
return [
|
||||
'id' => (int) $existing['id'],
|
||||
'name' => (string) $existing['name'],
|
||||
'email' => (string) ($existing['email'] ?? ''),
|
||||
'phone' => (string) ($existing['phone'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO party (type, name, email, phone, status, created_at, updated_at)
|
||||
VALUES ('supplier', :name, :email, :phone, 'active', NOW(), NOW())
|
||||
RETURNING id, name, email, phone"
|
||||
);
|
||||
$stmt->execute([
|
||||
':name' => $name,
|
||||
':email' => $email !== '' ? $email : null,
|
||||
':phone' => $phone !== '' ? $phone : null,
|
||||
]);
|
||||
$party = $stmt->fetch();
|
||||
if (!is_array($party)) {
|
||||
throw new RuntimeException('Hersteller konnte nicht angelegt werden');
|
||||
}
|
||||
|
||||
$roleStmt = $pdo->prepare(
|
||||
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')"
|
||||
);
|
||||
$roleStmt->execute([':party_id' => (int) $party['id']]);
|
||||
|
||||
return [
|
||||
'id' => (int) $party['id'],
|
||||
'name' => (string) $party['name'],
|
||||
'email' => (string) ($party['email'] ?? ''),
|
||||
'phone' => (string) ($party['phone'] ?? ''),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../shared/db.php';
|
||||
require_once __DIR__ . '/../../../shared/auth/service.php';
|
||||
require_once __DIR__ . '/../../kontakte/service.php';
|
||||
require_once __DIR__ . '/../service.php';
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
|
||||
|
||||
try {
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
$currentUser = auth_current_user($pdo);
|
||||
if ($currentUser === null) {
|
||||
json_response(401, ['ok' => false, 'error' => 'Unauthorized']);
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
|
||||
}
|
||||
|
||||
$jsonInput = file_get_contents('php://input');
|
||||
if ($jsonInput === false || trim($jsonInput) === '') {
|
||||
json_response(400, ['ok' => false, 'error' => 'Empty payload']);
|
||||
}
|
||||
|
||||
$data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data) || !auth_validate_csrf_token($data['csrfToken'] ?? null)) {
|
||||
json_response(403, ['ok' => false, 'error' => 'Invalid security token']);
|
||||
}
|
||||
|
||||
$action = trim((string) ($data['action'] ?? ''));
|
||||
if ($action === 'create_manufacturer') {
|
||||
$manufacturer = create_manufacturer(
|
||||
$pdo,
|
||||
(string) ($data['name'] ?? ''),
|
||||
isset($data['email']) ? (string) $data['email'] : null,
|
||||
isset($data['phone']) ? (string) $data['phone'] : null
|
||||
);
|
||||
json_response(201, ['ok' => true, 'manufacturer' => $manufacturer]);
|
||||
}
|
||||
|
||||
if ($action === 'update_lot') {
|
||||
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
|
||||
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === ''
|
||||
? null
|
||||
: (int) $manufacturerPartyId;
|
||||
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
|
||||
$adjustmentQty = isset($data['adjustmentQty']) && $data['adjustmentQty'] !== '' ? (float) $data['adjustmentQty'] : 0.0;
|
||||
$outQty = isset($data['outQty']) && $data['outQty'] !== '' ? (float) $data['outQty'] : 0.0;
|
||||
$updatedLot = update_stock_lot_traceability(
|
||||
$pdo,
|
||||
(int) ($data['lotId'] ?? 0),
|
||||
$manufacturerPartyId,
|
||||
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,
|
||||
$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)]);
|
||||
}
|
||||
|
||||
if ($action === 'prepare_lot') {
|
||||
$productId = (int) ($data['productId'] ?? 0);
|
||||
$lotNumber = trim((string) ($data['lotNumber'] ?? ''));
|
||||
$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
|
||||
FROM stock_lot
|
||||
WHERE id = :lot_id
|
||||
AND product_id = :product_id
|
||||
AND status = 'open'"
|
||||
);
|
||||
$lotStmt->execute([':lot_id' => $lotId, ':product_id' => $productId]);
|
||||
if ($lotStmt->fetchColumn() === false) {
|
||||
throw new InvalidArgumentException('Offene Charge nicht gefunden');
|
||||
}
|
||||
} else {
|
||||
$lotId = create_open_lot($pdo, $productId);
|
||||
}
|
||||
$preparedLot = update_stock_lot_traceability(
|
||||
$pdo,
|
||||
$lotId,
|
||||
$manufacturerPartyId,
|
||||
$manufacturerLotNumber,
|
||||
$mfgDate,
|
||||
$expiryDate,
|
||||
$purchaseDate,
|
||||
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
|
||||
$receiptQty,
|
||||
0.0,
|
||||
0.0,
|
||||
$lotNumber,
|
||||
$targetQty,
|
||||
$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(422, ['ok' => false, 'error' => 'Unsupported action']);
|
||||
} catch (JsonException) {
|
||||
json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
|
||||
} 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']);
|
||||
}
|
||||
+119
-13
@@ -35,21 +35,67 @@ 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
|
||||
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
|
||||
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"
|
||||
);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays): array
|
||||
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
|
||||
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"
|
||||
);
|
||||
$stmt->execute([':product_id' => $productId]);
|
||||
$row = $stmt->fetch();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays, ?int $productId = null): array
|
||||
{
|
||||
$startDate = $asOfDate->modify('-' . ($lookbackDays - 1) . ' days')->format('Y-m-d');
|
||||
$endDate = $asOfDate->modify('+1 day')->format('Y-m-d');
|
||||
$stmt = $pdo->prepare(
|
||||
$sql =
|
||||
"SELECT a.product_id, so.order_date::DATE AS sale_date,
|
||||
SUM(a.qty * GREATEST(sol.qty - sol.qty_cancelled, 0) / NULLIF(sol.qty, 0)) AS units
|
||||
FROM sales_order_line_lot_allocation a
|
||||
@@ -61,11 +107,15 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
|
||||
AND sol.line_status <> 'cancelled'
|
||||
AND so.order_status <> 'cancelled'
|
||||
AND so.order_date >= :start_date::DATE
|
||||
AND so.order_date < :end_date::DATE
|
||||
GROUP BY a.product_id, so.order_date::DATE
|
||||
ORDER BY a.product_id, sale_date"
|
||||
);
|
||||
$stmt->execute([':start_date' => $startDate, ':end_date' => $endDate]);
|
||||
AND so.order_date < :end_date::DATE";
|
||||
$params = [':start_date' => $startDate, ':end_date' => $endDate];
|
||||
if ($productId !== null) {
|
||||
$sql .= ' AND a.product_id = :product_id';
|
||||
$params[':product_id'] = $productId;
|
||||
}
|
||||
$sql .= ' GROUP BY a.product_id, so.order_date::DATE ORDER BY a.product_id, sale_date';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$sales = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
@@ -74,7 +124,12 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
|
||||
return $sales;
|
||||
}
|
||||
|
||||
function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQty, array $dailySales): array
|
||||
function lager_forecast_calculate(
|
||||
DateTimeImmutable $asOfDate,
|
||||
float $physicalQty,
|
||||
array $dailySales,
|
||||
?DateTimeImmutable $forecastStartDate = null
|
||||
): array
|
||||
{
|
||||
$lookbackDays = 365;
|
||||
$halfLifeDays = 60;
|
||||
@@ -126,8 +181,9 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt
|
||||
? 'high'
|
||||
: ($salesDays >= 10 ? 'medium' : 'low');
|
||||
$daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand);
|
||||
$forecastStartDate ??= $asOfDate;
|
||||
return [
|
||||
'forecast_date' => $asOfDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
|
||||
'forecast_date' => $forecastStartDate->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,
|
||||
@@ -136,7 +192,10 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt
|
||||
|
||||
function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeImmutable $asOfDate): void
|
||||
{
|
||||
$pdo->beginTransaction();
|
||||
$startedTransaction = !$pdo->inTransaction();
|
||||
if ($startedTransaction) {
|
||||
$pdo->beginTransaction();
|
||||
}
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO stock_lot_forecast_history (
|
||||
@@ -172,15 +231,50 @@ function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeI
|
||||
':confidence' => $forecast['confidence'], ':daily_demand' => $forecast['daily_demand'],
|
||||
':lot_id' => (int) $lot['lot_id'],
|
||||
]);
|
||||
$pdo->commit();
|
||||
if ($startedTransaction) {
|
||||
$pdo->commit();
|
||||
}
|
||||
} catch (Throwable $error) {
|
||||
if ($pdo->inTransaction()) {
|
||||
if ($startedTransaction && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId): void
|
||||
{
|
||||
if ($productId <= 0) {
|
||||
throw new InvalidArgumentException('Produkt-ID fuer Forecast fehlt');
|
||||
}
|
||||
|
||||
$lot = lager_forecast_load_current_lot($pdo, $productId);
|
||||
if ($lot === null || $lot['lot_id'] === null) {
|
||||
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
|
||||
}
|
||||
|
||||
$asOfDate = new DateTimeImmutable('today', new DateTimeZone('Europe/Zurich'));
|
||||
$dailySales = lager_forecast_load_daily_sales($pdo, $asOfDate, 365, $productId);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
function run_lager_stock_lot_forecast(PDO $pdo): array
|
||||
{
|
||||
$runId = lager_forecast_start_run($pdo);
|
||||
@@ -204,6 +298,18 @@ 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']++;
|
||||
|
||||
+1159
-8
File diff suppressed because it is too large.
Load diff
+444
-261
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function system_write_audit_log(
|
||||
PDO $pdo,
|
||||
string $entityName,
|
||||
string $entityId,
|
||||
string $action,
|
||||
?string $changedBy,
|
||||
array $beforeData,
|
||||
array $afterData,
|
||||
array $context = []
|
||||
): void {
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO audit_log (
|
||||
entity_name, entity_id, action, changed_by, before_data, after_data, context
|
||||
) VALUES (
|
||||
:entity_name, :entity_id, :action, :changed_by, :before_data::jsonb, :after_data::jsonb, :context::jsonb
|
||||
)"
|
||||
);
|
||||
$stmt->execute([
|
||||
':entity_name' => $entityName,
|
||||
':entity_id' => $entityId,
|
||||
':action' => $action,
|
||||
':changed_by' => $changedBy,
|
||||
':before_data' => json_encode($beforeData, JSON_THROW_ON_ERROR),
|
||||
':after_data' => json_encode($afterData, JSON_THROW_ON_ERROR),
|
||||
':context' => json_encode($context, JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../shared/db.php';
|
||||
require_once __DIR__ . '/../erp/lager/forecast.php';
|
||||
require_once __DIR__ . '/../erp/import-integration/shopify-webhook-worker.php';
|
||||
require_once __DIR__ . '/../erp/import-integration/shopify-delta-run.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
@@ -19,7 +21,26 @@ $env = expand_env_values(parse_env_file(dirname(__DIR__, 2) . '/.env'));
|
||||
$pdo = connect_database($env);
|
||||
|
||||
try {
|
||||
$reconciliationStartedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Zurich'));
|
||||
$jobs = [
|
||||
'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['technical_run_id'] = $runId;
|
||||
if (($result['status'] ?? '') === 'done') {
|
||||
shopify_delta_store_watermark($pdo, $reconciliationStartedAt->format(DATE_ATOM));
|
||||
}
|
||||
shopify_delta_finish_run($pdo, $runId, (string) $result['status'], $result);
|
||||
return $result;
|
||||
} catch (Throwable $error) {
|
||||
shopify_delta_finish_run($pdo, $runId, 'failed', ['status' => 'failed', 'technical_run_id' => $runId], $error);
|
||||
throw $error;
|
||||
}
|
||||
},
|
||||
'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo),
|
||||
];
|
||||
$results = [];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
|
||||
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$orderId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
|
||||
if (!is_int($orderId) || $orderId <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid order id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$detail = get_sales_order_detail($pdo, $orderId);
|
||||
if ($detail === null) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'Order not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'order' => $detail], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../modules/erp/lager/api/lot.php';
|
||||
@@ -1,6 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
http_response_code(410);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Bestellungen-Realtime ist deaktiviert.';
|
||||
exit;
|
||||
|
||||
require_once __DIR__ . '/../../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
|
||||
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
|
||||
|
||||
@@ -175,6 +175,8 @@
|
||||
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
|
||||
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
|
||||
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
|
||||
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
|
||||
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
|
||||
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
|
||||
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
|
||||
--layout-notification-card-width: var(--dimension-notification-card-width);
|
||||
@@ -287,6 +289,8 @@
|
||||
--dimension-object-card-mobile-width: 100%;
|
||||
--dimension-object-card-mobile-height: auto;
|
||||
--dimension-object-card-desktop-breakpoint: 768px;
|
||||
--dimension-scrollable-card-width: 600px;
|
||||
--dimension-scrollable-card-height: 600px;
|
||||
--dimension-content-card-margin-top-desktop: 100px;
|
||||
--dimension-content-card-margin-top-mobile: 1rem;
|
||||
--dimension-object-group-card-min-width: 450px;
|
||||
@@ -656,6 +660,13 @@ 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);
|
||||
@@ -2096,6 +2107,147 @@ 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 */
|
||||
/* ========================================================= */
|
||||
@@ -5031,3 +5183,38 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* Patterns: Scrollable Card */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-scrollable-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--layout-scrollable-card-width);
|
||||
max-width: 100%;
|
||||
height: var(--layout-scrollable-card-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__header,
|
||||
.sg-scrollable-card__actions-segment {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-small);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions > .sg-button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"styleguideVersion": "2026.05.18.1",
|
||||
"styleguideCommit": "3f4ec38",
|
||||
"syncedAtUtc": "2026-09-03T15:57:49Z",
|
||||
"styleguideCommit": "71734d6",
|
||||
"syncedAtUtc": "2026-09-08T09:49:27Z",
|
||||
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
|
||||
"mirroredDocsPath": "docs/styleguide"
|
||||
}
|
||||
@@ -175,6 +175,8 @@
|
||||
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
|
||||
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
|
||||
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
|
||||
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
|
||||
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
|
||||
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
|
||||
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
|
||||
--layout-notification-card-width: var(--dimension-notification-card-width);
|
||||
@@ -287,6 +289,8 @@
|
||||
--dimension-object-card-mobile-width: 100%;
|
||||
--dimension-object-card-mobile-height: auto;
|
||||
--dimension-object-card-desktop-breakpoint: 768px;
|
||||
--dimension-scrollable-card-width: 600px;
|
||||
--dimension-scrollable-card-height: 600px;
|
||||
--dimension-content-card-margin-top-desktop: 100px;
|
||||
--dimension-content-card-margin-top-mobile: 1rem;
|
||||
--dimension-object-group-card-min-width: 450px;
|
||||
@@ -656,6 +660,13 @@ 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);
|
||||
@@ -2096,6 +2107,147 @@ 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 */
|
||||
/* ========================================================= */
|
||||
@@ -5031,3 +5183,38 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* Patterns: Scrollable Card */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-scrollable-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--layout-scrollable-card-width);
|
||||
max-width: 100%;
|
||||
height: var(--layout-scrollable-card-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__header,
|
||||
.sg-scrollable-card__actions-segment {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-small);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions > .sg-button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sg-bestellungen-mobile-detail-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -75,6 +79,48 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-lager-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-lager-mobile-detail-host .sg-vsf-drawer-card {
|
||||
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;
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -91,9 +137,8 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay__panel {
|
||||
width: min(960px, 100%);
|
||||
max-height: calc(100vh - (2 * var(--spacing-large)));
|
||||
overflow: auto;
|
||||
--layout-scrollable-card-width: min(960px, 100%);
|
||||
--layout-scrollable-card-height: min(600px, calc(100vh - (2 * var(--spacing-large))));
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay__title {
|
||||
@@ -112,6 +157,19 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
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);
|
||||
}
|
||||
@@ -128,6 +186,14 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
@@ -181,7 +247,7 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
}
|
||||
|
||||
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {
|
||||
@@ -204,6 +270,22 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
color: var(--color-signal-yellow);
|
||||
}
|
||||
|
||||
.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],
|
||||
.sg-card-list-page-drawer [data-lager-new-lot-notification-wrapper][hidden],
|
||||
.sg-card-list-page-drawer [data-lager-open-edit-form][hidden],
|
||||
.sg-card-list-page-drawer [data-lager-open-card][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-action"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-small);
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer__title {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,6 +6,7 @@ require_once __DIR__ . '/../modules/shared/auth/ui/login.php';
|
||||
require_once __DIR__ . '/../modules/shared/auth/ui/home.php';
|
||||
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';
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
|
||||
@@ -48,6 +49,7 @@ $currentUser = auth_current_user($pdo);
|
||||
if ($currentUser !== null) {
|
||||
$otcProducts = get_otc_order_form_products($pdo);
|
||||
$lagerProducts = get_lager_product_overview($pdo);
|
||||
$lagerManufacturers = get_manufacturer_options($pdo);
|
||||
$lagerArtikel = get_lager_artikel_overview($pdo);
|
||||
$bestellungenTable = get_sales_order_overview($pdo, [
|
||||
'search' => (string) ($_GET['bestellungen_search'] ?? ''),
|
||||
@@ -60,7 +62,7 @@ if ($currentUser !== null) {
|
||||
echo auth_render_bestellungen_content($bestellungenTable);
|
||||
exit;
|
||||
}
|
||||
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel);
|
||||
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user