Compare commits

..
Author SHA1 Message Date
gitea_admin 6c1e840c97 Rollback failed charge creation 2026-09-08 19:28:36 +02:00
gitea_admin 764b6fa886 Fix ambiguous charge forecast query 2026-09-08 19:26:11 +02:00
gitea_admin 3ae9951b13 Anchor next lot forecast after current lot 2026-09-08 19:24:34 +02:00
gitea_admin 959586ecc9 Split inventory forecast by current and next lot 2026-09-08 18:48:53 +02:00
gitea_admin 77f6fee5ac Fix spacing between historical lot cards 2026-09-08 18:26:56 +02:00
gitea_admin 8f7fb9875a Complete lot activation and virtual shortage reconciliation 2026-09-08 17:45:19 +02:00
gitea_admin 400b352522 Avoid repeating MHD in warning text 2026-09-08 17:14:46 +02:00
gitea_admin 4a8936761b Reconcile virtual shortage allocations on lot activation 2026-09-08 17:07:04 +02:00
gitea_admin 0e8df45548 Refresh active lot details after promotion 2026-09-08 17:00:13 +02:00
gitea_admin 5059bd741a Fix open lot qualification query 2026-09-08 16:57:56 +02:00
gitea_admin 8846a57b43 Fix virtual shortage lot activation and warning 2026-09-08 16:55:37 +02:00
gitea_admin 17efaee53f Finalize open lot qualification and selection 2026-09-08 15:28:01 +02:00
gitea_admin e69eda9953 Keep open lot groups hidden by product 2026-09-08 15:22:33 +02:00
gitea_admin 801695ea89 Add spacing between open lot cards 2026-09-08 15:19:51 +02:00
gitea_admin ba65b05c08 Disable selected open lot action 2026-09-08 15:18:13 +02:00
gitea_admin 15b391f1d6 Bind new lot card to API result 2026-09-08 15:13:06 +02:00
gitea_admin f8f011f96c Keep lot drawer open after creation 2026-09-08 15:08:27 +02:00
gitea_admin a9fa9955d2 Restore lager tab after lot save 2026-09-08 15:03:09 +02:00
gitea_admin f50cf944fe Reset stale open lot confirmation state 2026-09-08 14:57:57 +02:00
gitea_admin 59c953d42f Fix lot drawer state after save 2026-09-08 14:56:46 +02:00
gitea_admin b92c92f5b5 Fix incomplete lot save selection sync 2026-09-08 14:49:33 +02:00
gitea_admin 7daf8b7096 Restore incomplete lot save confirmation 2026-09-08 14:47:37 +02:00
gitea_admin 51a5e630a8 Backfill qualified lot activation selection 2026-09-08 14:41:13 +02:00
gitea_admin 3f15756596 Correct independent stock lot activation flow 2026-09-08 14:38:54 +02:00
gitea_admin 9eb9cd6e0c Fix lot handler PHP syntax 2026-09-08 14:04:47 +02:00
gitea_admin 34967b10cd Fix PHP quoting in lot deletion handler 2026-09-08 14:03:48 +02:00
gitea_admin f38b48dc53 Fix charge creation after deleting open lot 2026-09-08 13:59:18 +02:00
gitea_admin d640f3656d Revert "Add documents to stock lots"
This reverts commit 2bec934732.
2026-09-08 13:49:20 +02:00
gitea_admin f7dcbdf786 Revert "Fix document download environment path"
This reverts commit b3f52f2810.
2026-09-08 13:49:20 +02:00
gitea_admin 5903425f78 Revert "Align lot document upload layout"
This reverts commit a31057f0c0.
2026-09-08 13:49:20 +02:00
gitea_admin a422137997 Revert "Fix lot document card segment structure"
This reverts commit 382d9fe9df.
2026-09-08 13:49:20 +02:00
gitea_admin f83195c9b3 Revert "Separate lot document view and edit segments"
This reverts commit 683327c240.
2026-09-08 13:49:20 +02:00
gitea_admin b9a6d6d8cd Revert "Hide lot document edit segment in view mode"
This reverts commit 0d03de8ff2.
2026-09-08 13:49:20 +02:00
gitea_admin 520b68a433 Revert "Hide lot document editor outside edit mode"
This reverts commit c75e308f45.
2026-09-08 13:49:20 +02:00
gitea_admin 51571f6224 Revert "Align lot document upload fields"
This reverts commit 0826560373.
2026-09-08 13:49:20 +02:00
gitea_admin 6a2b163a51 Revert "Prevent lot document upload overflow"
This reverts commit e9903eec19.
2026-09-08 13:49:20 +02:00
gitea_admin bd219ac463 Revert "Align document comment label column"
This reverts commit f130092d40.
2026-09-08 13:49:20 +02:00
gitea_admin 0b39acbf30 Revert "Remove lot document comments"
This reverts commit 3a3de9da22.
2026-09-08 13:49:20 +02:00
gitea_admin 61fc46df6e Revert "Save lot documents with edit form"
This reverts commit 7491e0b7c5.
2026-09-08 13:49:20 +02:00
gitea_admin 59db50eccd Revert "Restore full-width lot document segment"
This reverts commit 4dda727f03.
2026-09-08 13:49:20 +02:00
gitea_admin c83741fca0 Revert "Fix PHP escaping in lot drawer script"
This reverts commit 8356b545c4.
2026-09-08 13:49:20 +02:00
gitea_admin 2b540b91ed Revert "Resolve lot drawer PHP parse error"
This reverts commit 0867ffa0fa.
2026-09-08 13:49:20 +02:00
gitea_admin 241e4e961c Revert "Separate lot document card segment"
This reverts commit a1f6e2edee.
2026-09-08 13:49:20 +02:00
gitea_admin f4b31af7b8 Revert "Reset document segments after lot save"
This reverts commit 1b752c0830.
2026-09-08 13:49:20 +02:00
gitea_admin 4ca6f2241e Revert "Fix lot document file picker trigger"
This reverts commit 608d91f530.
2026-09-08 13:49:20 +02:00
gitea_admin 879129cb13 Revert "Fix document file picker trigger"
This reverts commit fdcea56c26.
2026-09-08 13:49:20 +02:00
gitea_admin df3f2fa46c Revert "Fix lot document upload completion"
This reverts commit b3c84fae4c.
2026-09-08 13:49:20 +02:00
gitea_admin 7d865e335a Revert "Show selected lot document files"
This reverts commit 99ec2667da.
2026-09-08 13:49:20 +02:00
gitea_admin 2c408f2706 Revert "Complete lot document selection state"
This reverts commit f1631f5755.
2026-09-08 13:49:20 +02:00
gitea_admin 6d8508bf3e Revert "Use compact file removal buttons"
This reverts commit 485882536f.
2026-09-08 13:49:20 +02:00
gitea_admin bb5cd7d039 Revert "Use compact document removal controls"
This reverts commit ae311e4612.
2026-09-08 13:49:20 +02:00
gitea_admin ae311e4612 Use compact document removal controls 2026-09-08 13:45:49 +02:00
gitea_admin 485882536f Use compact file removal buttons 2026-09-08 13:42:49 +02:00
gitea_admin f1631f5755 Complete lot document selection state 2026-09-08 13:39:52 +02:00
gitea_admin 99ec2667da Show selected lot document files 2026-09-08 13:38:52 +02:00
gitea_admin b3c84fae4c Fix lot document upload completion 2026-09-08 13:32:02 +02:00
gitea_admin fdcea56c26 Fix document file picker trigger 2026-09-08 13:27:38 +02:00
gitea_admin 608d91f530 Fix lot document file picker trigger 2026-09-08 13:25:51 +02:00
gitea_admin 1b752c0830 Reset document segments after lot save 2026-09-08 13:22:52 +02:00
gitea_admin a1f6e2edee Separate lot document card segment 2026-09-08 13:21:56 +02:00
gitea_admin 0867ffa0fa Resolve lot drawer PHP parse error 2026-09-08 13:13:15 +02:00
gitea_admin 8356b545c4 Fix PHP escaping in lot drawer script 2026-09-08 13:12:18 +02:00
gitea_admin 4dda727f03 Restore full-width lot document segment 2026-09-08 13:10:44 +02:00
gitea_admin 7491e0b7c5 Save lot documents with edit form 2026-09-08 13:05:37 +02:00
gitea_admin 3a3de9da22 Remove lot document comments 2026-09-08 12:17:49 +02:00
gitea_admin f130092d40 Align document comment label column 2026-09-08 12:14:29 +02:00
gitea_admin e9903eec19 Prevent lot document upload overflow 2026-09-08 12:12:26 +02:00
gitea_admin 0826560373 Align lot document upload fields 2026-09-08 12:10:44 +02:00
gitea_admin c75e308f45 Hide lot document editor outside edit mode 2026-09-08 12:07:34 +02:00
gitea_admin 0d03de8ff2 Hide lot document edit segment in view mode 2026-09-08 12:05:13 +02:00
gitea_admin 683327c240 Separate lot document view and edit segments 2026-09-08 12:03:11 +02:00
gitea_admin 382d9fe9df Fix lot document card segment structure 2026-09-08 11:58:01 +02:00
gitea_admin b8136764eb Fix open lot deletion audit payload 2026-09-08 11:54:08 +02:00
gitea_admin a31057f0c0 Align lot document upload layout 2026-09-08 11:51:04 +02:00
gitea_admin 5b0a623f4b Sync styleguide 2026.05.18.1 2026-09-08 11:49:28 +02:00
gitea_admin b3f52f2810 Fix document download environment path 2026-09-08 11:38:32 +02:00
gitea_admin 2bec934732 Add documents to stock lots 2026-09-08 11:38:10 +02:00
gitea_admin e47990dfe0 Sync styleguide 2026.05.18.1 2026-09-08 11:28:53 +02:00
gitea_admin 9d55a6d213 Forecast-Warnung mit sprechender Leerdatum-Bezeichnung anzeigen 2026-09-08 10:47:06 +02:00
gitea_admin 7c7c6da344 MHD-Warngrund in der Forecast-Anzeige ergaenzen 2026-09-08 10:45:44 +02:00
gitea_admin 6837ebfa6b Warnungsanzeige nach Forecast-Speicherung aktualisieren 2026-09-08 10:44:36 +02:00
gitea_admin 939c96f62d Chargendialog strukturell an Overlay-Card-Pattern angleichen 2026-09-08 10:42:27 +02:00
gitea_admin 82162d5dff Forecast-Anzeige direkt nach Chargenspeicherung synchronisieren 2026-09-08 10:30:51 +02:00
gitea_admin e98c5e6aa4 Forecast nach jeder Chargenspeicherung aktualisieren 2026-09-08 10:25:30 +02:00
gitea_admin 6bd2bf1b3f Offene-Charge-Overlay an Styleguide-Pattern angleichen 2026-09-08 10:21:44 +02:00
gitea_admin e62c7e11c2 Drawer-Bestand auf das geoeffnete Produkt begrenzen 2026-09-08 10:18:32 +02:00
gitea_admin 9382647a87 Offene Chargen im Drawer vollbreit und im Lagerbestand beruecksichtigen 2026-09-08 10:14:11 +02:00
gitea_admin 668ebbc64a Offene Chargen mit Sicherheitsabfrage loeschen 2026-09-08 09:56:34 +02:00
gitea_admin 44faf75e28 Sync styleguide 2026.05.18.1 2026-09-08 09:53:25 +02:00
gitea_admin 49ecbfa17c NULL-Chargennummer beim Speichern korrekt behandeln 2026-09-08 09:38:46 +02:00
gitea_admin aeab22b48a Chargen-Speicherfehler durch PDO-Platzhalter beheben 2026-09-08 09:38:09 +02:00
gitea_admin c076ec9889 Wording der Leerfeld-Bestaetigung korrigieren 2026-09-08 09:36:34 +02:00
gitea_admin de2df1a13b Leere Chargenfelder vor dem Speichern bestätigen 2026-09-08 09:30:22 +02:00
gitea_admin c8d9f03d7d Formular nach Chargen-Speichern ausblenden 2026-09-08 09:21:34 +02:00
gitea_admin 28fa29f294 Offene Chargen mit Zielmenge korrigieren 2026-09-08 09:18:31 +02:00
gitea_admin 584d941c03 Charge-Mengen beim Bearbeiten ersetzen 2026-09-08 09:16:18 +02:00
gitea_admin 3a56a24b00 Bestellungen mobil denselben Drawer wie Desktop verwenden 2026-09-06 21:18:37 +02:00
gitea_admin e6d4276445 Drawer-Schliessen-Button aus Produktdetail entfernen 2026-09-06 18:01:52 +02:00
gitea_admin 19ec3d66d9 Produktdetail mobil als denselben Drawer verwenden 2026-09-06 18:00:02 +02:00
gitea_admin 42f02d9441 Virtuelle Charge mobil identisch zum Desktop anzeigen 2026-09-06 17:54:47 +02:00
gitea_admin 16569a2357 Neue Charge im mobilen Produktdetail geschlossen starten 2026-09-06 17:52:21 +02:00
gitea_admin 1b783b6417 Mobile Produktdetail mit vollständigem Drawer-Inhalt anzeigen 2026-09-06 17:50:07 +02:00
gitea_admin f6ff1d0f78 Revert "Mobile Lagerdetails mit Desktop-Drawer-Inhalt anzeigen"
This reverts commit 2e78162e93.
2026-09-06 17:46:38 +02:00
gitea_admin 36c66eb0cc Revert "Mobile Produktdetail per DOM-Klon befüllen"
This reverts commit 2ee52b3672.
2026-09-06 17:46:38 +02:00
gitea_admin 4fbd944ece Revert "Mobile Lagerdetail ausserhalb ausgeblendeter Inhalte rendern"
This reverts commit 20670b4666.
2026-09-06 17:46:38 +02:00
gitea_admin 20670b4666 Mobile Lagerdetail ausserhalb ausgeblendeter Inhalte rendern 2026-09-06 17:44:55 +02:00
gitea_admin 2ee52b3672 Mobile Produktdetail per DOM-Klon befüllen 2026-09-06 17:41:14 +02:00
gitea_admin 2e78162e93 Mobile Lagerdetails mit Desktop-Drawer-Inhalt anzeigen 2026-09-06 17:38:44 +02:00
gitea_admin ca5cf5a03f Erlaube mehrere offene Lagerchargen 2026-09-06 17:37:01 +02:00
gitea_admin e656841de2 Beruecksichtige offene Chargenmengen im Forecast 2026-09-06 17:33:23 +02:00
gitea_admin 7b25f4da0a OTC-Produktfelder mobil am Styleguide ausrichten 2026-09-06 16:26:46 +02:00
gitea_admin 501f32c35e OTC-Bestandsanzeige aus Produktzeilen entfernen 2026-09-06 15:52:47 +02:00
gitea_admin af58b813ca OTC-Produktlabels und Mengenfelder ausrichten 2026-09-06 15:51:55 +02:00
gitea_admin a5ef8a4f43 OTC-Produktmengen kompakter darstellen 2026-09-06 15:50:53 +02:00
gitea_admin 89057c0c26 OTC-Formular vollständig am Styleguide ausrichten 2026-09-06 15:49:10 +02:00
gitea_admin f21bf84e80 OTC-Pulldowns mit Styleguide-Chevron anzeigen 2026-09-06 15:47:28 +02:00
gitea_admin b1fdf99e91 OTC-Fenster nicht durch Hintergrundklick schliessen 2026-09-06 15:46:02 +02:00
gitea_admin 88dbb008d2 OTC-Weiter-Button ausserhalb des Formulars aktivieren 2026-09-06 15:44:51 +02:00
gitea_admin 1cc35e9b5a Sync styleguide 2026.05.18.1 2026-09-06 15:43:46 +02:00
gitea_admin 1362e94948 Scrollable-Card-Styles ins Portal synchronisieren 2026-09-06 15:40:05 +02:00
gitea_admin a526cb9f60 Close recovered Shopify events already imported 2026-09-06 15:38:06 +02:00
gitea_admin ba796f0a3f Recover pending Shopify orders by stable order ID 2026-09-06 15:36:58 +02:00
gitea_admin f739106ab9 OTC-Card nur im Inhaltsbereich scrollbar machen 2026-09-06 15:36:24 +02:00
gitea_admin 307affbb67 Allow Shopify worker reuse from central cron 2026-09-06 15:35:25 +02:00
gitea_admin 85617b5b34 OTC-Formular als Scrollable Card darstellen 2026-09-06 15:34:35 +02:00
gitea_admin bd2ef4e799 Merge Shopify daily reconciliation 2026-09-06 15:33:19 +02:00
gitea_admin bf875f6dea Automate Shopify reconciliation in daily cron 2026-09-06 15:31:57 +02:00
gitea_admin e24394866f OTC-Verkauf vor dem Absenden bestätigen 2026-09-06 15:12:53 +02:00
gitea_admin 23d670acca OTC-Formular nach erfolgreicher Erfassung schliessen 2026-09-06 15:07:51 +02:00
gitea_admin 109df4f1f9 Bestellungen-Realtime vorerst deaktivieren 2026-09-06 14:55:51 +02:00
gitea_admin bc40316fdd Blockierenden Bestellungen-Realtime-Stream entfernen 2026-09-06 14:55:08 +02:00
gitea_admin e0cd3a456a OTC auf Einzelartikel je Produkt begrenzen 2026-09-06 14:48:59 +02:00
gitea_admin d5d428e09c Verschiebe Artikeltabelle in Artikelbereich 2026-09-06 14:26:29 +02:00
gitea_admin 2a31a88c1d OTC-Zahlungssummen als Verkaufspreis verwenden 2026-09-06 14:21:58 +02:00
gitea_admin 3957d57a3a Entferne Hintergrund der Neue-Charge-Aktion 2026-09-06 14:19:36 +02:00
gitea_admin 24eec23dba Passe Bearbeitungsmodus offener Chargen an 2026-09-06 14:18:11 +02:00
gitea_admin 10758c51b9 Korrigiere Neue-Charge-Formular und Notification 2026-09-06 14:16:48 +02:00
gitea_admin 0e7c5e51d5 Ermoegliche Bearbeitung offener Chargen 2026-09-06 14:14:40 +02:00
gitea_admin 60e94c7560 Blende technische INIT-Chargen aus Historie aus 2026-09-06 14:12:28 +02:00
gitea_admin 43cb4b9885 Ergaenze offene Chargen und Fehlbestands-Hinweis 2026-09-06 14:09:32 +02:00
gitea_admin 0401a7ae2a Begrenze Bestandskorrekturen auf ganze Mengen 2026-09-06 13:51:44 +02:00
gitea_admin 5908f71a39 Ergaenze Chargenformular um Lagerbewegungen 2026-09-06 13:49:10 +02:00
gitea_admin 1e5ededa00 Chargenspeichern im offenen Drawer abschliessen 2026-09-06 13:40:47 +02:00
gitea_admin 105a7df1ed Chargenspeichern ohne Seitenwechsel ausführen 2026-09-06 13:39:37 +02:00
gitea_admin c5b7b731ec Chargenmengen im Drawer korrekt befüllen 2026-09-06 13:36:55 +02:00
gitea_admin 81b657f53a Naurua-Chargennummer im Formular anzeigen 2026-09-06 13:36:13 +02:00
gitea_admin d14b533b06 Initialen Lesemodus der Charge sicherstellen 2026-09-06 13:34:19 +02:00
gitea_admin a5a1a44177 Aktive Charge mit Formularmodus bearbeiten 2026-09-06 13:32:17 +02:00
gitea_admin 28fd56f258 Lagerabfrage um fehlende Gruppierung korrigieren 2026-09-06 10:53:35 +02:00
gitea_admin 14f8c7d433 Warnungstitel je Warnfall unterscheiden 2026-09-06 10:52:37 +02:00
gitea_admin d747600e1c Historische Chargen im Produkt-Drawer anzeigen 2026-09-06 10:51:26 +02:00
gitea_admin ad5d23f6be Warnkarten im Produkt-Drawer sprechend einfärben 2026-09-06 10:45:56 +02:00
gitea_admin 28c4d8374f Merge feature/bestellungen-drawer-green-cards 2026-09-06 10:32:38 +02:00
gitea_admin caa80b0505 Bestellungen-Drawer an Produkte-Card-Stil angleichen 2026-09-06 10:32:21 +02:00
gitea_admin efdbba144f Merge feature/bestellungen-drawer-cards 2026-09-06 10:29:53 +02:00
gitea_admin 0581b7aead Bestellungen-Drawer in drei getrennte Cards aufteilen 2026-09-06 10:29:37 +02:00
gitea_admin f4cad97d39 Merge feature/bestellungen-drawer 2026-09-06 10:28:23 +02:00
gitea_admin 575650a7f0 Bestellungen-Drawer mit Artikeln und Chargen erweitern 2026-09-06 10:27:54 +02:00
gitea_admin e43b5d34e0 Prevent duplicate manufacturer parties 2026-09-06 10:21:25 +02:00
gitea_admin 19ce29c3d0 Add manufacturer lot traceability 2026-09-06 10:16:47 +02:00
gitea_admin 2aadce17bd Merge Klick-Cursor fuer mobile Zurueck-Cards 2026-09-06 10:10:48 +02:00
gitea_admin f021aec239 Klick-Cursor fuer mobile Zurueck-Cards setzen 2026-09-06 10:09:42 +02:00
43 changed files with 3337 additions and 322 deletions

No files matched your search

+6
View File
@@ -14,6 +14,12 @@
10. `db/migrations/0014_require_human_lot_activation.sql` 10. `db/migrations/0014_require_human_lot_activation.sql`
11. `db/migrations/0015_sales_order_payments.sql` 11. `db/migrations/0015_sales_order_payments.sql`
12. `db/migrations/0016_stock_lot_forecast_history.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 ## 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` ### `kontakte`
- `party` - `party`
- `party_role`
- `address` - `address`
- `contact` - `contact`
- `party_external_identity` - `party_external_identity`
@@ -8,18 +8,19 @@ Read-only-Abgleich von Shopify-Bestellungen nach dem ERP-Stichtag und identifier
## 2. Prozess-Einbettung ## 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 ## 3. Input
- `cutoff_timestamp` - `cutoff_timestamp`
- optional `dry_run` - 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. Es werden keine vorbereiteten Shopify-Geschäftsdaten an nachgelagerte Module übergeben.
## 4. Exakter Prozessablauf ## 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. 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. 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. 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 ## 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 ## 6. Output
+1
View File
@@ -14,6 +14,7 @@ Zentraler Kontaktstamm fuer Kunden, Lieferanten und sonstige Parteien.
## Writes ## Writes
- Kontaktstamm - Kontaktstamm
- Herstellerrollen im Parteien-Stamm
- Adressen - Adressen
- Kommunikationsdaten - Kommunikationsdaten
+3
View File
@@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen.
- Warenabgang - Warenabgang
- Umlagerung - Umlagerung
- Chargenpflege - Chargenpflege
- Verknüpfung von Naurua- und Hersteller-Chargen
- Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation - Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation
## Reads ## Reads
@@ -42,4 +43,6 @@ Bestand, Charge, MHD und Bewegungen.
- `modules/erp/lager/service.php` - `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. 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,7 +4,7 @@ Status: Verbindliche Prozess-Spezifikation
## 1. Zweck ## 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 ## 2. Prozess-Einbettung
@@ -13,18 +13,18 @@ Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolg
## 3. Input ## 3. Input
- optional eine einzelne `product_id` bei synchronem Verkaufstrigger - optional eine einzelne `product_id` bei synchronem Verkaufstrigger
- aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand - 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 ## 4. Exakter Prozessablauf
1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen. 1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen.
2. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden. 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. 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. 4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen. 5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen. 6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
7. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen. 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 in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren. 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. 9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
## 5. Batch, Betriebsmodell und Einbettung ## 5. Batch, Betriebsmodell und Einbettung
@@ -37,7 +37,8 @@ Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produ
## 7. Erfolgskriterien ## 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. - Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage.
- Die vollständige Berechnungshistorie bleibt erhalten. - Die vollständige Berechnungshistorie bleibt erhalten.
- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen. - 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"> <button class="sg-interaction-element sg-button sg-button--active" type="button" data-component="button" data-component-state="active">
Link-Button aktiv Link-Button aktiv
</button> </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> </div>
</section> </section>
@@ -843,7 +847,28 @@
</section> </section>
<script src="../scripts/help-icon-overlays.js"></script> <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> <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) => { document.querySelectorAll('.sg-mode-toggle:not(.sg-activation-mode-toggle)').forEach((toggle) => {
toggle.addEventListener('click', () => { toggle.addEventListener('click', () => {
const nextState = toggle.dataset.active === 'relative' ? 'absolute' : 'relative'; const nextState = toggle.dataset.active === 'relative' ? 'absolute' : 'relative';
+2
View File
@@ -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>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>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>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-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>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> <tr><td>notification-card-width</td><td>50%</td><td>Breite der Notification Card relativ zum übergeordneten Layout.</td></tr>
+2
View File
@@ -43,6 +43,7 @@
<li><a href="./patterns/portal-header.html">Portal Header</a></li> <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/options-row.html">Options Row</a></li>
<li><a href="./patterns/object-card.html">Object Card</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/object-group-card.html">Object Group Card</a></li>
<li><a href="./patterns/navigation-card.html">Navigation 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> <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/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/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/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/multiselektions-pulldown.html">Multiselektions-Pulldown</a></li>
<li><a href="./patterns/text-layouts.html">Text Layouts</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> <li><a href="./patterns/delete-confirmation-overlay.html">Overlay Card</a></li>
+71
View File
@@ -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> <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> </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> <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> <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> <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>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> <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> </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> </section>
</body> </body>
+2
View File
@@ -2,6 +2,7 @@
@import "./styles/02-base.css"; @import "./styles/02-base.css";
@import "./styles/03-typography-helpers.css"; @import "./styles/03-typography-helpers.css";
@import "./styles/10-components-interactive-elements.css"; @import "./styles/10-components-interactive-elements.css";
@import "./styles/47-patterns-file-upload.css";
@import "./styles/20-patterns-portal-header.css"; @import "./styles/20-patterns-portal-header.css";
@import "./styles/21-patterns-options-row.css"; @import "./styles/21-patterns-options-row.css";
@import "./styles/22-patterns-object-card.css"; @import "./styles/22-patterns-object-card.css";
@@ -26,3 +27,4 @@
@import "./styles/43-components-typography.css"; @import "./styles/43-components-typography.css";
@import "./styles/44-components-public-page-components.css"; @import "./styles/44-components-public-page-components.css";
@import "./styles/45-patterns-public-page-patterns.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-width: var(--dimension-object-card-mobile-width);
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height); --layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint); --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-desktop: var(--dimension-content-card-margin-top-desktop);
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile); --layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
--layout-notification-card-width: var(--dimension-notification-card-width); --layout-notification-card-width: var(--dimension-notification-card-width);
@@ -287,6 +289,8 @@
--dimension-object-card-mobile-width: 100%; --dimension-object-card-mobile-width: 100%;
--dimension-object-card-mobile-height: auto; --dimension-object-card-mobile-height: auto;
--dimension-object-card-desktop-breakpoint: 768px; --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-desktop: 100px;
--dimension-content-card-margin-top-mobile: 1rem; --dimension-content-card-margin-top-mobile: 1rem;
--dimension-object-group-card-min-width: 450px; --dimension-object-group-card-min-width: 450px;
@@ -139,6 +139,13 @@
color: var(--text-control-default); 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 { .sg-button--process {
background: var(--surface-button-process); background: var(--surface-button-process);
color: var(--text-button-process); color: var(--text-button-process);
@@ -1578,3 +1585,52 @@
color: var(--text-hyperlink); color: var(--text-hyperlink);
text-decoration: none; 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);
}
}
+14 -4
View File
@@ -129,10 +129,20 @@ function sellable_item_exists(PDO $pdo, int $sellableItemId): bool
function find_sellable_item_for_product(PDO $pdo, int $productId): ?int function find_sellable_item_for_product(PDO $pdo, int $productId): ?int
{ {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT sellable_item_id 'SELECT sic.sellable_item_id
FROM sellable_item_component FROM sellable_item_component sic
WHERE product_id = :product_id INNER JOIN sellable_item si ON si.id = sic.sellable_item_id
ORDER BY 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' LIMIT 1'
); );
$stmt->execute([':product_id' => $productId]); $stmt->execute([':product_id' => $productId]);
+144
View File
@@ -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 function get_sales_order_realtime_snapshot(PDO $pdo, int $limit = 1000): array
{ {
$limit = max(1, min(2000, $limit)); $limit = max(1, min(2000, $limit));
+5 -8
View File
@@ -30,7 +30,7 @@ if (!is_array($data)) {
json_response(400, ['ok' => false, 'error' => 'JSON object expected']); json_response(400, ['ok' => false, 'error' => 'JSON object expected']);
} }
$required = ['products', 'totalPrice', 'orderDate', 'payments', 'billing']; $required = ['products', 'orderDate', 'payments', 'billing'];
foreach ($required as $field) { foreach ($required as $field) {
if (!array_key_exists($field, $data)) { if (!array_key_exists($field, $data)) {
json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]); json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
@@ -38,7 +38,6 @@ foreach ($required as $field) {
} }
$products = $data['products']; $products = $data['products'];
$totalPrice = parse_number($data['totalPrice']);
$orderDate = trim((string) $data['orderDate']); $orderDate = trim((string) $data['orderDate']);
$payments = $data['payments']; $payments = $data['payments'];
$billing = is_array($data['billing']) ? $data['billing'] : []; $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']); 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); $orderDateTime = DateTimeImmutable::createFromFormat('!Y-m-d', $orderDate);
$orderDateErrors = DateTimeImmutable::getLastErrors(); $orderDateErrors = DateTimeImmutable::getLastErrors();
if ($orderDateTime === false) { if ($orderDateTime === false) {
@@ -109,10 +104,12 @@ foreach ($payments as $payment) {
$paymentTotal = round($paymentTotal + (float) $amount, 2); $paymentTotal = round($paymentTotal + (float) $amount, 2);
} }
if (abs($paymentTotal - (float) $totalPrice) > 0.005) { if ($paymentTotal <= 0) {
json_response(422, ['ok' => false, 'error' => 'Payment amounts must equal total price']); json_response(422, ['ok' => false, 'error' => 'Payment total must be greater than zero']);
} }
$totalPrice = $paymentTotal;
try { try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env')); $env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
$pdo = connect_database($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(); 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 function shopify_delta_finish_run(PDO $pdo, int $runId, string $status, array $result, ?Throwable $error = null): void
{ {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
@@ -75,7 +146,7 @@ GQL;
return $orders; 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); $cutoffDate = new DateTimeImmutable($cutoff);
$searchQuery = 'created_at:>' . $cutoffDate->format('Y-m-d'); $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' => [], 'clarification_order_gids' => [],
]; ];
$ordersByGid = [];
foreach (shopify_delta_load_orders($env, $searchQuery) as $summaryOrder) { 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'] ?? '')); $createdAt = new DateTimeImmutable((string) ($summaryOrder['createdAt'] ?? ''));
if ($createdAt <= $cutoffDate) { if (!isset($recoverySet[$orderGid]) && $createdAt <= $cutoffDate) {
continue; continue;
} }
$counters['checked_orders']++; $counters['checked_orders']++;
if (($summaryOrder['cancelledAt'] ?? null) !== null || strtoupper((string) ($summaryOrder['displayFinancialStatus'] ?? '')) === 'REFUNDED') { if (($summaryOrder['cancelledAt'] ?? null) !== null || strtoupper((string) ($summaryOrder['displayFinancialStatus'] ?? '')) === 'REFUNDED') {
$counters['skipped_cancelled_or_refunded']++; $counters['skipped_cancelled_or_refunded']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
continue; continue;
} }
@@ -121,8 +209,14 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun):
} }
if (($result['status'] ?? '') === 'already_imported') { if (($result['status'] ?? '') === 'already_imported') {
$counters['already_imported']++; $counters['already_imported']++;
if (!$dryRun && isset($recoverySet[(string) $summaryOrder['id']])) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
} else { } else {
$counters['handed_off']++; $counters['handed_off']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
} }
} catch (Throwable $error) { } catch (Throwable $error) {
$counters['status'] = 'partial_success'; $counters['status'] = 'partial_success';
@@ -132,19 +132,21 @@ function shopify_webhook_worker_run(PDO $pdo, array $env, int $limit): array
} }
} }
$argvLimit = 20; if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
foreach ($argv as $argument) { $argvLimit = 20;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--limit=')) { if (str_starts_with($argument, '--limit=')) {
$argvLimit = max(1, min(100, (int) substr($argument, 8))); $argvLimit = max(1, min(100, (int) substr($argument, 8)));
} }
} }
try { try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env')); $env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env); $pdo = connect_database($env);
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL; echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0); exit(0);
} catch (Throwable $error) { } catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL); fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1); exit(1);
}
} }
+82
View File
@@ -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'] ?? ''),
];
}
+196
View File
@@ -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']);
}
+64 -4
View File
@@ -35,10 +35,25 @@ function lager_forecast_load_current_lots(PDO $pdo): array
{ {
$stmt = $pdo->query( $stmt = $pdo->query(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id, "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 FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current' 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 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' WHERE p.status = 'active'
ORDER BY p.id" ORDER BY p.id"
); );
@@ -49,10 +64,25 @@ function lager_forecast_load_current_lot(PDO $pdo, int $productId): ?array
{ {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id, "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 FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current' 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 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 WHERE p.status = 'active' AND p.id = :product_id
LIMIT 1" LIMIT 1"
); );
@@ -94,7 +124,12 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
return $sales; 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; $lookbackDays = 365;
$halfLifeDays = 60; $halfLifeDays = 60;
@@ -146,8 +181,9 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt
? 'high' ? 'high'
: ($salesDays >= 10 ? 'medium' : 'low'); : ($salesDays >= 10 ? 'medium' : 'low');
$daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand); $daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand);
$forecastStartDate ??= $asOfDate;
return [ 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, 'forecast_status' => 'forecast', 'confidence' => $confidence, 'daily_demand' => $dailyDemand,
'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays, 'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays,
'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays, 'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays,
@@ -225,6 +261,18 @@ function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId):
$dailySales[$productId] ?? [] $dailySales[$productId] ?? []
); );
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate); 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 function run_lager_stock_lot_forecast(PDO $pdo): array
@@ -250,6 +298,18 @@ function run_lager_stock_lot_forecast(PDO $pdo): array
try { try {
$forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []); $forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []);
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate); 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']++; $result['processed_products']++;
} catch (Throwable $error) { } catch (Throwable $error) {
$result['failed_products']++; $result['failed_products']++;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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),
]);
}
+21
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../shared/db.php'; require_once __DIR__ . '/../shared/db.php';
require_once __DIR__ . '/../erp/lager/forecast.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') { if (PHP_SAPI !== 'cli') {
http_response_code(404); http_response_code(404);
@@ -19,7 +21,26 @@ $env = expand_env_values(parse_env_file(dirname(__DIR__, 2) . '/.env'));
$pdo = connect_database($env); $pdo = connect_database($env);
try { try {
$reconciliationStartedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Zurich'));
$jobs = [ $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), 'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo),
]; ];
$results = []; $results = [];
+34
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/erp/lager/api/lot.php';
+5
View File
@@ -1,6 +1,11 @@
<?php <?php
declare(strict_types=1); 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/db.php';
require_once __DIR__ . '/../../../modules/shared/auth/service.php'; require_once __DIR__ . '/../../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php'; require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
+187
View File
@@ -175,6 +175,8 @@
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width); --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-mobile-height: var(--dimension-object-card-mobile-height);
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint); --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-desktop: var(--dimension-content-card-margin-top-desktop);
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile); --layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
--layout-notification-card-width: var(--dimension-notification-card-width); --layout-notification-card-width: var(--dimension-notification-card-width);
@@ -287,6 +289,8 @@
--dimension-object-card-mobile-width: 100%; --dimension-object-card-mobile-width: 100%;
--dimension-object-card-mobile-height: auto; --dimension-object-card-mobile-height: auto;
--dimension-object-card-desktop-breakpoint: 768px; --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-desktop: 100px;
--dimension-content-card-margin-top-mobile: 1rem; --dimension-content-card-margin-top-mobile: 1rem;
--dimension-object-group-card-min-width: 450px; --dimension-object-group-card-min-width: 450px;
@@ -656,6 +660,13 @@ section + section {
color: var(--text-control-default); 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 { .sg-button--process {
background: var(--surface-button-process); background: var(--surface-button-process);
color: var(--text-button-process); color: var(--text-button-process);
@@ -2096,6 +2107,147 @@ section + section {
text-decoration: none; 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 */ /* 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;
}
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"styleguideVersion": "2026.05.18.1", "styleguideVersion": "2026.05.18.1",
"styleguideCommit": "3f4ec38", "styleguideCommit": "71734d6",
"syncedAtUtc": "2026-09-03T15:57:49Z", "syncedAtUtc": "2026-09-08T09:49:27Z",
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide", "sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
"mirroredDocsPath": "docs/styleguide" "mirroredDocsPath": "docs/styleguide"
} }
+187
View File
@@ -175,6 +175,8 @@
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width); --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-mobile-height: var(--dimension-object-card-mobile-height);
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint); --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-desktop: var(--dimension-content-card-margin-top-desktop);
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile); --layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
--layout-notification-card-width: var(--dimension-notification-card-width); --layout-notification-card-width: var(--dimension-notification-card-width);
@@ -287,6 +289,8 @@
--dimension-object-card-mobile-width: 100%; --dimension-object-card-mobile-width: 100%;
--dimension-object-card-mobile-height: auto; --dimension-object-card-mobile-height: auto;
--dimension-object-card-desktop-breakpoint: 768px; --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-desktop: 100px;
--dimension-content-card-margin-top-mobile: 1rem; --dimension-content-card-margin-top-mobile: 1rem;
--dimension-object-group-card-min-width: 450px; --dimension-object-group-card-min-width: 450px;
@@ -656,6 +660,13 @@ section + section {
color: var(--text-control-default); 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 { .sg-button--process {
background: var(--surface-button-process); background: var(--surface-button-process);
color: var(--text-button-process); color: var(--text-button-process);
@@ -2096,6 +2107,147 @@ section + section {
text-decoration: none; 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 */ /* 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;
}
+65 -4
View File
@@ -96,6 +96,31 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
width: 100%; 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 { .sg-otc-order-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -112,9 +137,8 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
} }
.sg-otc-order-overlay__panel { .sg-otc-order-overlay__panel {
width: min(960px, 100%); --layout-scrollable-card-width: min(960px, 100%);
max-height: calc(100vh - (2 * var(--spacing-large))); --layout-scrollable-card-height: min(600px, calc(100vh - (2 * var(--spacing-large))));
overflow: auto;
} }
.sg-otc-order-overlay__title { .sg-otc-order-overlay__title {
@@ -133,6 +157,19 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
color: var(--color-signal-red); 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 { .sg-otc-order-overlay .sg-form-sections-card__field-group {
gap: var(--spacing-small); gap: var(--spacing-small);
} }
@@ -149,6 +186,14 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
} }
@media (max-width: 767px) { @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 { .sg-large-table--bestellungen .sg-search-field-row {
width: min(100%, calc(var(--layout-search-field-width) * 0.75)); width: min(100%, calc(var(--layout-search-field-width) * 0.75));
} }
@@ -202,7 +247,7 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
} }
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row { .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 { .sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {
@@ -225,6 +270,22 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
color: var(--color-signal-yellow); 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 { .sg-card-list-page-drawer__title {
color: var(--color-white); color: var(--color-white);
} }
+3 -1
View File
@@ -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/shared/auth/ui/home.php';
require_once __DIR__ . '/../modules/erp/bestellungen/service.php'; require_once __DIR__ . '/../modules/erp/bestellungen/service.php';
require_once __DIR__ . '/../modules/erp/lager/service.php'; require_once __DIR__ . '/../modules/erp/lager/service.php';
require_once __DIR__ . '/../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php'; require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env')); $env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
@@ -48,6 +49,7 @@ $currentUser = auth_current_user($pdo);
if ($currentUser !== null) { if ($currentUser !== null) {
$otcProducts = get_otc_order_form_products($pdo); $otcProducts = get_otc_order_form_products($pdo);
$lagerProducts = get_lager_product_overview($pdo); $lagerProducts = get_lager_product_overview($pdo);
$lagerManufacturers = get_manufacturer_options($pdo);
$lagerArtikel = get_lager_artikel_overview($pdo); $lagerArtikel = get_lager_artikel_overview($pdo);
$bestellungenTable = get_sales_order_overview($pdo, [ $bestellungenTable = get_sales_order_overview($pdo, [
'search' => (string) ($_GET['bestellungen_search'] ?? ''), 'search' => (string) ($_GET['bestellungen_search'] ?? ''),
@@ -60,7 +62,7 @@ if ($currentUser !== null) {
echo auth_render_bestellungen_content($bestellungenTable); echo auth_render_bestellungen_content($bestellungenTable);
exit; exit;
} }
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel); render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
exit; exit;
} }