Compare commits
339
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7f6b1820 | ||
|
|
d0079aa5f7 | ||
|
|
e2e6a40d9a | ||
|
|
99eae4cada | ||
|
|
de10e37a0c | ||
|
|
4758aecec2 | ||
|
|
c4e1c291d8 | ||
|
|
42127b0916 | ||
|
|
d03f653848 | ||
|
|
b1fd16cf06 | ||
|
|
b2cb88e4ff | ||
|
|
ee9bcc85ce | ||
|
|
4f0094a821 | ||
|
|
c2913c1026 | ||
|
|
13420b0e01 | ||
|
|
5567b53581 | ||
|
|
ff0fffccad | ||
|
|
38886e39bd | ||
|
|
25ef8ef7b0 | ||
|
|
6750d4778f | ||
|
|
7fc75eaee6 | ||
|
|
f1b090ee85 | ||
|
|
e6431dbe66 | ||
|
|
23c0cf69d7 | ||
|
|
e4be458433 | ||
|
|
0c8cb05237 | ||
|
|
5d9c058e10 | ||
|
|
af235987ea | ||
|
|
bae293ad94 | ||
|
|
1ad35cdcfd | ||
|
|
c0c3c2600f | ||
|
|
5413bae3ea | ||
|
|
e51f3ab507 | ||
|
|
e1f482ef5c | ||
|
|
f14023bd92 | ||
|
|
d68654e94f | ||
|
|
724e3413e4 | ||
|
|
f77ce71bd6 | ||
|
|
dc52003c01 | ||
|
|
3a0cb81a97 | ||
|
|
313475afec | ||
|
|
6226f2a037 | ||
|
|
2a09401600 | ||
|
|
2d3c9ce4ab | ||
|
|
273fb05833 | ||
|
|
dfe9748ff9 | ||
|
|
1b66cd3add | ||
|
|
a6f9236abf | ||
|
|
36fb17ac32 | ||
|
|
0e71aca89b | ||
|
|
ba36485180 | ||
|
|
0edd9e7b3c | ||
|
|
1fcd6aa245 | ||
|
|
1d0e71c5db | ||
|
|
5825d37e83 | ||
|
|
5b83d10d92 | ||
|
|
95c42b342b | ||
|
|
78cc6390f7 | ||
|
|
9b49db98d4 | ||
|
|
e6bf7be2f9 | ||
|
|
212476c3f6 | ||
|
|
42684f3864 | ||
|
|
e2e46a601b | ||
|
|
a1ac5f34ae | ||
|
|
1bf5b51679 | ||
|
|
b68546f824 | ||
|
|
841e559a70 | ||
|
|
f0af2efe8a | ||
|
|
c9d386badd | ||
|
|
f4834e0e7a | ||
|
|
1921464e27 | ||
|
|
f720782736 | ||
|
|
43598bf65a | ||
|
|
2bfe91695e | ||
|
|
22880b512a | ||
|
|
debee672f8 | ||
|
|
3d60998145 | ||
|
|
481da014bc | ||
|
|
d22395b96c | ||
|
|
aaa9fa67e9 | ||
|
|
ca2facdbc6 | ||
|
|
3d638ab2a1 | ||
|
|
30c11c5ca7 | ||
|
|
2a1144f0aa | ||
|
|
21e0f48fd5 | ||
|
|
9fc85141e0 | ||
|
|
184f50d034 | ||
|
|
6fae2de15f | ||
|
|
e188f932f4 | ||
|
|
71f329170f | ||
|
|
3ba33f85a0 | ||
|
|
1b553a9095 | ||
|
|
e9d157c87e | ||
|
|
47c09a3e4c | ||
|
|
759e337976 | ||
|
|
b15eda6ff1 | ||
|
|
71b27a387d | ||
|
|
2c734bd415 | ||
|
|
20f130f614 | ||
|
|
4eef9d9785 | ||
|
|
361635332c | ||
|
|
a42d7a0718 | ||
|
|
4b112f3a57 | ||
|
|
0de04a8dfa | ||
|
|
bace7b4a05 | ||
|
|
18e89595e0 | ||
|
|
d1129175bc | ||
|
|
c1cb272702 | ||
|
|
b7ff51adc5 | ||
|
|
fdc0f02b91 | ||
|
|
c4b93ff217 | ||
|
|
ca42ded4e2 | ||
|
|
30e44755fe | ||
|
|
2862d7a6e9 | ||
|
|
28784ed6ac | ||
|
|
6a12a43114 | ||
|
|
94d336f090 | ||
|
|
af0206d9cd | ||
|
|
65d2b1405f | ||
|
|
57d8da9577 | ||
|
|
2a754973cd | ||
|
|
e503759197 | ||
|
|
47baeaa5f7 | ||
|
|
0fba652770 | ||
|
|
9064d4eb42 | ||
|
|
f66c22027e | ||
|
|
614a934dc1 | ||
|
|
906c76a959 | ||
|
|
f2467f4411 | ||
|
|
aa68b41971 | ||
|
|
fdabe38180 | ||
|
|
ecdd973c3f | ||
|
|
fe641e9e7c | ||
|
|
0c3a87868c | ||
|
|
278e415e08 | ||
|
|
16882207bb | ||
|
|
a672fc76e4 | ||
|
|
1eef7bd70b | ||
|
|
49fd392f79 | ||
|
|
7178c4af53 | ||
|
|
551f90a617 | ||
|
|
b22e3ce391 | ||
|
|
662579cc6e | ||
|
|
bbec87abfa | ||
|
|
c147624d71 | ||
|
|
889530d8e6 | ||
|
|
5f3c71a96c | ||
|
|
985ada2f39 | ||
|
|
18d73dd4d5 | ||
|
|
f9ffa5a6b6 | ||
|
|
d259c35000 | ||
|
|
667278563d | ||
|
|
de61a3e515 | ||
|
|
d0240ddfab | ||
|
|
bb2ddb388e | ||
|
|
9bde337506 | ||
|
|
3267a121de | ||
|
|
df5a533983 | ||
|
|
2607f448ad | ||
|
|
8492db2e39 | ||
|
|
7c2d1cedb6 | ||
|
|
2d49840aba | ||
|
|
8d5cb3b289 | ||
|
|
5f6806ab33 | ||
|
|
9e877ac464 | ||
|
|
e960cca6ea | ||
|
|
4d4f354eef | ||
|
|
0e5d3fda91 | ||
|
|
d69faae5f4 | ||
|
|
48f8cc6890 | ||
|
|
4e477d9034 | ||
|
|
91ac3a6828 | ||
|
|
185dfcdb38 | ||
|
|
6c1e840c97 | ||
|
|
764b6fa886 | ||
|
|
3ae9951b13 | ||
|
|
959586ecc9 | ||
|
|
77f6fee5ac | ||
|
|
8f7fb9875a | ||
|
|
400b352522 | ||
|
|
4a8936761b | ||
|
|
0e8df45548 | ||
|
|
5059bd741a | ||
|
|
8846a57b43 | ||
|
|
17efaee53f | ||
|
|
e69eda9953 | ||
|
|
801695ea89 | ||
|
|
ba65b05c08 | ||
|
|
15b391f1d6 | ||
|
|
f8f011f96c | ||
|
|
a9fa9955d2 | ||
|
|
f50cf944fe | ||
|
|
59c953d42f | ||
|
|
b92c92f5b5 | ||
|
|
7daf8b7096 | ||
|
|
51a5e630a8 | ||
|
|
3f15756596 | ||
|
|
9eb9cd6e0c | ||
|
|
34967b10cd | ||
|
|
f38b48dc53 | ||
|
|
d640f3656d | ||
|
|
f7dcbdf786 | ||
|
|
5903425f78 | ||
|
|
a422137997 | ||
|
|
f83195c9b3 | ||
|
|
b9a6d6d8cd | ||
|
|
520b68a433 | ||
|
|
51571f6224 | ||
|
|
6a2b163a51 | ||
|
|
bd219ac463 | ||
|
|
0b39acbf30 | ||
|
|
61fc46df6e | ||
|
|
59db50eccd | ||
|
|
c83741fca0 | ||
|
|
2b540b91ed | ||
|
|
241e4e961c | ||
|
|
f4b31af7b8 | ||
|
|
4ca6f2241e | ||
|
|
879129cb13 | ||
|
|
df3f2fa46c | ||
|
|
7d865e335a | ||
|
|
2c408f2706 | ||
|
|
6d8508bf3e | ||
|
|
bb5cd7d039 | ||
|
|
ae311e4612 | ||
|
|
485882536f | ||
|
|
f1631f5755 | ||
|
|
99ec2667da | ||
|
|
b3c84fae4c | ||
|
|
fdcea56c26 | ||
|
|
608d91f530 | ||
|
|
1b752c0830 | ||
|
|
a1f6e2edee | ||
|
|
0867ffa0fa | ||
|
|
8356b545c4 | ||
|
|
4dda727f03 | ||
|
|
7491e0b7c5 | ||
|
|
3a3de9da22 | ||
|
|
f130092d40 | ||
|
|
e9903eec19 | ||
|
|
0826560373 | ||
|
|
c75e308f45 | ||
|
|
0d03de8ff2 | ||
|
|
683327c240 | ||
|
|
382d9fe9df | ||
|
|
b8136764eb | ||
|
|
a31057f0c0 | ||
|
|
5b0a623f4b | ||
|
|
b3f52f2810 | ||
|
|
2bec934732 | ||
|
|
e47990dfe0 | ||
|
|
9d55a6d213 | ||
|
|
7c7c6da344 | ||
|
|
6837ebfa6b | ||
|
|
939c96f62d | ||
|
|
82162d5dff | ||
|
|
e98c5e6aa4 | ||
|
|
6bd2bf1b3f | ||
|
|
e62c7e11c2 | ||
|
|
9382647a87 | ||
|
|
668ebbc64a | ||
|
|
44faf75e28 | ||
|
|
49ecbfa17c | ||
|
|
aeab22b48a | ||
|
|
c076ec9889 | ||
|
|
de2df1a13b | ||
|
|
c8d9f03d7d | ||
|
|
28fa29f294 | ||
|
|
584d941c03 | ||
|
|
3a56a24b00 | ||
|
|
e6d4276445 | ||
|
|
19ec3d66d9 | ||
|
|
42f02d9441 | ||
|
|
16569a2357 | ||
|
|
1b783b6417 | ||
|
|
f6ff1d0f78 | ||
|
|
36c66eb0cc | ||
|
|
4fbd944ece | ||
|
|
20670b4666 | ||
|
|
2ee52b3672 | ||
|
|
2e78162e93 | ||
|
|
ca5cf5a03f | ||
|
|
e656841de2 | ||
|
|
7b25f4da0a | ||
|
|
501f32c35e | ||
|
|
af58b813ca | ||
|
|
a5ef8a4f43 | ||
|
|
89057c0c26 | ||
|
|
f21bf84e80 | ||
|
|
b1fdf99e91 | ||
|
|
88dbb008d2 | ||
|
|
1cc35e9b5a | ||
|
|
1362e94948 | ||
|
|
a526cb9f60 | ||
|
|
ba796f0a3f | ||
|
|
f739106ab9 | ||
|
|
307affbb67 | ||
|
|
85617b5b34 | ||
|
|
bd2ef4e799 | ||
|
|
bf875f6dea | ||
|
|
e24394866f | ||
|
|
23d670acca | ||
|
|
109df4f1f9 | ||
|
|
bc40316fdd | ||
|
|
e0cd3a456a | ||
|
|
d5d428e09c | ||
|
|
2a31a88c1d | ||
|
|
3957d57a3a | ||
|
|
24eec23dba | ||
|
|
10758c51b9 | ||
|
|
0e7c5e51d5 | ||
|
|
60e94c7560 | ||
|
|
43cb4b9885 | ||
|
|
0401a7ae2a | ||
|
|
5908f71a39 | ||
|
|
1e5ededa00 | ||
|
|
105a7df1ed | ||
|
|
c5b7b731ec | ||
|
|
81b657f53a | ||
|
|
d14b533b06 | ||
|
|
a5a1a44177 | ||
|
|
28fd56f258 | ||
|
|
14f8c7d433 | ||
|
|
d747600e1c | ||
|
|
ad5d23f6be | ||
|
|
28c4d8374f | ||
|
|
caa80b0505 | ||
|
|
efdbba144f | ||
|
|
0581b7aead | ||
|
|
f4cad97d39 | ||
|
|
575650a7f0 | ||
|
|
e43b5d34e0 | ||
|
|
19ce29c3d0 | ||
|
|
2aadce17bd | ||
|
|
f021aec239 | ||
|
|
d3fa2937a2 | ||
|
|
d16696a6fe | ||
|
|
03ea914d04 | ||
|
|
5dd41a4bb2 |
No files matched your search
@@ -14,6 +14,12 @@
|
||||
10. `db/migrations/0014_require_human_lot_activation.sql`
|
||||
11. `db/migrations/0015_sales_order_payments.sql`
|
||||
12. `db/migrations/0016_stock_lot_forecast_history.sql`
|
||||
13. `db/migrations/0017_manufacturer_lot_traceability.sql`
|
||||
14. `db/migrations/0018_shopify_delta_sync_state.sql`
|
||||
15. `db/migrations/0019_allow_multiple_open_stock_lots.sql`
|
||||
16. `db/migrations/0020_include_stock_adjustments_in_balance.sql`
|
||||
17. `db/migrations/0021_stock_lot_activation_selection.sql`
|
||||
18. `db/migrations/0022_backfill_stock_lot_activation_selection.sql`
|
||||
|
||||
## Beispielausfuehrung
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE party_role (
|
||||
party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE,
|
||||
role_code TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT pk_party_role PRIMARY KEY (party_id, role_code),
|
||||
CONSTRAINT chk_party_role_code CHECK (role_code IN ('manufacturer'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_party_role_code ON party_role(role_code);
|
||||
|
||||
ALTER TABLE stock_lot
|
||||
RENAME COLUMN supplier_lot_number TO manufacturer_lot_number;
|
||||
|
||||
ALTER TABLE stock_lot
|
||||
ADD COLUMN manufacturer_party_id BIGINT REFERENCES party(id) ON DELETE RESTRICT;
|
||||
|
||||
INSERT INTO party (type, name, status, created_at, updated_at)
|
||||
SELECT 'supplier', MIN(TRIM(supplier_name)), 'active', NOW(), NOW()
|
||||
FROM stock_lot
|
||||
WHERE supplier_name IS NOT NULL
|
||||
AND TRIM(supplier_name) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM party p
|
||||
WHERE LOWER(TRIM(p.name)) = LOWER(TRIM(stock_lot.supplier_name))
|
||||
)
|
||||
GROUP BY LOWER(TRIM(supplier_name));
|
||||
|
||||
INSERT INTO party_role (party_id, role_code)
|
||||
SELECT p.id, 'manufacturer'
|
||||
FROM party p
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM stock_lot sl
|
||||
WHERE sl.supplier_name IS NOT NULL
|
||||
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name))
|
||||
)
|
||||
ON CONFLICT (party_id, role_code) DO NOTHING;
|
||||
|
||||
UPDATE stock_lot sl
|
||||
SET manufacturer_party_id = p.id
|
||||
FROM party p
|
||||
WHERE sl.supplier_name IS NOT NULL
|
||||
AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name));
|
||||
|
||||
ALTER TABLE stock_lot
|
||||
DROP COLUMN supplier_name;
|
||||
|
||||
CREATE INDEX idx_stock_lot_manufacturer_party ON stock_lot(manufacturer_party_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE shopify_delta_sync_state (
|
||||
process_name TEXT PRIMARY KEY,
|
||||
last_successful_cutoff TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO shopify_delta_sync_state (process_name, last_successful_cutoff)
|
||||
SELECT
|
||||
'erp.import-integration.shopify_delta_order_import',
|
||||
COALESCE(MAX(shopify_created_at), NOW())
|
||||
FROM sales_order
|
||||
WHERE shopify_order_gid IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS uq_stock_lot_one_open_per_product;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE VIEW v_stock_lot_balance AS
|
||||
SELECT
|
||||
sl.id AS stock_lot_id,
|
||||
sl.product_id,
|
||||
COALESCE(SUM(CASE WHEN sm.move_type = 'in' THEN sm.qty ELSE 0 END), 0) AS qty_in,
|
||||
COALESCE(SUM(CASE WHEN sm.move_type = 'out' THEN sm.qty ELSE 0 END), 0) AS qty_out,
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN sm.move_type = 'in' THEN sm.qty
|
||||
WHEN sm.move_type = 'out' THEN -sm.qty
|
||||
WHEN sm.move_type = 'adjustment' AND sm.to_location_id IS NOT NULL AND sm.from_location_id IS NULL THEN sm.qty
|
||||
WHEN sm.move_type = 'adjustment' AND sm.from_location_id IS NOT NULL AND sm.to_location_id IS NULL THEN -sm.qty
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS qty_net
|
||||
FROM stock_lot sl
|
||||
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
|
||||
GROUP BY sl.id, sl.product_id;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,119 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE stock_lot
|
||||
ADD COLUMN IF NOT EXISTS next_active BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_stock_lot_one_next_active_per_product
|
||||
ON stock_lot(product_id)
|
||||
WHERE status = 'open' AND next_active = TRUE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION fn_auto_switch_lot_when_depleted()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_product_id BIGINT;
|
||||
v_open_lot_id BIGINT;
|
||||
v_qualified_count INTEGER;
|
||||
v_current_net NUMERIC;
|
||||
v_event_key TEXT;
|
||||
BEGIN
|
||||
SELECT product_id
|
||||
INTO v_product_id
|
||||
FROM stock_lot
|
||||
WHERE id = NEW.lot_id;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current'
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT qty_net
|
||||
INTO v_current_net
|
||||
FROM v_stock_lot_balance
|
||||
WHERE stock_lot_id = NEW.lot_id;
|
||||
|
||||
IF COALESCE(v_current_net, 0) > 0 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
PERFORM 1
|
||||
FROM stock_lot
|
||||
WHERE product_id = v_product_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current'
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT sl.id
|
||||
INTO v_open_lot_id
|
||||
FROM stock_lot sl
|
||||
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
||||
WHERE sl.product_id = v_product_id
|
||||
AND sl.status = 'open'
|
||||
AND sl.next_active = TRUE
|
||||
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
|
||||
AND sl.manufacturer_party_id IS NOT NULL
|
||||
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
|
||||
AND sl.mfg_date IS NOT NULL
|
||||
AND sl.expiry_date IS NOT NULL
|
||||
AND sl.purchase_date IS NOT NULL
|
||||
AND v.qty_net > 0
|
||||
LIMIT 1;
|
||||
|
||||
IF v_open_lot_id IS NULL THEN
|
||||
SELECT COUNT(*), MIN(sl.id)
|
||||
INTO v_qualified_count, v_open_lot_id
|
||||
FROM stock_lot sl
|
||||
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
||||
WHERE sl.product_id = v_product_id
|
||||
AND sl.status = 'open'
|
||||
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
|
||||
AND sl.manufacturer_party_id IS NOT NULL
|
||||
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
|
||||
AND sl.mfg_date IS NOT NULL
|
||||
AND sl.expiry_date IS NOT NULL
|
||||
AND sl.purchase_date IS NOT NULL
|
||||
AND v.qty_net > 0;
|
||||
|
||||
IF v_qualified_count <> 1 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
UPDATE stock_lot
|
||||
SET next_active = TRUE, updated_at = NOW()
|
||||
WHERE id = v_open_lot_id;
|
||||
END IF;
|
||||
|
||||
UPDATE stock_lot
|
||||
SET status = 'closed', updated_at = NOW()
|
||||
WHERE id = NEW.lot_id;
|
||||
|
||||
UPDATE stock_lot
|
||||
SET status = 'current', next_active = FALSE, updated_at = NOW()
|
||||
WHERE id = v_open_lot_id;
|
||||
|
||||
v_event_key := format('lot.auto_switched:%s:%s', v_product_id, txid_current());
|
||||
|
||||
PERFORM fn_enqueue_event(
|
||||
'lot.auto_switched',
|
||||
v_event_key,
|
||||
'stock_lot',
|
||||
NEW.lot_id::TEXT,
|
||||
jsonb_build_object(
|
||||
'productId', v_product_id,
|
||||
'closedLotId', NEW.lot_id,
|
||||
'newCurrentLotId', v_open_lot_id,
|
||||
'occurredAt', NOW()
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,25 @@
|
||||
BEGIN;
|
||||
|
||||
WITH qualified AS (
|
||||
SELECT
|
||||
sl.id,
|
||||
sl.product_id,
|
||||
COUNT(*) OVER (PARTITION BY sl.product_id) AS qualified_count
|
||||
FROM stock_lot sl
|
||||
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
||||
WHERE sl.status = 'open'
|
||||
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
|
||||
AND sl.manufacturer_party_id IS NOT NULL
|
||||
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
|
||||
AND sl.mfg_date IS NOT NULL
|
||||
AND sl.expiry_date IS NOT NULL
|
||||
AND sl.purchase_date IS NOT NULL
|
||||
AND v.qty_net > 0
|
||||
)
|
||||
UPDATE stock_lot sl
|
||||
SET next_active = TRUE, updated_at = NOW()
|
||||
FROM qualified q
|
||||
WHERE sl.id = q.id
|
||||
AND q.qualified_count = 1;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,215 @@
|
||||
BEGIN;
|
||||
|
||||
-- Market Management base schema.
|
||||
-- Fachliche Ownership: market_management.
|
||||
|
||||
CREATE TABLE market_category (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_market_category_code ON market_category(code);
|
||||
CREATE UNIQUE INDEX uq_market_category_name ON market_category(name);
|
||||
|
||||
INSERT INTO market_category (code, name)
|
||||
VALUES
|
||||
('herbstmarkt', 'Herbstmärkte'),
|
||||
('weihnachtsmarkt', 'Weihnachtsmärkte'),
|
||||
('fruehlingsmarkt', 'Frühlingsmärkte'),
|
||||
('ostermarkt', 'Ostermärkte'),
|
||||
('kunsthandwerksmarkt', 'Kunsthandwerksmärkte'),
|
||||
('regionalmarkt', 'Regionalmärkte'),
|
||||
('weitere_geeignete_maerkte', 'Weitere geeignete Märkte');
|
||||
|
||||
CREATE TABLE market_municipality_reference (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bfs_municipality_number INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
canton_code TEXT NOT NULL,
|
||||
data_version DATE NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_market_municipality_reference_bfs_number_version
|
||||
ON market_municipality_reference(bfs_municipality_number, data_version);
|
||||
CREATE INDEX idx_market_municipality_reference_canton
|
||||
ON market_municipality_reference(canton_code, name);
|
||||
|
||||
CREATE TABLE market_definition (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category_id BIGINT NOT NULL REFERENCES market_category(id) ON DELETE RESTRICT,
|
||||
archived_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_market_definition_name ON market_definition(name);
|
||||
CREATE INDEX idx_market_definition_category ON market_definition(category_id);
|
||||
|
||||
CREATE TABLE market_occurrence (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
market_definition_id BIGINT NOT NULL REFERENCES market_definition(id) ON DELETE RESTRICT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
place TEXT,
|
||||
address TEXT,
|
||||
municipality_reference_id BIGINT REFERENCES market_municipality_reference(id) ON DELETE RESTRICT,
|
||||
notes TEXT,
|
||||
registration_deadline DATE,
|
||||
request_or_registration_date DATE,
|
||||
confirmation_date DATE,
|
||||
payment_deadline DATE,
|
||||
stand_size TEXT,
|
||||
table_available BOOLEAN,
|
||||
table_rentable BOOLEAN,
|
||||
own_table_required BOOLEAN,
|
||||
shelter_available BOOLEAN,
|
||||
power_available BOOLEAN,
|
||||
water_available BOOLEAN,
|
||||
lighting_available BOOLEAN,
|
||||
other_infrastructure TEXT,
|
||||
own_stand_requirements TEXT,
|
||||
travel_address TEXT,
|
||||
travel_distance_km NUMERIC(10, 2),
|
||||
travel_duration_minutes INTEGER,
|
||||
parking_options TEXT,
|
||||
delivery_access TEXT,
|
||||
direct_unload_possible BOOLEAN,
|
||||
storage_options TEXT,
|
||||
travel_notes TEXT,
|
||||
payment_status TEXT NOT NULL DEFAULT 'unpaid',
|
||||
payment_date DATE,
|
||||
paid_amount NUMERIC(14, 2),
|
||||
payment_method_code TEXT,
|
||||
cancellation_terms TEXT,
|
||||
revenue_share_terms TEXT,
|
||||
other_participation_conditions TEXT,
|
||||
contract_folder_url TEXT,
|
||||
archived_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_market_occurrence_status CHECK (
|
||||
status IN ('open', 'waitlist', 'registered', 'confirmation_pending', 'confirmed', 'cancelled', 'completed')
|
||||
),
|
||||
CONSTRAINT chk_market_occurrence_payment_status CHECK (
|
||||
payment_status IN ('paid', 'unpaid')
|
||||
),
|
||||
CONSTRAINT chk_market_occurrence_travel_distance CHECK (
|
||||
travel_distance_km IS NULL OR travel_distance_km >= 0
|
||||
),
|
||||
CONSTRAINT chk_market_occurrence_travel_duration CHECK (
|
||||
travel_duration_minutes IS NULL OR travel_duration_minutes >= 0
|
||||
),
|
||||
CONSTRAINT chk_market_occurrence_paid_amount CHECK (
|
||||
paid_amount IS NULL OR paid_amount >= 0
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_market_occurrence_market ON market_occurrence(market_definition_id);
|
||||
CREATE INDEX idx_market_occurrence_status ON market_occurrence(status);
|
||||
CREATE INDEX idx_market_occurrence_municipality
|
||||
ON market_occurrence(municipality_reference_id);
|
||||
CREATE INDEX idx_market_occurrence_archived_at ON market_occurrence(archived_at);
|
||||
|
||||
CREATE TABLE market_occurrence_organizer (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurrence_id BIGINT NOT NULL UNIQUE REFERENCES market_occurrence(id) ON DELETE RESTRICT,
|
||||
organization_contact_id BIGINT NOT NULL,
|
||||
contact_person_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_market_occurrence_organizer_organization
|
||||
ON market_occurrence_organizer(organization_contact_id);
|
||||
CREATE INDEX idx_market_occurrence_organizer_contact_person
|
||||
ON market_occurrence_organizer(contact_person_id);
|
||||
|
||||
CREATE TABLE market_occurrence_day (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
|
||||
date DATE NOT NULL,
|
||||
stand_start_time TIME,
|
||||
stand_end_time TIME,
|
||||
setup_start_time TIME,
|
||||
setup_end_time TIME,
|
||||
delivery_start_time TIME,
|
||||
delivery_end_time TIME,
|
||||
observations TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_market_occurrence_day_stand_time CHECK (
|
||||
stand_start_time IS NULL OR stand_end_time IS NULL OR stand_end_time >= stand_start_time
|
||||
),
|
||||
CONSTRAINT chk_market_occurrence_day_setup_time CHECK (
|
||||
setup_start_time IS NULL OR setup_end_time IS NULL OR setup_end_time >= setup_start_time
|
||||
),
|
||||
CONSTRAINT chk_market_occurrence_day_delivery_time CHECK (
|
||||
delivery_start_time IS NULL OR delivery_end_time IS NULL OR delivery_end_time >= delivery_start_time
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_market_occurrence_day_date ON market_occurrence_day(occurrence_id, date);
|
||||
CREATE INDEX idx_market_occurrence_day_date ON market_occurrence_day(date);
|
||||
|
||||
CREATE TABLE market_occurrence_cost_item (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
|
||||
cost_type TEXT NOT NULL,
|
||||
description TEXT,
|
||||
gross_amount NUMERIC(14, 2) NOT NULL,
|
||||
tax_status TEXT NOT NULL,
|
||||
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
|
||||
tax_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN tax_status = 'tax_free' THEN 0
|
||||
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
|
||||
END
|
||||
) STORED,
|
||||
net_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
|
||||
gross_amount - CASE
|
||||
WHEN tax_status = 'tax_free' THEN 0
|
||||
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
|
||||
END
|
||||
) STORED,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_market_occurrence_cost_gross_nonnegative CHECK (gross_amount >= 0),
|
||||
CONSTRAINT chk_market_occurrence_cost_tax_status CHECK (tax_status IN ('tax_free', 'taxable')),
|
||||
CONSTRAINT chk_market_occurrence_cost_tax_rate CHECK (
|
||||
(tax_status = 'tax_free' AND tax_rate = 0)
|
||||
OR (tax_status = 'taxable' AND tax_rate > 0)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_market_occurrence_cost_item_occurrence
|
||||
ON market_occurrence_cost_item(occurrence_id);
|
||||
|
||||
CREATE TABLE market_mcp_idempotency (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_subject TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL,
|
||||
result_status TEXT NOT NULL,
|
||||
result_entity_type TEXT,
|
||||
result_entity_id TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
CONSTRAINT chk_market_mcp_idempotency_result_status CHECK (
|
||||
result_status IN ('processing', 'succeeded', 'failed')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_market_mcp_idempotency_client_key
|
||||
ON market_mcp_idempotency(client_id, idempotency_key);
|
||||
CREATE INDEX idx_market_mcp_idempotency_expiry ON market_mcp_idempotency(expires_at);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE market_mcp_idempotency
|
||||
ADD COLUMN result_payload JSONB;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,10 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE market_definition
|
||||
ADD COLUMN website_url TEXT;
|
||||
|
||||
ALTER TABLE market_definition
|
||||
ADD CONSTRAINT chk_market_definition_website_url
|
||||
CHECK (website_url IS NULL OR website_url ~* '^https?://');
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE market_occurrence_day
|
||||
ADD COLUMN inherit_from_first_day BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,28 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE sales_order
|
||||
ADD COLUMN market_occurrence_id BIGINT,
|
||||
ADD COLUMN market_occurrence_day_id BIGINT;
|
||||
|
||||
ALTER TABLE market_occurrence_day
|
||||
ADD CONSTRAINT uq_market_occurrence_day_occurrence_id UNIQUE (occurrence_id, id);
|
||||
|
||||
ALTER TABLE sales_order
|
||||
ADD CONSTRAINT fk_sales_order_market_occurrence
|
||||
FOREIGN KEY (market_occurrence_id)
|
||||
REFERENCES market_occurrence (id)
|
||||
ON DELETE RESTRICT,
|
||||
ADD CONSTRAINT fk_sales_order_market_occurrence_day
|
||||
FOREIGN KEY (market_occurrence_id, market_occurrence_day_id)
|
||||
REFERENCES market_occurrence_day (occurrence_id, id)
|
||||
ON DELETE RESTRICT,
|
||||
ADD CONSTRAINT chk_sales_order_market_link_complete
|
||||
CHECK ((market_occurrence_id IS NULL) = (market_occurrence_day_id IS NULL));
|
||||
|
||||
CREATE INDEX idx_sales_order_market_occurrence
|
||||
ON sales_order (market_occurrence_id);
|
||||
|
||||
CREATE INDEX idx_sales_order_market_occurrence_day
|
||||
ON sales_order (market_occurrence_day_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,55 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE market_organizer (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_market_organizer_status CHECK (status IN ('active', 'inactive'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_market_organizer_name ON market_organizer (LOWER(TRIM(name)));
|
||||
|
||||
INSERT INTO market_organizer (name)
|
||||
VALUES ('Beispielveranstalter 1');
|
||||
|
||||
ALTER TABLE market_definition
|
||||
ADD COLUMN organizer_id BIGINT;
|
||||
|
||||
UPDATE market_definition
|
||||
SET organizer_id = (SELECT id FROM market_organizer WHERE name = 'Beispielveranstalter 1');
|
||||
|
||||
ALTER TABLE market_definition
|
||||
ALTER COLUMN organizer_id SET NOT NULL,
|
||||
ADD CONSTRAINT fk_market_definition_organizer
|
||||
FOREIGN KEY (organizer_id) REFERENCES market_organizer(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX idx_market_definition_organizer ON market_definition (organizer_id);
|
||||
|
||||
ALTER TABLE market_occurrence_organizer
|
||||
ADD COLUMN contact_person_name TEXT,
|
||||
ADD COLUMN contact_email TEXT,
|
||||
ADD COLUMN contact_phone TEXT;
|
||||
|
||||
UPDATE market_occurrence_organizer oo
|
||||
SET contact_person_name = NULLIF(TRIM(CONCAT_WS(' ', c.first_name, c.last_name)), ''),
|
||||
contact_email = c.email,
|
||||
contact_phone = c.phone
|
||||
FROM contact c
|
||||
WHERE c.id = oo.contact_person_id;
|
||||
|
||||
ALTER TABLE market_occurrence_organizer
|
||||
DROP COLUMN organization_contact_id,
|
||||
DROP COLUMN contact_person_id;
|
||||
|
||||
INSERT INTO market_occurrence_organizer (occurrence_id, contact_person_name, created_at, updated_at)
|
||||
SELECT o.id, NULL, NOW(), NOW()
|
||||
FROM market_occurrence o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM market_occurrence_organizer oo WHERE oo.occurrence_id = o.id
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,40 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE market_municipality (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
municipality_reference_id BIGINT UNIQUE REFERENCES market_municipality_reference(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
canton_code TEXT,
|
||||
bfs_municipality_number INTEGER,
|
||||
data_version DATE,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_market_municipality_source CHECK (source IN ('reference', 'local'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_market_municipality_name
|
||||
ON market_municipality (LOWER(TRIM(name)));
|
||||
CREATE INDEX idx_market_municipality_name
|
||||
ON market_municipality (name);
|
||||
|
||||
INSERT INTO market_municipality (
|
||||
municipality_reference_id, name, canton_code, bfs_municipality_number, data_version, source, is_active
|
||||
)
|
||||
SELECT id, name, canton_code, bfs_municipality_number, data_version, 'reference', is_active
|
||||
FROM market_municipality_reference;
|
||||
|
||||
ALTER TABLE market_occurrence
|
||||
DROP CONSTRAINT market_occurrence_municipality_reference_id_fkey;
|
||||
|
||||
UPDATE market_occurrence o
|
||||
SET municipality_reference_id = m.id
|
||||
FROM market_municipality m
|
||||
WHERE m.municipality_reference_id = o.municipality_reference_id;
|
||||
|
||||
ALTER TABLE market_occurrence
|
||||
ADD CONSTRAINT fk_market_occurrence_municipality
|
||||
FOREIGN KEY (municipality_reference_id) REFERENCES market_municipality(id) ON DELETE RESTRICT;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,63 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE market_cost_type (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO market_cost_type (code, name, is_system)
|
||||
VALUES ('stand_fee', 'Standkosten', TRUE);
|
||||
|
||||
ALTER TABLE market_occurrence_cost_item
|
||||
ADD COLUMN cost_type_id BIGINT REFERENCES market_cost_type(id) ON DELETE RESTRICT;
|
||||
|
||||
INSERT INTO market_cost_type (code, name)
|
||||
SELECT DISTINCT
|
||||
'legacy_' || md5(trim(cost_type)),
|
||||
trim(cost_type)
|
||||
FROM market_occurrence_cost_item
|
||||
WHERE trim(cost_type) <> ''
|
||||
AND lower(trim(cost_type)) <> 'standkosten'
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
UPDATE market_occurrence_cost_item item
|
||||
SET cost_type_id = type.id
|
||||
FROM market_cost_type type
|
||||
WHERE type.name = CASE
|
||||
WHEN lower(trim(item.cost_type)) = 'standkosten' THEN 'Standkosten'
|
||||
ELSE trim(item.cost_type)
|
||||
END;
|
||||
|
||||
UPDATE market_occurrence_cost_item item
|
||||
SET cost_type_id = type.id
|
||||
FROM market_cost_type type
|
||||
WHERE type.code = 'stand_fee'
|
||||
AND item.id = (
|
||||
SELECT MIN(first_item.id)
|
||||
FROM market_occurrence_cost_item first_item
|
||||
WHERE first_item.occurrence_id = item.occurrence_id
|
||||
);
|
||||
|
||||
INSERT INTO market_occurrence_cost_item (occurrence_id, cost_type_id, description, gross_amount, tax_status, tax_rate)
|
||||
SELECT occurrence.id, type.id, NULL, 0.00, 'taxable', 8.10
|
||||
FROM market_occurrence occurrence
|
||||
JOIN market_cost_type type ON type.code = 'stand_fee'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM market_occurrence_cost_item item WHERE item.occurrence_id = occurrence.id
|
||||
);
|
||||
|
||||
ALTER TABLE market_occurrence_cost_item
|
||||
ALTER COLUMN cost_type_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE market_occurrence_cost_item
|
||||
DROP COLUMN cost_type;
|
||||
|
||||
CREATE INDEX idx_market_occurrence_cost_item_type
|
||||
ON market_occurrence_cost_item(cost_type_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN;
|
||||
|
||||
UPDATE market_occurrence_cost_item item
|
||||
SET tax_status = 'taxable', tax_rate = 8.10, updated_at = NOW()
|
||||
WHERE item.id = (
|
||||
SELECT MIN(first_item.id)
|
||||
FROM market_occurrence_cost_item first_item
|
||||
WHERE first_item.occurrence_id = item.occurrence_id
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -11,6 +11,7 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte
|
||||
### `kontakte`
|
||||
|
||||
- `party`
|
||||
- `party_role`
|
||||
- `address`
|
||||
- `contact`
|
||||
- `party_external_identity`
|
||||
|
||||
+4
-3
@@ -8,18 +8,19 @@ Read-only-Abgleich von Shopify-Bestellungen nach dem ERP-Stichtag und identifier
|
||||
|
||||
## 2. Prozess-Einbettung
|
||||
|
||||
Manuell startbarer Initial-Deltalauf und später wiederverwendbarer Reconciliation-Prozess im Submodul `erp/import-integration`. Er ist vom Webhook-Empfang getrennt.
|
||||
Manuell startbarer Initial-Deltalauf und täglich über den zentralen ERP-Cron gestarteter Reconciliation-Prozess im Submodul `erp/import-integration`. Er ist vom Webhook-Empfang getrennt.
|
||||
|
||||
## 3. Input
|
||||
|
||||
- `cutoff_timestamp`
|
||||
- optional `dry_run`
|
||||
- beim täglichen Cron der persistierte letzte erfolgreiche Wasserstand sowie der älteste noch nicht erfolgreich verarbeitete Webhook-Eingang
|
||||
|
||||
Es werden keine vorbereiteten Shopify-Geschäftsdaten an nachgelagerte Module übergeben.
|
||||
|
||||
## 4. Exakter Prozessablauf
|
||||
|
||||
1. `cutoff_timestamp` und `dry_run` validieren.
|
||||
1. `cutoff_timestamp` und `dry_run` validieren; beim Cron den kleineren Wert aus Wasserstand und ältestem offenen Webhook-Eingang verwenden und offene Webhook-Order-GIDs zusätzlich direkt zur Recovery aufnehmen.
|
||||
2. Read-only Shopify-Bestellungen mit `createdAt` nach dem Stichtag in stabiler Reihenfolge laden.
|
||||
3. Stornierte oder vollständig erstattete Bestellungen als nicht lagerwirksam klassifizieren und nur technisch protokollieren.
|
||||
4. Für jede qualifizierte Bestellung die Shopify Order-GID idempotent gegen `sales_order.shopify_order_gid` prüfen.
|
||||
@@ -42,7 +43,7 @@ Write targets:
|
||||
|
||||
## 5. Batch, Betriebsmodell und Einbettung
|
||||
|
||||
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere Reconciliation-Läufe nutzen denselben Prozess mit einem definierten Wasserzeichen. Keine Parallelisierung.
|
||||
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere tägliche Reconciliation-Läufe nutzen den persistierten Wasserstand und ziehen offene oder fehlgeschlagene Webhook-Eingänge sowohl über den Prüfbereich als auch über ihre stabile Order-GID wieder in die Verarbeitung. Ein technisches Event wird erst nach erfolgreicher Projektion als verarbeitet markiert. Der Wasserstand wird nur nach einem vollständig erfolgreichen Lauf fortgeschrieben. Keine Parallelisierung.
|
||||
|
||||
## 6. Output
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Zentraler Kontaktstamm fuer Kunden, Lieferanten und sonstige Parteien.
|
||||
## Writes
|
||||
|
||||
- Kontaktstamm
|
||||
- Herstellerrollen im Parteien-Stamm
|
||||
- Adressen
|
||||
- Kommunikationsdaten
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen.
|
||||
- Warenabgang
|
||||
- Umlagerung
|
||||
- Chargenpflege
|
||||
- Verknüpfung von Naurua- und Hersteller-Chargen
|
||||
- Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation
|
||||
|
||||
## Reads
|
||||
@@ -42,4 +43,6 @@ Bestand, Charge, MHD und Bewegungen.
|
||||
|
||||
- `modules/erp/lager/service.php`
|
||||
|
||||
Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug.
|
||||
|
||||
Virtuelle Fehlbestandschargen tragen den Status `virtual_shortage`, sind nicht bestandswirksam und werden bei einer menschlich bestaetigten Nachallokation durch eine neue reale Chargenzuordnung ersetzt. Die virtuelle Ausgangszuordnung bleibt als Historie erhalten.
|
||||
@@ -4,31 +4,32 @@ Status: Verbindliche Prozess-Spezifikation
|
||||
|
||||
## 1. Zweck
|
||||
|
||||
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder aktuellen Charge aktiver Produkte.
|
||||
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums der aktuellen Charge und – sofern vorhanden – der qualifizierten, als nächste Charge markierten offenen Charge aktiver Produkte.
|
||||
|
||||
## 2. Prozess-Einbettung
|
||||
|
||||
Direkt ausgeführter, täglich um 06:00 Uhr gestarteter Lager-Prozess über den zentralen ERP-Cron-Einstieg.
|
||||
Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolgreichen Verkaufsallokation in der zentralen Lagerschnittstelle für Shopify- und OTC-Verkäufe sowie täglich um 06:00 Uhr über den zentralen ERP-Cron-Einstieg.
|
||||
|
||||
## 3. Input
|
||||
|
||||
- keine externen Prozessparameter
|
||||
- aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand
|
||||
- optional eine einzelne `product_id` bei synchronem Verkaufstrigger
|
||||
- aktive Produkte, aktuelle Charge, qualifizierte als nächste Charge markierte offene Charge, physische Chargenbestände und gültige Verkaufsallokationen aus dem eigenen Datenbestand
|
||||
|
||||
## 4. Exakter Prozessablauf
|
||||
|
||||
1. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden.
|
||||
2. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
|
||||
3. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
|
||||
4. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
|
||||
5. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
|
||||
6. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen.
|
||||
7. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren.
|
||||
8. Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
|
||||
1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen.
|
||||
2. Aktive Produkte, ihre aktuelle Charge und die qualifizierte, als nächste Charge markierte offene Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden. Für jede Charge wird nur ihr eigener positiver Nettobestand berücksichtigt.
|
||||
3. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
|
||||
4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
|
||||
5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
|
||||
6. Den gewichteten Tagesabsatz ab dem ersten gültigen Verkaufstag innerhalb des geladenen Zeitraums mit einer Halbwertszeit von 60 Tagen berechnen; Tage vor dem ersten Verkauf werden nicht als Nullabsatz berücksichtigt.
|
||||
7. Das Leer-Datum der aktuellen Charge aus ihrem physischen Bestand und der gewichteten Nachfrage berechnen. Das Leer-Datum der nächsten Charge wird mit derselben Nachfrage ab dem Leer-Datum der aktuellen Charge weitergerechnet; bei bereits leerer aktueller Charge beginnt es am aktuellen Datum.
|
||||
8. Jede erfolgreiche Berechnung je Charge in `stock_lot_forecast_history` speichern und den jeweiligen Snapshot in `stock_lot` aktualisieren.
|
||||
9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
|
||||
|
||||
## 5. Batch, Betriebsmodell und Einbettung
|
||||
|
||||
Ein täglicher Lauf um 06:00 Uhr Europe/Zurich verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Produktfehler sind batch-tolerant; technische Gesamtfehler schlagen den Lauf hart fehl.
|
||||
Der Cron läuft täglich um 06:00 Uhr Europe/Zurich und verarbeitet alle aktiven Produkte deterministisch nach `product.id`. Verkaufstrigger aktualisieren die betroffenen Produkte synchron innerhalb der laufenden Verkaufs-Transaktion. Produktfehler im Cron sind batch-tolerant; technische Gesamtfehler schlagen den jeweiligen Lauf hart fehl.
|
||||
|
||||
## 6. Output
|
||||
|
||||
@@ -36,7 +37,8 @@ Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produ
|
||||
|
||||
## 7. Erfolgskriterien
|
||||
|
||||
- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot.
|
||||
- Jede aktuelle Charge erhält täglich einen Prognosesnapshot nur für ihren eigenen Bestand.
|
||||
- Eine qualifizierte, als nächste Charge markierte offene Charge erhält einen separaten Prognosesnapshot nur für ihren eigenen Bestand.
|
||||
- Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage.
|
||||
- Die vollständige Berechnungshistorie bleibt erhalten.
|
||||
- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Market Management
|
||||
|
||||
Stand: 2026-09-15
|
||||
Status: Fachliches Zielbild / Anforderungen in Ausarbeitung
|
||||
|
||||
## Zweck
|
||||
|
||||
Das Modul verwaltet Märkte von der Recherche und Prüfung über Anfrage, Anmeldung
|
||||
und Durchführung bis zur Kosten-, Zahlungs- und Erlösauswertung.
|
||||
|
||||
## Fachliche Ownership
|
||||
|
||||
Das Modul soll die fachliche Ownership für Marktstammdaten, Markttermine,
|
||||
Teilnahme- und Durchführungsstatus, marktbezogene Kosten und Zahlungen sowie
|
||||
Erlös- und Tagesauswertungen tragen.
|
||||
|
||||
## Zugriffsmodell
|
||||
|
||||
Die Nutzung des Moduls ist an eine Benutzeridentität gebunden. Das Modul soll
|
||||
separat von der übrigen Anwendung nutzbar und an weitere Personen verkaufbar
|
||||
sein.
|
||||
|
||||
Die Ownership der Lizenz-/Entitlement-Daten ist noch nicht entschieden und
|
||||
bleibt offen.
|
||||
|
||||
## MCP-Schnittstelle zu Hermes
|
||||
|
||||
Die App stellt die MCP-Server-Schnittstelle bereit. Hermes Agent ist MCP-Client
|
||||
und ruft ausschließlich freigegebene Funktionen des Marktmanagement-Moduls
|
||||
auf.
|
||||
|
||||
Die App behält die Kontrolle über:
|
||||
|
||||
- Authentifizierung und Berechtigungsprüfung
|
||||
- Validierung und fachliche Interpretation der Eingaben
|
||||
- erlaubte Schreiboperationen
|
||||
- Persistierung in der eigenen Datenbank
|
||||
- Audit- und Quelleninformationen
|
||||
|
||||
Hermes erhält keinen direkten Zugriff auf die Datenbank. Das Zielbild und die
|
||||
Schnittstellenentscheidungen sind im
|
||||
[market_management_hermes_mcp_konzept.md](konzepte/market_management_hermes_mcp_konzept.md)
|
||||
dokumentiert.
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
- Zunächst keine eigene Benutzerverwaltung.
|
||||
- Keine eigene technische Runtime- oder Scheduler-Logik.
|
||||
- Fachlich neutrale UI- und technische Querschnittsbausteine gehören nach
|
||||
`shared`.
|
||||
|
||||
## Dokumente
|
||||
|
||||
- [Anforderungsprofil](requirements/market_management_requirements.md)
|
||||
- [Datenarchitektur](data/market_management_data_architecture.md)
|
||||
- [Technisches Tabellen- und Prozesskonzept](konzepte/market_management_technisches_konzept.md)
|
||||
- [Hermes-MCP-Konzept](konzepte/market_management_hermes_mcp_konzept.md)
|
||||
- [DB-Ownership-Konzept](konzepte/market_management_db_ownership.md)
|
||||
- Prozessdokumente werden unter `processes/main/` und
|
||||
`processes/sub_processes/` angelegt, sobald die fachlichen Prozesse explizit
|
||||
definiert sind.
|
||||
|
||||
## Offene Architekturentscheidung
|
||||
|
||||
Die Ownership und konkrete Ablage der Lizenz-/Entitlement-Daten ist offen.
|
||||
Diese Entscheidung darf bei der weiteren Umsetzung nicht stillschweigend
|
||||
vorweggenommen werden.
|
||||
@@ -0,0 +1,106 @@
|
||||
# market_management_data_architecture
|
||||
|
||||
Stand: 2026-09-18
|
||||
Status: Bestätigtes Datenarchitektur-Zielbild
|
||||
|
||||
## 1. Ziel
|
||||
|
||||
Das Marktmanagement-Modul muss wiederkehrende Märkte mit mehreren konkreten
|
||||
Durchführungen und mehreren Tagen je Durchführung ohne redundante Vermischung
|
||||
der Daten abbilden können.
|
||||
|
||||
## 2. Bestätigte Hierarchie
|
||||
|
||||
```text
|
||||
Kategorie 1 ─── n Marktdefinition 1 ─── n Durchführung 1 ─── n Durchführungstag
|
||||
```
|
||||
|
||||
Eine Kategorie enthält beliebig viele Marktdefinitionen. Jede Marktdefinition
|
||||
gehört genau einer Kategorie.
|
||||
|
||||
### Kategorie
|
||||
|
||||
Eine Kategorie ordnet Märkte fachlich. Fabienne darf die deterministische
|
||||
Kategorie-/Marktartliste erweitern. Hermes darf bestehende Kategorien
|
||||
verwenden, aber keine Kategorien anlegen.
|
||||
|
||||
### Marktdefinition
|
||||
|
||||
Eine Marktdefinition ist die übergeordnete fachliche Identität, unter der
|
||||
mehrere konkrete Durchführungen geführt werden können. Der zentrale Marktstamm
|
||||
enthält nur den eindeutigen Marktnamen und die Marktart/Kategorie.
|
||||
|
||||
### Durchführung
|
||||
|
||||
Eine Durchführung ist eine konkrete Ausgabe bzw. Teilnahme an einem Markt. Die
|
||||
Durchführung kann einen oder mehrere Durchführungstage umfassen.
|
||||
|
||||
### Durchführungstag
|
||||
|
||||
Ein Durchführungstag ist ein einzelner Tag innerhalb einer konkreten
|
||||
Durchführung. Tagesbezogene Zeit-, Erlös- und Verkaufsdaten gehören auf diese
|
||||
Ebene.
|
||||
|
||||
## 3. Feld- und Referenzwert-Zuordnung
|
||||
|
||||
### Zentraler Marktstamm
|
||||
|
||||
- Marktname
|
||||
- Marktart/Kategorie aus einer deterministischen Liste
|
||||
- optionale Website-URL der Veranstaltung
|
||||
|
||||
### Durchführung
|
||||
|
||||
Alle übrigen veränderlichen Marktangaben werden pro Durchführung erfasst,
|
||||
insbesondere Kontakt, Ort, Termine, Anmeldung, Status, Stand, Infrastruktur,
|
||||
Logistik, Kosten, Zahlung, Vertragslink und Notizen.
|
||||
|
||||
### Durchführungstag
|
||||
|
||||
- Datum des Durchführungstags
|
||||
- Standzeit und tagesbezogene Durchführungszeiten
|
||||
- Bruttoerlös aus zugeordneten OTC-Verkäufen
|
||||
- Anzahl verkaufter Artikel aus zugeordneten OTC-Positionen
|
||||
- Ereignisse und Beobachtungen
|
||||
|
||||
### Deterministische Referenzwerte
|
||||
|
||||
- Marktart/Kategorie
|
||||
- Marktstatus
|
||||
- Zahlungsstatus
|
||||
- Zahlungsart
|
||||
- Kanton
|
||||
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
|
||||
|
||||
### Abgeleitete Werte
|
||||
|
||||
- Wochentag aus dem Datum
|
||||
- Saison aus dem Durchführungsdatum
|
||||
- Gesamtkosten aus dem erfassten Bruttobetrag
|
||||
- offener Betrag aus Kosten und bezahltem Betrag
|
||||
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
|
||||
Durchführungstagen
|
||||
|
||||
## 4. Ownership- und Normalisierungsregeln
|
||||
|
||||
- Jede Ebene erhält eine eigene stabile Identität.
|
||||
- Mehrere Durchführungstage werden nicht als wiederholte Spalten in der
|
||||
Durchführung gespeichert.
|
||||
- Mehrere Durchführungen werden nicht als wiederholte Spalten im Markt
|
||||
gespeichert.
|
||||
- Tageserlöse und Tagesverkaufszahlen werden nur für den Durchführungstag aus
|
||||
zugeordneten OTC-Verkäufen abgeleitet.
|
||||
- Aggregierte Werte für Durchführung oder Markt werden aus den untergeordneten
|
||||
Durchführungstagen abgeleitet oder als klar definierte Auswertung bereitgestellt.
|
||||
- MCP-Schreiboperationen müssen eindeutig an die betroffene Ebene gebunden sein.
|
||||
- Fremde Modul- oder DB-Ownership wird durch diese Datenarchitektur nicht
|
||||
eingeführt.
|
||||
|
||||
## 5. Bestätigte Ergänzungen
|
||||
|
||||
- Eine Durchführung ist eine konkrete Teilnahme bzw. Veranstaltungsausgabe.
|
||||
- Jeder Durchführungstag ist eine eigene Subdurchführung mit eigenen OTC-
|
||||
Verkäufen und abgeleiteten Tageswerten.
|
||||
- Markt und Durchführung werden unabhängig voneinander archiviert und
|
||||
reaktiviert.
|
||||
- Der Zahlungsstatus ist manuell und besitzt nur `paid` oder `unpaid`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Market Management: DB-Ownership-Konzept
|
||||
|
||||
Stand: 2026-09-19
|
||||
Status: Verbindliches DB-Ownership-Konzept
|
||||
|
||||
## 1. Owned Tabellen des Moduls
|
||||
|
||||
| Tabelle | Ownership | Schreibverantwortung |
|
||||
|---|---|---|
|
||||
| `market_category` | `market_management` | Marktmanagement-Service |
|
||||
| `market_definition` | `market_management` | Marktmanagement-Service |
|
||||
| `market_occurrence` | `market_management` | Marktmanagement-Service |
|
||||
| `market_occurrence_organizer` | `market_management` | Marktmanagement-Service über Kontakt-Schnittstelle |
|
||||
| `market_occurrence_day` | `market_management` | Marktmanagement-Service |
|
||||
| `market_occurrence_cost_item` | `market_management` | Marktmanagement-Service |
|
||||
| `market_municipality_reference` | `market_management` | Referenzdatenprozess |
|
||||
| `market_municipality` | `market_management` | Marktmanagement-Service |
|
||||
| `market_mcp_idempotency` | `market_management` | MCP-Submodul |
|
||||
|
||||
## 2. Fremde Datenreferenzen
|
||||
|
||||
### Kontaktmodul
|
||||
|
||||
Das Marktmanagement-Modul speichert nur die stabilen IDs von Organisation und
|
||||
Kontaktperson. Es schreibt keine Kontakt-, Organisations- oder Adressdaten in
|
||||
fremde Tabellen.
|
||||
|
||||
### OTC-/Direktverkaufsmodul
|
||||
|
||||
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
|
||||
referenziert einen gültigen `market_occurrence_day_id`. Marktmanagement liest
|
||||
die Verkaufsdaten ausschließlich über die definierte Schnittstelle für die
|
||||
Umsatz- und Artikelmengenaggregation.
|
||||
|
||||
## 3. Schreibgrenzen
|
||||
|
||||
- Direkte Cross-Modul-Writes sind verboten.
|
||||
- Marktmanagement schreibt nicht in OTC-, Kontakt- oder Organisations-Tabellen.
|
||||
- Das OTC-Modul schreibt nicht in Marktmanagement-Tabellen.
|
||||
- MCP schreibt nie direkt in Tabellen.
|
||||
- Alle Marktmanagement-Writes laufen über den owning Modul-Service.
|
||||
|
||||
## 4. Referenzdaten
|
||||
|
||||
Der Gemeindereferenzbestand verwendet die BFS-Gemeindenummer als stabile
|
||||
fachliche Referenz und speichert den Datenstand zur Nachvollziehbarkeit.
|
||||
|
||||
Das Marktmanagement besitzt zusätzlich das eigene Gemeinderegister
|
||||
`market_municipality`. Dieses Register enthält die offiziellen Gemeinden sowie
|
||||
lokal neu angelegte Gemeinden und ist die einzige Auswahl- und Schreibbasis für
|
||||
Gemeindezuordnungen von Marktdurchführungen.
|
||||
|
||||
Die Kategorie-/Marktartliste gehört zum Marktmanagement und wird durch Fabienne
|
||||
gepflegt. Hermes darf sie nur lesen.
|
||||
|
||||
## 5. Technische Änderungsregeln
|
||||
|
||||
- Tabellen- und Constraint-Änderungen erfolgen ausschließlich über versionierte
|
||||
Migrationen.
|
||||
- Fachliche Constraints werden im DB- und Prozessvertrag gespiegelt.
|
||||
- Aggregierte Umsatzwerte werden nicht als unabhängige Fachwahrheit gespeichert.
|
||||
- Archivierte Datensätze bleiben erhalten.
|
||||
- Markt und Durchführung besitzen getrennte Archivstatus.
|
||||
@@ -0,0 +1,311 @@
|
||||
# Hermes: Market-Management-MCP-API
|
||||
|
||||
Stand: 2026-09-20
|
||||
Status: Aktive technische Schnittstellendokumentation
|
||||
|
||||
## Verbindung
|
||||
|
||||
Hermes verwendet den MCP-Endpunkt der DEV-App:
|
||||
|
||||
```text
|
||||
https://erpnaurua.imhochrain.ch/api/market-management-mcp.php
|
||||
```
|
||||
|
||||
Jeder Request ist ein JSON-RPC-2.0-Request über HTTPS. Der Request benötigt:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <MARKET_MCP_TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Der Token wird nicht in dieser Dokumentation oder im Repository gespeichert.
|
||||
|
||||
## Verbindungsablauf
|
||||
|
||||
## Dokumentlinks in Marktdurchführungen
|
||||
|
||||
Wenn Hermes Informationen aus einer Datei verarbeitet und diese in einer
|
||||
Marktdurchführung speichert, muss Hermes die Quelldatei als vollständige
|
||||
HTTPS-URL in das Feld `notes` aufnehmen. Der bestehende Text darf erhalten
|
||||
bleiben; die Position im Freitext ist frei wählbar.
|
||||
|
||||
Die URL verwendet dieses Format:
|
||||
|
||||
```text
|
||||
https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad>
|
||||
```
|
||||
|
||||
Der Parameter `path` ist relativ zum Ordner `Hermes Naurua` und enthält nicht
|
||||
den lokalen Mount-Prefix und nicht den Ordnernamen `Hermes Naurua` selbst.
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
Quelldatei: Hermes Naurua/Marktmanagement/2026 Bellevue Noël/Standmietvertrag_Naurua.pdf
|
||||
Link: https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf
|
||||
```
|
||||
|
||||
Leerzeichen, Umlaute und sonstige Sonderzeichen müssen vollständig URL-encodiert
|
||||
werden. Die ERP-Anzeige macht ausschließlich diese Dokument-URLs klickbar. Der
|
||||
Link ist schreibgeschützt und erfordert eine aktive ERP-Anmeldung.
|
||||
|
||||
Die Route benötigt auf dem ERP-Webserver die Konfiguration
|
||||
`HERMES_DOCUMENT_ROOT=/volume1/Hermes/Hermes Naurua`. Dieser lokale Pfad ist
|
||||
die auf DEV verifizierte Ablage der Synology-Freigabe.
|
||||
|
||||
## Fachliche Begrifflichkeit für Hermes
|
||||
|
||||
Die fachliche Hierarchie lautet:
|
||||
|
||||
```text
|
||||
Markt (market_definition)
|
||||
└── Marktdurchführung (market_occurrence)
|
||||
└── Markttag / Markttage (market_occurrence_day)
|
||||
```
|
||||
|
||||
Ein Markt ist die zentrale Marktdefinition. Eine Marktdurchführung ist eine
|
||||
konkrete Ausgabe bzw. Teilnahme an diesem Markt. Eine Marktdurchführung besitzt
|
||||
mindestens einen Markttag. OTC-Verkäufe werden immer einem konkreten Markttag
|
||||
zugeordnet; die zugehörige Marktdurchführung und der Markt ergeben sich aus
|
||||
dieser Referenz.
|
||||
|
||||
### 1. Initialisieren
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Werkzeuge laden
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Werkzeug aufrufen
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "market_management.list_categories",
|
||||
"arguments": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Das Ergebnis steht bei `tools/call` als JSON im Textinhalt des ersten
|
||||
`content`-Elements.
|
||||
|
||||
## Verfügbare Werkzeuge
|
||||
|
||||
### Marktdefinitionen
|
||||
|
||||
- `market_management.search_markets`
|
||||
- optional: `search`, `category_id`, `status`, `place`,
|
||||
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
|
||||
`sort`, `sort_direction`, `limit`, `offset`
|
||||
- `market_management.search_occurrences`
|
||||
- sucht Marktdurchführungen
|
||||
- optional: `search`, `market_definition_id`, `status`, `place`, `address`,
|
||||
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
|
||||
`sort`, `sort_direction`, `limit`, `offset`
|
||||
- `market_management.get_market`
|
||||
- erforderlich: `market_id`
|
||||
- optional: `include_archived`
|
||||
- liefert Marktdurchführungen inklusive Markttagen, Kosten und berechneten Verkaufswerten
|
||||
- `market_management.create_market`
|
||||
- erforderlich: `name`, `category_id`, `organizer_id`
|
||||
- `market_management.list_market_organizers`
|
||||
- liefert aktive Veranstalterorganisationen
|
||||
- `market_management.create_market_organizer`
|
||||
- erforderlich: `name`
|
||||
- optional: `email`, `phone`
|
||||
- optional: `website_url` als HTTP(S)-URL des Marktes
|
||||
- `market_management.update_market`
|
||||
- erforderlich: `market_id`
|
||||
- änderbar: `name`, `category_id`, `website_url`
|
||||
- `market_management.archive_market`
|
||||
- erforderlich: `market_id`
|
||||
- `market_management.reactivate_market`
|
||||
- erforderlich: `market_id`
|
||||
|
||||
### Kategorien
|
||||
|
||||
- `market_management.list_categories`
|
||||
|
||||
Hermes darf Kategorien lesen und bei einem Markt referenzieren, aber keine
|
||||
Kategorien anlegen oder ändern.
|
||||
|
||||
- `market_management.list_reference_data`
|
||||
- liefert Kategorien, Kantone und aktive Gemeinden aus dem Markt-Gemeinderegister
|
||||
- `market_management.create_municipality`
|
||||
- erforderlich: `name`
|
||||
- legt eine lokale Gemeinde im Markt-Gemeinderegister an
|
||||
- `market_management.get_overview`
|
||||
- liefert serverseitig gefilterte Markt- und Marktdurchführungslisten
|
||||
|
||||
### Marktdurchführungen
|
||||
|
||||
- `market_management.create_occurrence`
|
||||
- erforderlich: `market_definition_id`, `days` mit mindestens einem Markttag
|
||||
- initialer Status: `open`
|
||||
- weitere Marktdurchführungsfelder sind optional und werden typisiert validiert
|
||||
- `market_management.get_occurrence`
|
||||
- erforderlich: `occurrence_id`
|
||||
- liefert zusätzlich den Markt, Markttage, Kosten und
|
||||
berechnete Tages- und Gesamterlöse
|
||||
- `market_management.update_occurrence`
|
||||
- erforderlich: `occurrence_id`
|
||||
- änderbar: sämtliche editierbaren GUI-Marktdurchführungsfelder einschließlich
|
||||
Gemeinde, Kontaktperson, Kontakt-E-Mail und Kontakt-Telefon
|
||||
- der vom Markt geerbte Veranstalter ist nicht editierbar
|
||||
- `market_management.archive_occurrence`
|
||||
- erforderlich: `occurrence_id`
|
||||
- `market_management.reactivate_occurrence`
|
||||
- erforderlich: `occurrence_id`
|
||||
|
||||
Unterstützte Statuswerte:
|
||||
|
||||
```text
|
||||
open
|
||||
waitlist
|
||||
registered
|
||||
confirmation_pending
|
||||
confirmed
|
||||
cancelled
|
||||
completed
|
||||
```
|
||||
|
||||
Unterstützte Zahlungsstatuswerte:
|
||||
|
||||
```text
|
||||
unpaid
|
||||
paid
|
||||
```
|
||||
|
||||
### Markttage
|
||||
|
||||
- `market_management.create_day`
|
||||
- legt einen Markttag an
|
||||
- erforderlich: `occurrence_id`, `date`
|
||||
- `market_management.update_day`
|
||||
- erforderlich: `day_id`
|
||||
- `market_management.delete_day`
|
||||
- erforderlich: `day_id`
|
||||
|
||||
Weitere mögliche Tagesfelder sind `stand_start_time`, `stand_end_time`,
|
||||
`setup_start_time`, `setup_end_time`, `delivery_start_time`,
|
||||
`delivery_end_time` und `observations`.
|
||||
|
||||
### Veranstalter und Kosten
|
||||
|
||||
- Veranstalter werden am Markt über `organizer_id` gesetzt und von den
|
||||
Durchführungen geerbt. Marktdurchführungen können nur `contact_person_name`,
|
||||
`contact_email` und `contact_phone` ändern.
|
||||
- Kosten werden bei `market_management.create_occurrence` automatisch mit einer
|
||||
`Standkosten`-Position (`stand_fee`) und dem Standardsteuersatz 8.1 % angelegt.
|
||||
- `market_management.update_occurrence` ändert Kosten über `gross_cost`,
|
||||
`tax_status`, `tax_rate` sowie die optionale mehrzeilige Freitext-Aufschlüsselung
|
||||
`cost_items_text`.
|
||||
- `cost_items_text` wird beim Schreiben als Beschreibung der automatisch
|
||||
verwalteten `stand_fee`-Kostenposition gespeichert. In den MCP-Leseantworten
|
||||
wird derselbe Wert zusätzlich als `cost_items_text` auf der Durchführung
|
||||
ausgegeben; damit entspricht er direkt dem GUI-Feld „Kostenpositionen“.
|
||||
- `tax_status` ist `taxable` oder `tax_free`; bei `tax_free` ist der Steuersatz
|
||||
`0.00`.
|
||||
- `gross_cost` darf `0.00` sein; einzelne Kostenpositionen und Kostenarten werden
|
||||
nicht separat über MCP verwaltet.
|
||||
|
||||
## Beispiel: Markt anlegen
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 10,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "market_management.create_market",
|
||||
"arguments": {
|
||||
"name": "Beispielmarkt Hermes Test",
|
||||
"category_id": 1,
|
||||
"idempotency_key": "hermes-create-market-001"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Beispiel: Marktdurchführung mit Markttag anlegen
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "market_management.create_occurrence",
|
||||
"arguments": {
|
||||
"market_definition_id": 1,
|
||||
"status": "open",
|
||||
"place": "Beispielort",
|
||||
"registration_deadline": "2026-10-01",
|
||||
"idempotency_key": "hermes-create-occurrence-001",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-11-21",
|
||||
"stand_start_time": "09:00",
|
||||
"stand_end_time": "18:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 12,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "market_management.create_day",
|
||||
"arguments": {
|
||||
"occurrence_id": 1,
|
||||
"date": "2026-11-21",
|
||||
"stand_start_time": "09:00",
|
||||
"stand_end_time": "18:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fachliche Grenzen
|
||||
|
||||
- Hermes erhält keinen direkten Datenbankzugriff.
|
||||
- Hermes darf keine Kategorien anlegen.
|
||||
- Umsatz und Artikelmengen werden nicht über MCP geschrieben; sie werden aus
|
||||
den OTC-Verkäufen abgeleitet.
|
||||
- Markt und Marktdurchführung werden unabhängig archiviert.
|
||||
- Alle Schreibvorgänge werden serverseitig validiert.
|
||||
- Jeder schreibende MCP-Aufruf benötigt `idempotency_key`; optional kann
|
||||
`client_id` mitgegeben werden. Wiederholungen liefern dasselbe Ergebnis.
|
||||
- Umsatz- und Artikelmengen aus OTC-Verkäufen sind erst verfügbar, sobald die
|
||||
separate OTC-Schnittstelle die Zuordnung zum Markttag bereitstellt.
|
||||
|
||||
## Fehler
|
||||
|
||||
Bei fehlender oder ungültiger Authentifizierung antwortet der Endpunkt mit
|
||||
HTTP 401. Ungültige JSON-RPC-Requests oder fachlich ungültige Eingaben werden
|
||||
als JSON-RPC-Fehlerantwort zurückgegeben.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Market Management: Hermes-MCP-Konzept
|
||||
|
||||
Stand: 2026-09-19
|
||||
Status: Verbindliches Schnittstellenkonzept
|
||||
|
||||
## 1. Zweck
|
||||
|
||||
Die App stellt einen geschützten MCP-Server bereit. Hermes Agent ist MCP-Client
|
||||
und arbeitet im Benutzerkontext. Hermes kann Marktmanagement-Daten suchen,
|
||||
anlegen, ändern, archivieren und reaktivieren.
|
||||
|
||||
## 2. Verantwortungen
|
||||
|
||||
Die App kontrolliert:
|
||||
|
||||
- Authentifizierung
|
||||
- Benutzer- und Modulberechtigung
|
||||
- Eingabevalidierung
|
||||
- fachliche Feldzuordnung
|
||||
- Persistierung
|
||||
- Audit- und Herkunftsdaten
|
||||
- Idempotenz
|
||||
|
||||
Hermes besitzt keinen direkten Zugriff auf Datenbank, interne Services oder
|
||||
fremde Module.
|
||||
|
||||
## 3. Schnittstellenmodell
|
||||
|
||||
Der MCP-Server veröffentlicht fachliche Tools, keine generischen Datenbank-
|
||||
oder Tabellenoperationen.
|
||||
|
||||
Vorgesehene Toolgruppen:
|
||||
|
||||
- Märkte suchen und lesen
|
||||
- Märkte anlegen, ändern, archivieren und reaktivieren
|
||||
- Marktdurchführungen suchen und lesen
|
||||
- Marktdurchführungen anlegen, ändern, archivieren und reaktivieren
|
||||
- Markttage anlegen, ändern und entfernen
|
||||
- Kategorien, Kantone und Gemeinden lesen
|
||||
- Kosten und Zahlungsstatus ändern
|
||||
- Übersichtsdaten lesen
|
||||
|
||||
Hermes darf bestehende Kategorien verwenden, aber keine Kategorien anlegen.
|
||||
Umsatz- und Artikelmengen werden nicht über Hermes geschrieben, sondern aus den
|
||||
OTC-Verkäufen abgeleitet.
|
||||
|
||||
## 4. Authentifizierung und Autorisierung
|
||||
|
||||
- MCP-Endpunkt über geschütztes HTTPS
|
||||
- Bearer-Token-Prüfung an der Transportgrenze
|
||||
- der konfigurierte Token identifiziert den technischen Hermes-Benutzerkontext
|
||||
- zusätzliche Prüfung im jeweiligen Tool-Handler
|
||||
- kein direkter Datenbankzugriff für Hermes
|
||||
|
||||
Für die erste Implementierungsstufe wird bewusst keine neue OAuth-/OIDC- oder
|
||||
Entitlement-Infrastruktur eingeführt. Der Token wird ausschließlich über die
|
||||
geschützte App-Konfiguration bereitgestellt und mit konstanter Zeit verglichen.
|
||||
Die Implementierung verwendet dafür den Konfigurationswert
|
||||
`MARKET_MCP_TOKEN`.
|
||||
|
||||
Die fachliche Ownership der Lizenz-/Entitlement-Daten bleibt offen. Die
|
||||
Schnittstelle prüft nur die über den bestehenden Zugriffskontext gelieferte
|
||||
Modulberechtigung.
|
||||
|
||||
## 5. Schreibregeln
|
||||
|
||||
- Jeder Tool-Aufruf ist atomar.
|
||||
- Fehlgeschlagene Aufrufe erzeugen keine Teilpersistierung.
|
||||
- Alle Schreibvorgänge laufen über die interne Modul-API.
|
||||
- Hermes darf bei einem neuen Markt nur eine bestehende Kategorie referenzieren.
|
||||
- Wenn Hermes Informationen aus einer Datei verarbeitet und in einer
|
||||
Marktdurchführung speichert, muss die Quelldatei als vollständige HTTPS-URL
|
||||
im Feld `notes` referenziert werden. Die Position im bestehenden Freitext ist
|
||||
frei wählbar.
|
||||
- Unsichere Erkennung wird nicht durch eine zusätzliche App-Freigabelogik
|
||||
behandelt; die fachliche Qualitätssicherung liegt im Hermes-Training.
|
||||
|
||||
## 6. Wiederholungsschutz
|
||||
|
||||
Schreibende Aufrufe können eine Idempotenzreferenz übergeben. Die App speichert
|
||||
Client, Benutzer, Tool, Schlüssel, Request-Hash und Verarbeitungsergebnis in
|
||||
`market_mcp_idempotency`.
|
||||
|
||||
Ein wiederholter Aufruf mit gleichem Schlüssel und gleichem Request liefert das
|
||||
bereits bekannte Ergebnis. Ein gleicher Schlüssel mit verändertem Request wird
|
||||
abgewiesen.
|
||||
|
||||
## 7. Daten- und Modulgrenzen
|
||||
|
||||
- Marktmanagement schreibt nur in eigene Tabellen.
|
||||
- Das OTC-Modul schreibt die Verkaufstabelle und referenziert den
|
||||
`market_occurrence_day_id`.
|
||||
- Kontakt- und Organisationsdaten werden über das Kontaktmodul referenziert.
|
||||
- Gemeindedaten werden über den eigenen Referenzbestand gelesen.
|
||||
- MCP-Komposition enthält keine Marktfachlogik.
|
||||
@@ -0,0 +1,404 @@
|
||||
# Market Management: Normatives Tabellen- und Prozesskonzept
|
||||
|
||||
Stand: 2026-09-19
|
||||
Status: Verbindliches Konzept für das Modul `market_management`
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
Tabellen:
|
||||
|
||||
- `market_category`
|
||||
- `market_definition`
|
||||
- `market_occurrence`
|
||||
- `market_occurrence_organizer`
|
||||
- `market_occurrence_day`
|
||||
- `market_occurrence_cost_item`
|
||||
- `market_municipality_reference`
|
||||
- `market_municipality`
|
||||
- `market_mcp_idempotency`
|
||||
|
||||
Bestehende Integrationsreferenz:
|
||||
|
||||
- `sales_order.market_occurrence_day_id` im owning OTC-/Direktverkaufsmodul
|
||||
|
||||
Prozessbereiche:
|
||||
|
||||
- zentrale Marktdefinition verwalten
|
||||
- Durchführung verwalten
|
||||
- Durchführungstage verwalten
|
||||
- Kosten und Zahlungsstatus verwalten
|
||||
- OTC-Verkäufe einem Durchführungstag zuordnen
|
||||
- Umsatz- und Artikelmengen lesen und aggregieren
|
||||
- Marktübersicht lesen
|
||||
- Hermes-MCP-Zugriff ausführen
|
||||
- Referenzdaten verwalten
|
||||
|
||||
## 2. Zweck und Scope
|
||||
|
||||
Dieses Dokument beschreibt das normative technische Zielbild für die Umsetzung
|
||||
bis einschließlich Punkt 10 des Anforderungsprofils.
|
||||
|
||||
Enthalten sind:
|
||||
|
||||
- zentrale Marktdefinition mit eindeutigem Namen und genau einer Kategorie
|
||||
- beliebig viele Durchführungen je Markt
|
||||
- mindestens ein Durchführungstag je Durchführung
|
||||
- veranstaltungs-, tages-, kosten- und zahlungsbezogene Daten
|
||||
- OTC-basierte Umsatz- und Artikelmengenermittlung
|
||||
- Archivierung von Markt und Durchführung auf getrennten Ebenen
|
||||
- serverseitige Übersichtsdaten
|
||||
- Benutzer- und Modulberechtigung
|
||||
- Hermes als MCP-Client der App
|
||||
|
||||
Nicht enthalten sind Punkt 11 und folgende, insbesondere intelligente
|
||||
Auswertungen, Korrelationen und wirtschaftliche Gemeindedaten.
|
||||
|
||||
GUI-Details, konkrete HTTP-Komposition und technische Hermes-Implementierung
|
||||
werden in den zugehörigen Detaildokumenten beschrieben.
|
||||
|
||||
## 3. Normatives Datenmodell
|
||||
|
||||
```text
|
||||
Kategorie / Marktart
|
||||
└── Marktdefinition
|
||||
└── Durchführung
|
||||
└── Durchführungstag / Subdurchführung
|
||||
└── OTC-Verkäufe
|
||||
```
|
||||
|
||||
### 3.1 `market_category`
|
||||
|
||||
Deterministische Kategorie-/Marktartliste.
|
||||
|
||||
Normativ:
|
||||
|
||||
- Kategorienamen sind eindeutig.
|
||||
- Fabienne darf die Liste erweitern.
|
||||
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
|
||||
- Deaktivierte Kategorien bleiben für historische Zuordnungen erhalten.
|
||||
|
||||
### 3.2 `market_definition`
|
||||
|
||||
Zentrale fachliche Marktdefinition.
|
||||
|
||||
Normativ:
|
||||
|
||||
- jeder Markt besitzt genau einen eindeutigen Namen
|
||||
- jeder Markt besitzt genau eine Kategorie
|
||||
- jeder Markt besitzt genau einen verpflichtenden Veranstalter aus dem eigenen Markt-Veranstalterregister
|
||||
- jeder Markt kann eine optionale Website-URL der Veranstaltung besitzen
|
||||
- der Markt enthält keine Durchführungs-, Kontakt-, Kosten-, Zahlungs- oder
|
||||
Erlösdaten
|
||||
- Markt und Durchführungen werden getrennt archiviert und reaktiviert
|
||||
|
||||
### 3.3 `market_occurrence`
|
||||
|
||||
Konkrete Teilnahme bzw. Veranstaltungsausgabe eines Marktes.
|
||||
|
||||
Normativ:
|
||||
|
||||
- jede Durchführung gehört genau einer Marktdefinition
|
||||
- jede Durchführung besitzt mindestens einen Durchführungstag
|
||||
- variable Veranstaltungs-, Kontakt-, Orts-, Logistik-, Kosten- und
|
||||
Zahlungsdaten gehören zur Durchführung
|
||||
- der Status gehört zur Durchführung
|
||||
- Zahlungsstatus und Marktstatus sind unabhängig
|
||||
- der Veranstalter wird von jeder Durchführung des Marktes geerbt und ist dort nicht editierbar
|
||||
- der Zahlungsstatus ist manuell `paid` oder `unpaid`
|
||||
|
||||
Minimale Pflichtfelder über alle Statuswerte hinweg:
|
||||
|
||||
- `market_definition_id`
|
||||
- initialer Status `open`
|
||||
- mindestens ein Durchführungstag
|
||||
|
||||
### 3.4 `market_occurrence_organizer`
|
||||
|
||||
Veranstalterbeziehung einer Durchführung.
|
||||
|
||||
Normativ:
|
||||
|
||||
- die Tabelle enthält die durchführungsbezogene Kontaktperson als Freitext sowie optionale E-Mail und Telefonnummer
|
||||
- jede Durchführung besitzt genau einen lokalen Kontaktdatensatz
|
||||
- der Veranstalter selbst wird ausschließlich über `market_definition.organizer_id` vom Markt geerbt
|
||||
- Veranstalter werden ausschließlich im Markt-Veranstalterregister verwaltet und nicht aus Lieferanten oder Kunden abgeleitet
|
||||
|
||||
### 3.5 `market_occurrence_day`
|
||||
|
||||
Einzelner Durchführungstag bzw. Subdurchführung.
|
||||
|
||||
Normativ:
|
||||
|
||||
- jeder Tag gehört genau einer Durchführung
|
||||
- das Datum ist je Durchführung eindeutig
|
||||
- Stand-, Aufbau-, Abbau- und tagesbezogene Anlieferungszeiten gehören hierher
|
||||
- Ereignisse und Beobachtungen gehören hierher
|
||||
- OTC-Verkäufe werden eindeutig diesem Datensatz zugeordnet
|
||||
- Bruttoerlös und Artikelmenge werden aus den zugeordneten OTC-Verkäufen
|
||||
abgeleitet und nicht manuell gespeichert
|
||||
|
||||
Minimale Pflichtfelder:
|
||||
|
||||
- `occurrence_id`
|
||||
- `date`
|
||||
|
||||
### Mindestpflichtfelder
|
||||
|
||||
Über alle Statuswerte hinweg gelten nur diese Pflichtfelder:
|
||||
|
||||
- Marktdefinition: `name`, `category_id`; `website_url` ist optional
|
||||
- Durchführung: `market_definition_id`, initialer Status `open`, mindestens ein
|
||||
Durchführungstag
|
||||
- Durchführungstag: `occurrence_id`, `date`
|
||||
|
||||
Alle weiteren Felder bleiben bei der Anlage optional und werden unabhängig vom
|
||||
Status ergänzt.
|
||||
|
||||
### 3.6 Kosten einer Durchführung
|
||||
|
||||
Die Durchführung besitzt einen einfachen Gesamtkostenbetrag und eine optionale
|
||||
mehrzeilige Freitext-Aufschlüsselung der Kostenpositionen.
|
||||
|
||||
Normativ:
|
||||
|
||||
- jede Durchführung besitzt genau eine technische `Standkosten`-Position
|
||||
(`stand_fee`); deren Betrag darf `0.00` sein und der Standardsteuersatz beträgt
|
||||
8.1 %
|
||||
- weitere Einzelpositionen und Kostenarten werden nicht separat gepflegt
|
||||
- CHF ist die einzige Währung
|
||||
- Bruttobetrag wird erfasst
|
||||
- Netto- und Steuerbetrag werden aus Brutto und Steuerart/-satz berechnet
|
||||
- die Freitext-Aufschlüsselung wird als Beschreibung gespeichert
|
||||
|
||||
### 3.7 `market_municipality_reference`
|
||||
|
||||
Technischer Referenzbestand des offiziellen Schweizer Gemeindeverzeichnisses.
|
||||
|
||||
Normativ:
|
||||
|
||||
- Gemeinden werden über BFS-Gemeindenummern referenziert
|
||||
- Gemeindename und Kantonszuordnung stammen aus dem Referenzbestand
|
||||
- nur gültige Gemeinden stehen für neue Eingaben zur Auswahl
|
||||
|
||||
### 3.8 `market_municipality`
|
||||
|
||||
Eigenes Gemeinderegister des Marktmanagements. Es enthält sowohl aktive
|
||||
Gemeinden aus dem offiziellen Referenzbestand als auch lokal neu angelegte
|
||||
Gemeinden.
|
||||
|
||||
Normativ:
|
||||
|
||||
- jede aktive Gemeinde besitzt einen eindeutigen Namen
|
||||
- Referenzgemeinden behalten ihre BFS-Gemeindenummer und den Datenstand
|
||||
- lokal angelegte Gemeinden besitzen keine BFS-Referenz
|
||||
- neue Gemeinden werden über das Marktmanagement angelegt und danach in der
|
||||
Auswahl für weitere Marktdurchführungen angeboten
|
||||
- eine Marktdurchführung referenziert genau einen Datensatz dieses Registers
|
||||
- der Datenstand wird versioniert gespeichert
|
||||
|
||||
### 3.9 `market_mcp_idempotency`
|
||||
|
||||
Technischer Idempotenzbestand für wiederholte Hermes-Schreibaufrufe.
|
||||
|
||||
Normativ:
|
||||
|
||||
- derselbe Client und Idempotenzschlüssel erzeugen keine doppelte Wirkung
|
||||
- ein abweichender Request mit demselben Schlüssel wird abgewiesen
|
||||
- der gespeicherte Verarbeitungsausgang bleibt nachvollziehbar
|
||||
|
||||
### 3.9 OTC-Integration
|
||||
|
||||
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
|
||||
referenziert eindeutig einen `market_occurrence_day_id`. Markt, Durchführung,
|
||||
Datum, Umsatz und Artikelmengen werden daraus ermittelt.
|
||||
|
||||
Historische OTC-Verkäufe ohne diese Zuordnung werden nicht migriert.
|
||||
|
||||
## 4. Normatives Prozessmodell
|
||||
|
||||
Alle fachlichen Prozesse gehören zum owning Modul `market_management`, außer der
|
||||
OTC-Verkaufsprozess selbst. Die OTC-Integration erfolgt ausschließlich über die
|
||||
öffentliche Marktmanagement-Schnittstelle.
|
||||
|
||||
### 4.1 Zentrale Marktdefinition verwalten
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- Marktdefinition mit eindeutigem Namen und Kategorie anlegen, ändern,
|
||||
archivieren oder reaktivieren
|
||||
|
||||
Liest:
|
||||
|
||||
- Kategorien
|
||||
- bestehende Marktdefinitionen
|
||||
|
||||
Schreibt:
|
||||
|
||||
- `market_category`, sofern Fabienne eine Kategorie erweitert
|
||||
- `market_definition`
|
||||
|
||||
### 4.2 Durchführung verwalten
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- eine konkrete Durchführung eines Marktes anlegen, bearbeiten, archivieren
|
||||
oder reaktivieren
|
||||
|
||||
Liest:
|
||||
|
||||
- Marktdefinition
|
||||
- Kontaktmodul
|
||||
- Gemeindereferenz
|
||||
|
||||
Schreibt:
|
||||
|
||||
- `market_occurrence`
|
||||
- `market_occurrence_organizer`
|
||||
|
||||
### 4.3 Durchführungstage verwalten
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- Durchführungstage anlegen, bearbeiten und entfernen
|
||||
|
||||
Liest:
|
||||
|
||||
- Durchführung
|
||||
|
||||
Schreibt:
|
||||
|
||||
- `market_occurrence_day`
|
||||
|
||||
### 4.4 Kosten und Zahlungsstatus verwalten
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- Gesamtkosten, Kostenaufschlüsselung und manuelle Zahlungsinformationen pflegen
|
||||
|
||||
Liest:
|
||||
|
||||
- Durchführung
|
||||
- bestehende Kosteninformation
|
||||
|
||||
Schreibt:
|
||||
|
||||
- technische Standkosten-Position in `market_occurrence_cost_item`
|
||||
- Zahlungsfelder in `market_occurrence`
|
||||
|
||||
### 4.5 OTC-Verkauf einem Durchführungstag zuordnen
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- beim OTC-Verkauf einen gültigen Durchführungstag auswählen und referenzieren
|
||||
|
||||
Liest:
|
||||
|
||||
- verfügbare Märkte, Durchführungen und Durchführungstage über die
|
||||
Marktmanagement-Schnittstelle
|
||||
|
||||
Schreibt:
|
||||
|
||||
- ausschließlich das OTC-Modul in seine eigene Verkaufstabelle
|
||||
|
||||
### 4.6 Umsatz- und Artikelmengen lesen
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- Umsatz und Artikelmengen für Tag, Durchführung und Markt aus den OTC-Daten
|
||||
aggregieren
|
||||
|
||||
Liest:
|
||||
|
||||
- zugeordnete OTC-Verkäufe und Verkaufspositionen
|
||||
- Markt-, Durchführungs- und Tagesdaten
|
||||
|
||||
Schreibt:
|
||||
|
||||
- keine fachlichen Umsatzsummen; die Werte sind abgeleitet
|
||||
|
||||
### 4.7 Marktübersicht lesen
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- Märkte, Durchführungen und Tage serverseitig suchen, filtern, sortieren und
|
||||
aggregiert ausgeben
|
||||
|
||||
Liest:
|
||||
|
||||
- alle owned Marktmanagement-Daten
|
||||
- Kontakt- und Gemeindereferenzen
|
||||
- OTC-Aggregate
|
||||
|
||||
Schreibt:
|
||||
|
||||
- nichts
|
||||
|
||||
### 4.8 Hermes-MCP-Zugriff
|
||||
|
||||
Fachliche Aufgabe:
|
||||
|
||||
- Hermes über die kontrollierte MCP-Schnittstelle suchen, anlegen, ändern,
|
||||
archivieren und reaktivieren lassen
|
||||
|
||||
Liest:
|
||||
|
||||
- Marktmanagement-Daten und erlaubte Referenzdaten
|
||||
|
||||
Schreibt:
|
||||
|
||||
- ausschließlich über die internen Marktmanagement-Services
|
||||
- `market_mcp_idempotency` für Wiederholungsschutz
|
||||
|
||||
Die konkrete MCP-Schnittstelle ist in
|
||||
[market_management_hermes_mcp_konzept.md](market_management_hermes_mcp_konzept.md)
|
||||
dokumentiert.
|
||||
|
||||
## 5. Normative Betriebsregeln
|
||||
|
||||
- Marktname und Kategorie sind zentrale Stammdaten.
|
||||
- Alle variablen Veranstaltungsdaten werden je Durchführung gepflegt.
|
||||
- Tageswerte werden je Durchführungstag gepflegt.
|
||||
- Wochentag, Saison, Datumsbereich, Gesamtkosten, offener Betrag, Umsatz und
|
||||
Artikelmengen werden abgeleitet.
|
||||
- Umsatz und Artikelmengen stammen ausschließlich aus OTC-Verkäufen.
|
||||
- Stornos und Rückerstattungen werden in den Aggregaten abgezogen.
|
||||
- Markt- und Durchführungarchivierung sind unabhängig.
|
||||
- Der erste Status ist `offen`.
|
||||
- `durchgefuehrt` wird nach dem letzten Durchführungstag automatisch gesetzt,
|
||||
sofern die Durchführung nicht `abgesagt` ist.
|
||||
- Die GUI enthält keine fachliche Primärlogik.
|
||||
- Hermes erhält keinen direkten DB-Zugriff.
|
||||
- Punkt 11 und folgende sind nicht Teil dieses Zielbilds.
|
||||
|
||||
## 6. Ausgabevertrag
|
||||
|
||||
Das Modul liefert:
|
||||
|
||||
- eindeutige Markt-, Durchführungs- und Tagesidentitäten
|
||||
- validierte Stammdaten und Referenzen
|
||||
- unabhängigen Markt- und Zahlungsstatus
|
||||
- berechnete Kosten-, Umsatz- und Artikelmengenwerte
|
||||
- serverseitig filter- und sortierbare Übersichtsdaten
|
||||
- kontrollierte MCP-Ergebnisse für Hermes
|
||||
|
||||
## 7. Erfolgskriterien
|
||||
|
||||
- Marktdefinitionen sind eindeutig und genau einer Kategorie zugeordnet.
|
||||
- Durchführungen und Durchführungstage sind unabhängig und vollständig
|
||||
verwaltbar.
|
||||
- Jeder neue OTC-Verkauf ist genau einem Durchführungstag zugeordnet.
|
||||
- Umsatz und Artikelmengen werden nicht manuell doppelt erfasst.
|
||||
- Kontakt- und Gemeindedaten werden über definierte Referenzen verwendet.
|
||||
- Archivierung funktioniert getrennt für Markt und Durchführung.
|
||||
- MCP-Schreibvorgänge sind authentifiziert, berechtigt und idempotent.
|
||||
- Die fachliche Logik bleibt im owning Modul.
|
||||
|
||||
## 8. Noch nicht normative Detailverträge
|
||||
|
||||
Die folgenden Dokumente werden erst nach expliziter Prozessdefinition als
|
||||
normative Verträge erstellt:
|
||||
|
||||
- Hauptprozesse unter `processes/main/`
|
||||
- Sub-Prozesse unter `processes/sub_processes/`
|
||||
- konkrete API-Schemas der Marktmanagement-Schnittstelle
|
||||
- GUI- und View-Verträge
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# market_management.occurrence_status_completion
|
||||
Stand: 2026-09-19
|
||||
Status: Verbindliche Prozess-Spezifikation
|
||||
|
||||
## 1. Zweck
|
||||
|
||||
Setzt aktive Durchführungen am täglichen Cron-Lauf automatisch auf
|
||||
`completed`, sobald der Kalendertag nach ihrem letzten Durchführungstag erreicht
|
||||
ist. Abgesagte Durchführungen werden nicht verändert.
|
||||
|
||||
## 2. Prozess-Einbettung
|
||||
|
||||
Täglicher synchroner Cron-Teilprozess des zentralen System-Cron-Entrypoints.
|
||||
Die fachliche Statusänderung gehört zum owning Modul `market_management`.
|
||||
|
||||
## 3. Input
|
||||
|
||||
- aktuelles Datum in der Zeitzone `Europe/Zurich`
|
||||
- keine vorbereiteten Fachdaten; der Prozess liest seine Daten selbst aus der
|
||||
Datenbank
|
||||
|
||||
## 4. Exakter Prozessablauf
|
||||
|
||||
1. Einen technischen Lauf in `public.process_runs` mit dem aktuellen Datum
|
||||
starten.
|
||||
2. Aus `market_occurrence` und `market_occurrence_day` aktive Durchführungen
|
||||
mit mindestens einem Tag bestimmen.
|
||||
3. Durchführungen ausschliessen, deren Status `cancelled` oder bereits
|
||||
`completed` ist.
|
||||
4. Für jede verbleibende Durchführung den letzten Durchführungstag bestimmen.
|
||||
5. Wenn dieser Tag vor dem aktuellen Datum liegt, den Status atomar auf
|
||||
`completed` setzen.
|
||||
6. Den technischen Lauf mit Anzahl und IDs der geänderten Durchführungen
|
||||
abschliessen.
|
||||
|
||||
Gelesen werden `market_occurrence` und `market_occurrence_day`. Geschrieben
|
||||
werden `market_occurrence.status`, `market_occurrence.updated_at` und der
|
||||
technische Lauf in `public.process_runs`.
|
||||
|
||||
## 5. Batch, Betriebsmodell und Einbettung
|
||||
|
||||
Ein täglicher synchroner Cron-Lauf; ein SQL-Update verarbeitet den vollständigen
|
||||
aktuellen Scope atomar. Die bestehende Cron-Sperre verhindert parallele Läufe.
|
||||
|
||||
## 6. Output
|
||||
|
||||
Ein kompakter Laufstatus mit aktuellem Datum, Anzahl und IDs der auf
|
||||
`completed` gesetzten Durchführungen sowie der technischen Lauf-ID.
|
||||
|
||||
## 7. Erfolgskriterien
|
||||
|
||||
- Eine Durchführung wird am Tag nach ihrem letzten Durchführungstag auf
|
||||
`completed` gesetzt.
|
||||
- Abgesagte und bereits abgeschlossene Durchführungen bleiben unverändert.
|
||||
- Durchführungen ohne Durchführungstag werden nicht verändert.
|
||||
- Wiederholte Cron-Läufe sind idempotent.
|
||||
- `public.process_runs` enthält einen abgeschlossenen technischen Lauf.
|
||||
|
||||
## 8. Fehlerschranke
|
||||
|
||||
Ein technischer Fehler bricht den Lauf ab, rollt die fachliche Transaktion
|
||||
zurück und markiert `process_runs` als `failed`.
|
||||
|
||||
## 9. Fachliche Betriebsregel
|
||||
|
||||
Die Prüfung erfolgt einmal täglich über den bestehenden Cron. Die Statusänderung
|
||||
ist unabhängig von GUI-Aufrufen und OTC-Bestellungen.
|
||||
|
||||
## 10. Sub-Prozess-Referenzen
|
||||
|
||||
Keine zusätzlichen Sub-Prozesse.
|
||||
|
||||
## 11. End-to-End Sub-Prozess-Kette
|
||||
|
||||
System-Cron → Statusabschlussprozess → `market_occurrence` und
|
||||
`public.process_runs`.
|
||||
|
||||
## 12. Sub-Prozess-Wiederverwendung
|
||||
|
||||
Der bestehende System-Cron-Entrypoint und die bestehende Cron-Sperre werden
|
||||
wiederverwendet. Ein zusätzlicher Scheduler wird nicht eingeführt.
|
||||
|
||||
## 13. Step-Liste
|
||||
|
||||
- Lauf starten
|
||||
- eligible Durchführungen bestimmen und abschliessen
|
||||
- Laufstatus persistieren
|
||||
|
||||
## 14. Parallelisierung
|
||||
|
||||
Keine Parallelisierung; der deterministische SQL-Batch läuft synchron und
|
||||
atomar.
|
||||
|
||||
## 15. Output-Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "done",
|
||||
"as_of_date": "YYYY-MM-DD",
|
||||
"completed_count": 0,
|
||||
"completed_occurrence_ids": [],
|
||||
"technical_run_id": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 16. Done-Kriterien
|
||||
|
||||
- Der tägliche Cron ruft den Prozess genau einmal auf.
|
||||
- Die Statusregel ist serverseitig implementiert.
|
||||
- Der Prozess schreibt `process_runs` vollständig.
|
||||
- Wiederholte Läufe erzeugen keine erneuten Statusänderungen.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# Anforderungsprofil Marktübersicht
|
||||
|
||||
Stand: 2026-09-15
|
||||
Status: Fachliche Arbeitsanforderung
|
||||
|
||||
## 1. Ziel des Systems
|
||||
|
||||
Das System unterstützt die Suche, Prüfung, Anmeldung, Durchführung und
|
||||
Auswertung von Herbstmärkten, Weihnachtsmärkten und weiteren geeigneten
|
||||
Märkten.
|
||||
|
||||
## 2. Umfang der ersten Phase
|
||||
|
||||
- Es wird jeweils nur ein Markt gleichzeitig bearbeitet.
|
||||
- Zunächst gibt es nur eine Benutzerin: Fabienne.
|
||||
- Eine eigene Benutzerverwaltung ist zunächst nicht erforderlich.
|
||||
- Die Anwendung soll übersichtlich und einfach bedienbar sein.
|
||||
|
||||
## 3. Datenhierarchie
|
||||
|
||||
Die Daten müssen mehrstufig abbildbar sein:
|
||||
|
||||
```text
|
||||
Kategorie
|
||||
└── Markt
|
||||
└── Durchführung
|
||||
└── Durchführungstag
|
||||
```
|
||||
|
||||
- Es können beliebig viele Kategorien und Märkte existieren.
|
||||
- Ein Markt gehört genau einer Kategorie.
|
||||
- Fabienne darf die deterministische Kategorie-/Marktartliste erweitern.
|
||||
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
|
||||
- Ein Markt kann beliebig viele Durchführungen enthalten.
|
||||
- Eine Durchführung kann beliebig viele Durchführungstage enthalten.
|
||||
- Erlös- und Verkaufsdaten werden auf Ebene des Durchführungstags erfasst und
|
||||
auf Ebene der Durchführung sowie des Marktes auswertbar gemacht.
|
||||
|
||||
Der zentrale Marktstamm enthält ausschließlich:
|
||||
|
||||
- Marktname
|
||||
- Marktart/Kategorie aus einer deterministischen Liste
|
||||
- optionale Website-URL der Veranstaltung
|
||||
|
||||
Alle übrigen veränderlichen Angaben werden pro Durchführung erfasst. Tages-
|
||||
und Verkaufsdaten werden pro Durchführungstag erfasst.
|
||||
|
||||
## 4. Nutzungsberechtigung
|
||||
|
||||
- Die Rechte zur Nutzung des Moduls werden an die Benutzeridentität geknüpft.
|
||||
- Das Modul soll ohne die restliche Anwendung an weitere Personen verkauft und
|
||||
separat genutzt werden können.
|
||||
- Die Ownership und Ablage der Lizenz-/Entitlement-Daten ist noch nicht
|
||||
entschieden und bleibt offen.
|
||||
|
||||
## 5. Marktarten
|
||||
|
||||
Initial verfügbare Marktarten:
|
||||
|
||||
- Herbstmärkte
|
||||
- Weihnachtsmärkte
|
||||
- Frühlingsmärkte
|
||||
- Ostermärkte
|
||||
- Kunsthandwerksmärkte
|
||||
- Regionalmärkte
|
||||
- Weitere geeignete Märkte
|
||||
|
||||
Die Marktart/Kategorie wird zentral am Markt aus einer deterministischen Liste
|
||||
ausgewählt. Eine Durchführung überschreibt diese Zuordnung nicht.
|
||||
|
||||
## 6. Markt- und Durchführungsdaten
|
||||
|
||||
### 6.1 Allgemeine Informationen
|
||||
|
||||
- Veranstalter
|
||||
- Ort
|
||||
- Gemeinde
|
||||
- Kanton
|
||||
- Adresse
|
||||
- Kontaktperson
|
||||
- E-Mail-Adresse
|
||||
- Telefonnummer
|
||||
- Notizen
|
||||
|
||||
Diese Angaben werden pro Durchführung erfasst. Marktname und Marktart/Kategorie
|
||||
gehören ausschließlich zur zentralen Marktdefinition.
|
||||
|
||||
### 6.2 Termin und Durchführung
|
||||
|
||||
- Beginn und Ende des Marktes
|
||||
- Datum jedes einzelnen Markttages
|
||||
- Standzeiten pro Tag
|
||||
- Auf- und Abbauzeiten
|
||||
- Anmeldefrist
|
||||
- Datum der Anfrage oder Anmeldung
|
||||
- Datum der Bestätigung
|
||||
- Datum der Durchführung
|
||||
- Wochentag
|
||||
- Saison
|
||||
|
||||
### 6.3 Stand und Infrastruktur
|
||||
|
||||
- Standgröße
|
||||
- Tisch vorhanden
|
||||
- Tisch kann gemietet werden
|
||||
- Eigener Tisch erforderlich
|
||||
- Zelt oder Überdachung vorhanden
|
||||
- Strom vorhanden
|
||||
- Wasser vorhanden
|
||||
- Beleuchtung vorhanden
|
||||
- Weitere Infrastruktur
|
||||
- Anforderungen an den eigenen Stand
|
||||
|
||||
### 6.4 Anfahrt und Logistik
|
||||
|
||||
- Anfahrtsadresse
|
||||
- Entfernung oder Fahrzeit
|
||||
- Parkmöglichkeiten
|
||||
- Zufahrt für Anlieferung
|
||||
- Zeitfenster für die Anlieferung
|
||||
- Möglichkeit zum direkten Ausladen
|
||||
- Lager- oder Aufbewahrungsmöglichkeiten
|
||||
- Besondere Hinweise zur Anfahrt
|
||||
|
||||
### 6.5 Kosten und Konditionen
|
||||
|
||||
- Standgebühr
|
||||
- Gebühren für Tisch, Strom oder weitere Infrastruktur
|
||||
- Weitere Pflichtkosten
|
||||
- Gesamtkosten
|
||||
- Zahlungsfrist
|
||||
- Zahlungsart
|
||||
- Abmeldebedingungen
|
||||
- Umsatzbeteiligung, falls vorhanden
|
||||
- Weitere Teilnahmebedingungen
|
||||
|
||||
### 6.6 Vertrag und Unterlagen
|
||||
|
||||
Die zugehörigen Unterlagen sollen über einen Link mit einem eigenen Ordner
|
||||
verbunden werden können.
|
||||
|
||||
- Link zum Vertragsordner
|
||||
|
||||
Der Link wird pro Durchführung erfasst.
|
||||
|
||||
### 6.7 Deterministische Referenzwerte und Berechnungen
|
||||
|
||||
Deterministisch vorgegebene Referenzwerte; die jeweilige Auswahl wird auf der
|
||||
zuständigen Ebene gespeichert:
|
||||
|
||||
- Marktart/Kategorie
|
||||
- Marktstatus
|
||||
- Zahlungsstatus
|
||||
- Zahlungsart
|
||||
- Kanton
|
||||
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
|
||||
|
||||
Abgeleitete Werte werden nicht manuell eingegeben:
|
||||
|
||||
- Wochentag aus dem Datum
|
||||
- Saison aus dem Durchführungsdatum
|
||||
- Gesamtkosten als manuell erfassbarer Bruttobetrag mit optionaler Freitext-Aufschlüsselung
|
||||
- offener Betrag aus Kosten und bezahltem Betrag
|
||||
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
|
||||
Durchführungstagen
|
||||
|
||||
## 7. Statusverwaltung
|
||||
|
||||
Folgende Marktstatus sollen verfügbar sein:
|
||||
|
||||
- Offen
|
||||
- Warteliste
|
||||
- Angemeldet
|
||||
- Bestätigung ausstehend
|
||||
- Bestätigt
|
||||
- Abgesagt
|
||||
- Durchgeführt
|
||||
|
||||
Der initiale Status ist `Offen`. Die Statuswerte werden grundsätzlich manuell
|
||||
gesetzt. `Durchgeführt` wird nach dem letzten Durchführungstag automatisch
|
||||
gesetzt, sofern die Durchführung nicht abgesagt ist. Manuelle Korrektur bleibt
|
||||
möglich.
|
||||
|
||||
## 8. Bezahlung
|
||||
|
||||
Der Zahlungsstatus wird unabhängig vom allgemeinen Marktstatus gespeichert.
|
||||
|
||||
Zahlungsstatus:
|
||||
|
||||
- Nicht bezahlt
|
||||
- Bezahlt
|
||||
|
||||
Zusätzliche Zahlungsinformationen:
|
||||
|
||||
- Zahlungsdatum
|
||||
- Bezahlter Betrag
|
||||
- Offener Betrag
|
||||
|
||||
## 9. Erlöserfassung
|
||||
|
||||
Der Erlös wird nicht manuell erfasst, sondern aus den OTC-Verkäufen berechnet.
|
||||
Bei mehrtägigen Märkten wird der Erlös je Durchführungstag separat ermittelt.
|
||||
|
||||
Pro Durchführungstag:
|
||||
|
||||
- Datum
|
||||
- Bruttoerlös aus OTC-Verkäufen
|
||||
- Anzahl verkaufter Artikel aus OTC-Positionen
|
||||
- Besondere Ereignisse oder Beobachtungen
|
||||
|
||||
Automatisch zu berechnen:
|
||||
|
||||
- Gesamterlös des Marktes
|
||||
- Gesamtzahl verkaufter Artikel
|
||||
- Durchschnittlicher Erlös pro Tag
|
||||
- Durchschnittlicher Erlös pro verkauftem Artikel, sofern berechenbar
|
||||
|
||||
Die Tageswerte sollen einzeln und als Gesamtergebnis auswertbar sein.
|
||||
|
||||
## 10. Marktübersicht
|
||||
|
||||
Die zentrale Übersicht soll ermöglichen:
|
||||
|
||||
- alle Märkte anzuzeigen
|
||||
- nach Marktart zu filtern
|
||||
- nach Status zu filtern
|
||||
- nach Gemeinde oder Ort zu filtern
|
||||
- nach Datum zu sortieren
|
||||
- nach Erlös zu sortieren
|
||||
- den Bezahlstatus zu erkennen
|
||||
- kommende Märkte hervorzuheben
|
||||
- überfällige Anmeldungen oder Zahlungen zu erkennen
|
||||
- Märkte zu öffnen und zu bearbeiten
|
||||
|
||||
Mindestens sichtbar:
|
||||
|
||||
- Marktname
|
||||
- Marktart
|
||||
- Ort oder Gemeinde
|
||||
- Datum
|
||||
- Wochentag
|
||||
- Status
|
||||
- Gesamtkosten
|
||||
- Bezahlstatus
|
||||
- Bruttoerlös
|
||||
|
||||
## 11. Intelligente Auswertungen
|
||||
|
||||
Das System soll später Zusammenhänge, Unterschiede und mögliche Korrelationen
|
||||
zwischen Marktmerkmalen und Ergebnissen sichtbar machen.
|
||||
|
||||
### 10.1 Marktart und Erlös
|
||||
|
||||
- Welche Marktarten erzielen den höchsten Erlös?
|
||||
- Sind Weihnachtsmärkte erfolgreicher als Herbstmärkte?
|
||||
- Welche Marktarten haben das beste Verhältnis zwischen Erlös und Kosten?
|
||||
- Welche Marktarten führen zu den meisten verkauften Artikeln?
|
||||
|
||||
### 10.2 Datum, Saison und Erlös
|
||||
|
||||
- An welchen Daten werden die höchsten Erlöse erzielt?
|
||||
- Gibt es Unterschiede zwischen frühem und spätem Saisonzeitpunkt?
|
||||
- Wie entwickelt sich der Erlös innerhalb einer Saison?
|
||||
- Welche Monate und Jahreszeiten sind besonders erfolgreich?
|
||||
- Gibt es einen Zusammenhang zwischen Marktdatum und Verkaufsleistung?
|
||||
|
||||
### 10.3 Zahlungstermin und Markterfolg
|
||||
|
||||
- Wie viele Tage liegen zwischen Zahlungstermin und Marktdatum?
|
||||
- Laufen Märkte besser, wenn der Zahlungstermin früh oder spät vor dem Markt
|
||||
liegt?
|
||||
- Gibt es einen Zusammenhang zwischen dem Zeitpunkt der Zahlung und dem
|
||||
späteren Erlös?
|
||||
- Gibt es Unterschiede zwischen Märkten vor und nach dem eigenen Zahlungstag?
|
||||
|
||||
### 10.4 Gemeinde und wirtschaftliches Umfeld
|
||||
|
||||
Der Ort soll später über die Gemeinde mit zusätzlichen wirtschaftlichen Daten
|
||||
vergleichbar sein.
|
||||
|
||||
Zu prüfen ist insbesondere die Hypothese, dass Märkte in Gemeinden mit höherem
|
||||
durchschnittlichem Einkommen möglicherweise einen höheren Erlös erzielen als
|
||||
Märkte in Gemeinden mit tieferem Einkommen.
|
||||
|
||||
Auswertungen:
|
||||
|
||||
- Zusammenhang zwischen Gemeinde und Erlös
|
||||
- Zusammenhang zwischen Gemeinde-Einkommen und Erlös
|
||||
- Zusammenhang zwischen Kaufkraft und Anzahl verkaufter Artikel
|
||||
- Vergleich verschiedener Gemeinden
|
||||
- Vergleich ähnlicher Märkte in Gemeinden mit unterschiedlichem
|
||||
Einkommensniveau
|
||||
|
||||
Die Quelle, Aktualisierung und fachliche Ownership der zusätzlichen
|
||||
wirtschaftlichen Gemeindedaten sind noch nicht spezifiziert.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Editable Action Card
|
||||
|
||||
## Zweck
|
||||
|
||||
`Editable Action Card` ist ein fachlich neutrales UI-Pattern fuer Karten mit
|
||||
Titelaktionen, Lesemodus und optionalem Bearbeitungsmodus.
|
||||
|
||||
Das Pattern kapselt nur die technische Interaktion:
|
||||
|
||||
- Umschalten zwischen Lese- und Bearbeitungsansicht
|
||||
- Sichtbarer, im Bearbeitungsmodus deaktivierter Bearbeiten-Button
|
||||
- Abbrechen einer Bearbeitung
|
||||
- optionale Loeschbestaetigung mit konfigurierbarem Token
|
||||
- Deaktivieren der Loeschaktion bis zur gueltigen Bestaetigung
|
||||
- gleichzeitiges Bearbeiten mehrerer Karten
|
||||
|
||||
Fachliche Aktionen, API-Aufrufe, Validierung und die Aktualisierung fachlicher
|
||||
Daten bleiben beim owning Modul.
|
||||
|
||||
## Markup-Vertrag
|
||||
|
||||
Eine Karte wird mit `data-shared-editable-action-card` markiert. Die
|
||||
Bedienelemente verwenden folgende Attribute:
|
||||
|
||||
- `data-shared-editable-card-edit`
|
||||
- `data-shared-editable-card-cancel`
|
||||
- `data-shared-editable-card-read`
|
||||
- `data-shared-editable-card-form`
|
||||
- optional `data-shared-editable-card-delete`
|
||||
- optional `data-shared-editable-card-delete-confirmation`
|
||||
- optional `data-shared-editable-card-delete-input`
|
||||
- optional `data-shared-editable-card-delete-submit`
|
||||
- optional `data-shared-editable-card-delete-cancel`
|
||||
|
||||
Der Bearbeiten-Button bleibt im Titelsegment sichtbar. Im Bearbeitungsmodus
|
||||
wird er deaktiviert (`disabled`, `aria-disabled="true"` und
|
||||
`sg-button--inactive`); die Aktionen zum Abbrechen und Speichern bleiben aktiv.
|
||||
|
||||
Das Loesch-Token wird je Karte mit
|
||||
`data-shared-editable-delete-token` konfiguriert. Die Standardaktion fuer das
|
||||
Loeschen bleibt im owning Modul und verwendet den bestehenden fachlichen
|
||||
Handler.
|
||||
|
||||
## Nutzung im Lager
|
||||
|
||||
Im Lager-Drawer verwenden die aktive Charge und offene Chargen das Pattern.
|
||||
Rein lesende Karten, insbesondere historische und virtuelle Chargen, bleiben
|
||||
beim bestehenden Content-Card-Pattern.
|
||||
@@ -26,6 +26,10 @@
|
||||
<button class="sg-interaction-element sg-button sg-button--active" type="button" data-component="button" data-component-state="active">
|
||||
Link-Button aktiv
|
||||
</button>
|
||||
|
||||
<button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning">
|
||||
Link Button Warning
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -832,6 +836,21 @@
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<!-- Component: Tooltip -->
|
||||
<section id="component-tooltip">
|
||||
<p class="sg-preview-label">Component: Tooltip</p>
|
||||
|
||||
<span class="sg-tooltip-wrap" data-component="tooltip">
|
||||
<button class="sg-interaction-element sg-button sg-button--active" type="button" aria-describedby="component-tooltip-panel" aria-expanded="false" data-component-part="tooltip-trigger">
|
||||
Tooltip anzeigen
|
||||
</button>
|
||||
|
||||
<span class="sg-tooltip-panel sg-table-label" id="component-tooltip-panel" role="tooltip" hidden data-component-part="tooltip-panel">
|
||||
Diese Sprechblase ist trigger-verankert und wechselt bei fehlendem Platz automatisch die Seite.
|
||||
</span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Component: Hyperlink -->
|
||||
<section id="component-hyperlink">
|
||||
@@ -843,7 +862,29 @@
|
||||
</section>
|
||||
|
||||
<script src="../scripts/help-icon-overlays.js"></script>
|
||||
<script src="../scripts/tooltip-overlays.js"></script>
|
||||
<section id="component-file-input">
|
||||
<p class="sg-preview-label">Component: File Input</p>
|
||||
<div class="sg-form-preview-area">
|
||||
<div class="sg-file-input" data-component="file-input">
|
||||
<div class="sg-file-input__row sg-labeled-input-row">
|
||||
<label class="sg-label" for="component-file-input-control">Datei auswählen</label>
|
||||
<input class="sg-file-input__control" id="component-file-input-control" type="file" multiple>
|
||||
<button class="sg-interaction-element sg-button sg-button--active sg-file-input__trigger" type="button">Dateien auswählen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-component="file-input"]').forEach((fileInput) => {
|
||||
const input = fileInput.querySelector('.sg-file-input__control');
|
||||
const trigger = fileInput.querySelector('.sg-file-input__trigger');
|
||||
if (input && trigger) {
|
||||
trigger.addEventListener('click', () => input.click());
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.sg-mode-toggle:not(.sg-activation-mode-toggle)').forEach((toggle) => {
|
||||
toggle.addEventListener('click', () => {
|
||||
const nextState = toggle.dataset.active === 'relative' ? 'absolute' : 'relative';
|
||||
|
||||
@@ -158,6 +158,8 @@
|
||||
<tr><td>input-field-max-width</td><td>600px</td><td>Maximale Breite von ein- und mehrzeiligen Eingabefeldern (ohne Suchfeld).</td></tr>
|
||||
<tr><td>email-body-max-width</td><td>600px</td><td>Maximale Breite des Inhaltsbereichs von E-Mail-Templates innerhalb des E-Mail-Viewports.</td></tr>
|
||||
<tr><td>object-card-height</td><td>600px</td><td>Fixe Höhe der Object Card im Desktop-Layout (u. a. für Company Card).</td></tr>
|
||||
<tr><td>scrollable-card-width</td><td>600px</td><td>Standardbreite der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
|
||||
<tr><td>scrollable-card-height</td><td>600px</td><td>Standardhöhe der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
|
||||
<tr><td>content-card-margin-top-desktop</td><td>100px</td><td>Oberer Außenabstand der Content Card auf Desktop.</td></tr>
|
||||
<tr><td>content-card-margin-top-mobile</td><td>1rem</td><td>Oberer Außenabstand der Content Card auf Mobile; entspricht spacing-large.</td></tr>
|
||||
<tr><td>notification-card-width</td><td>50%</td><td>Breite der Notification Card relativ zum übergeordneten Layout.</td></tr>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<li><a href="./patterns/portal-header.html">Portal Header</a></li>
|
||||
<li><a href="./patterns/options-row.html">Options Row</a></li>
|
||||
<li><a href="./patterns/object-card.html">Object Card</a></li>
|
||||
<li><a href="./patterns/scrollable-card.html">Scrollable Card</a></li>
|
||||
<li><a href="./patterns/object-group-card.html">Object Group Card</a></li>
|
||||
<li><a href="./patterns/navigation-card.html">Navigation Card</a></li>
|
||||
<li><a href="./patterns/left-navigation.html">Left Navigation</a></li>
|
||||
@@ -50,6 +51,7 @@
|
||||
<li><a href="./patterns/content-cards-group.html">Content Cards Group</a></li>
|
||||
<li><a href="./patterns/card-gruppe-mit-tastennavigation.html">Card Gruppe mit Tastennavigation</a></li>
|
||||
<li><a href="./patterns/formular-mit-abschnitten.html">Formular mit Abschnitten</a></li>
|
||||
<li><a href="./patterns/file-upload.html">Datei-Upload</a></li>
|
||||
<li><a href="./patterns/multiselektions-pulldown.html">Multiselektions-Pulldown</a></li>
|
||||
<li><a href="./patterns/text-layouts.html">Text Layouts</a></li>
|
||||
<li><a href="./patterns/delete-confirmation-overlay.html">Overlay Card</a></li>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Styleguide – Pattern Datei-Upload</title>
|
||||
<link rel="stylesheet" href="../styleguide.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="sg-main-heading">Pattern – Datei-Upload</h1>
|
||||
<section id="pattern-file-upload">
|
||||
<p class="sg-preview-label">Pattern: Datei-Upload</p>
|
||||
<form class="sg-file-upload" data-pattern="file-upload" action="#" method="post">
|
||||
<div class="sg-file-upload__dropzone sg-labeled-input-row" data-drag-over="false">
|
||||
<label class="sg-label" for="file-upload-input">Datei auswählen</label>
|
||||
<input class="sg-file-input__control" id="file-upload-input" name="documents" type="file" multiple aria-describedby="file-upload-status">
|
||||
<button class="sg-interaction-element sg-button sg-button--active sg-file-upload__trigger" type="button">Dateien auswählen</button>
|
||||
</div>
|
||||
<p class="sg-body sg-file-upload__status" id="file-upload-status" aria-live="polite">Noch keine Dateien ausgewählt.</p>
|
||||
<ul class="sg-file-upload__files" aria-label="Ausgewählte Dateien"></ul>
|
||||
<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive sg-file-upload__submit" type="submit" disabled aria-disabled="true">Hochladen</button>
|
||||
</form>
|
||||
</section>
|
||||
<script>
|
||||
(() => {
|
||||
const upload = document.querySelector('[data-pattern="file-upload"]');
|
||||
if (!upload) return;
|
||||
const input = upload.querySelector('#file-upload-input');
|
||||
const dropzone = upload.querySelector('.sg-file-upload__dropzone');
|
||||
const trigger = upload.querySelector('.sg-file-upload__trigger');
|
||||
const filesList = upload.querySelector('.sg-file-upload__files');
|
||||
const status = upload.querySelector('.sg-file-upload__status');
|
||||
const submit = upload.querySelector('[type="submit"]');
|
||||
let selectedFiles = [];
|
||||
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
const render = () => {
|
||||
filesList.innerHTML = '';
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'sg-file-upload__file';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'sg-file-upload__file-name';
|
||||
name.textContent = file.name;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'sg-file-upload__file-meta';
|
||||
meta.textContent = ` · ${formatSize(file.size)}`;
|
||||
name.append(meta);
|
||||
const remove = document.createElement('button');
|
||||
remove.className = 'sg-interaction-element sg-button sg-button--active sg-file-upload__remove';
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Entfernen';
|
||||
remove.addEventListener('click', () => { selectedFiles.splice(index, 1); render(); });
|
||||
item.append(name, remove);
|
||||
filesList.append(item);
|
||||
});
|
||||
const count = selectedFiles.length;
|
||||
status.textContent = count === 0 ? 'Noch keine Dateien ausgewählt.' : `${count} Datei${count === 1 ? '' : 'en'} ausgewählt.`;
|
||||
submit.disabled = count === 0;
|
||||
submit.setAttribute('aria-disabled', String(count === 0));
|
||||
submit.classList.toggle('sg-button--process-inactive', count === 0);
|
||||
};
|
||||
trigger.addEventListener('click', () => input.click());
|
||||
input.addEventListener('change', () => { selectedFiles = [...selectedFiles, ...Array.from(input.files)]; input.value = ''; render(); });
|
||||
['dragenter', 'dragover'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'true'; }));
|
||||
['dragleave', 'drop'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'false'; }));
|
||||
dropzone.addEventListener('drop', (event) => { selectedFiles = [...selectedFiles, ...Array.from(event.dataTransfer.files)]; render(); });
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Styleguide – Pattern Scrollable Card</title>
|
||||
<link rel="stylesheet" href="../styleguide.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1 class="sg-main-heading">Pattern – Scrollable Card</h1>
|
||||
|
||||
<section id="pattern-scrollable-card">
|
||||
<p class="sg-preview-label">Pattern: Scrollable Card</p>
|
||||
<p class="sg-body">Die Scrollable Card besitzt eine fixe Breite und Höhe. Header und Aktionsbereich bleiben sichtbar; ausschließlich der Inhaltsbereich ist scrollbar. Das Pattern ist unabhängig von Overlay-Patterns und kann bei Bedarf von diesen verwendet werden.</p>
|
||||
|
||||
<article class="sg-card sg-scrollable-card" data-pattern="scrollable-card" aria-label="Scrollable Card">
|
||||
<header class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-scrollable-card__header" data-pattern-part="scrollable-card-header">
|
||||
<div class="sg-strong">Scrollable Card</div>
|
||||
</header>
|
||||
|
||||
<div class="sg-card-segment sg-card-segment--body sg-scrollable-card__content" data-pattern-part="scrollable-card-content" tabindex="0" aria-label="Scrollbarer Karteninhalt">
|
||||
<p class="sg-body">Der Karteninhalt bleibt innerhalb der definierten Kartenhöhe. Bei längeren Inhalten entsteht hier ein eigener vertikaler Scrollbereich, während Titel und Aktionen sichtbar bleiben.</p>
|
||||
<p class="sg-body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer luctus, turpis vel porttitor cursus, nibh justo feugiat sem, sit amet egestas lorem arcu sed augue.</p>
|
||||
<p class="sg-body">Donec sed lorem in urna sagittis aliquet. Suspendisse potenti. Curabitur blandit tempus porttitor. Maecenas faucibus mollis interdum.</p>
|
||||
<p class="sg-body">Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Aenean lacinia bibendum nulla sed consectetur.</p>
|
||||
<p class="sg-body">Zusätzlicher Inhalt zur Demonstration des unabhängigen Scrollbereichs innerhalb der Card.</p>
|
||||
<p class="sg-body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed posuere consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.</p>
|
||||
<p class="sg-body">Maecenas sed diam eget risus varius blandit sit amet non magna. Donec ullamcorper nulla non metus auctor fringilla.</p>
|
||||
<p class="sg-body">Nullam quis risus eget urna mollis ornare vel eu leo. Vestibulum id ligula porta felis euismod semper.</p>
|
||||
<p class="sg-body">Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Etiam porta sem malesuada magna mollis euismod.</p>
|
||||
<p class="sg-body">Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
|
||||
<p class="sg-body">Curabitur blandit tempus porttitor. Donec id elit non mi porta gravida at eget metus. Vestibulum ligula porta felis euismod semper.</p>
|
||||
<p class="sg-body">Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.</p>
|
||||
<p class="sg-body">Cras justo odio, dapibus ac facilisis in, egestas eget quam. Etiam porta sem malesuada magna mollis euismod.</p>
|
||||
<p class="sg-body">Morbi risus porta ac consectetur ac, vestibulum at eros. Sed posuere consectetur est at lobortis.</p>
|
||||
<p class="sg-body">Donec sed odio dui. Aenean lacinia bibendum nulla sed consectetur. Nulla vitae elit libero, a pharetra augue.</p>
|
||||
<p class="sg-body">Vestibulum id ligula porta felis euismod semper. Donec ullamcorper nulla non metus auctor fringilla.</p>
|
||||
<p class="sg-body">Der untere Aktionsbereich bleibt auch am Ende des Scrollvorgangs an seiner Position sichtbar.</p>
|
||||
</div>
|
||||
|
||||
<footer class="sg-card-segment sg-card-segment--body sg-scrollable-card__actions-segment" data-pattern-part="scrollable-card-actions">
|
||||
<div class="sg-scrollable-card__actions">
|
||||
<button class="sg-interaction-element sg-button sg-button--active" type="button">Abbrechen</button>
|
||||
<button class="sg-interaction-element sg-button sg-button--active" type="button">Speichern</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,164 @@
|
||||
(function initTooltipOverlayModule() {
|
||||
const getViewportWidth = () => {
|
||||
if (window.visualViewport && typeof window.visualViewport.width === 'number') {
|
||||
return window.visualViewport.width;
|
||||
}
|
||||
return window.innerWidth;
|
||||
};
|
||||
|
||||
const getPixelValue = (propertyName) => {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const rawValue = rootStyles.getPropertyValue(propertyName).trim();
|
||||
const rootFontSize = parseFloat(rootStyles.fontSize) || 16;
|
||||
const value = parseFloat(rawValue);
|
||||
|
||||
if (Number.isNaN(value)) {
|
||||
return 0;
|
||||
}
|
||||
if (rawValue.endsWith('rem')) {
|
||||
return value * rootFontSize;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const setTooltipState = (wrap, isOpen) => {
|
||||
const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]');
|
||||
const panel = wrap.querySelector('[data-component-part="tooltip-panel"]');
|
||||
if (!trigger || !panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.dataset.tooltipOpen = String(isOpen);
|
||||
panel.hidden = !isOpen;
|
||||
trigger.setAttribute('aria-expanded', String(isOpen));
|
||||
};
|
||||
|
||||
const positionTooltip = (wrap) => {
|
||||
const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]');
|
||||
const panel = wrap.querySelector('[data-component-part="tooltip-panel"]');
|
||||
if (!trigger || !panel || panel.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportWidth = getViewportWidth();
|
||||
const safeInset = getPixelValue('--spacing-large');
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const gap = getPixelValue('--spacing-small');
|
||||
|
||||
let placement = 'bottom';
|
||||
if (triggerRect.right + (panelRect.width / 2) > viewportWidth - safeInset) {
|
||||
placement = 'left';
|
||||
} else if (triggerRect.left - (panelRect.width / 2) < safeInset) {
|
||||
placement = 'right';
|
||||
}
|
||||
|
||||
wrap.dataset.tooltipPlacement = placement;
|
||||
|
||||
if (placement === 'bottom') {
|
||||
const nextPanelRect = panel.getBoundingClientRect();
|
||||
if (nextPanelRect.bottom > window.innerHeight - safeInset
|
||||
&& triggerRect.top - panelRect.height - gap >= safeInset) {
|
||||
wrap.dataset.tooltipPlacement = 'top';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeTooltip = (wrap) => {
|
||||
wrap.dataset.tooltipPinned = 'false';
|
||||
wrap.dataset.tooltipHover = 'false';
|
||||
wrap.dataset.tooltipFocus = 'false';
|
||||
wrap.dataset.tooltipDismissed = 'false';
|
||||
setTooltipState(wrap, false);
|
||||
};
|
||||
|
||||
const openTooltip = (wrap) => {
|
||||
setTooltipState(wrap, true);
|
||||
positionTooltip(wrap);
|
||||
};
|
||||
|
||||
const initTooltip = (wrap) => {
|
||||
if (wrap.dataset.tooltipInit === 'true') {
|
||||
return;
|
||||
}
|
||||
wrap.dataset.tooltipInit = 'true';
|
||||
wrap.dataset.tooltipPlacement = 'bottom';
|
||||
wrap.dataset.tooltipPinned = 'false';
|
||||
wrap.dataset.tooltipHover = 'false';
|
||||
wrap.dataset.tooltipFocus = 'false';
|
||||
wrap.dataset.tooltipDismissed = 'false';
|
||||
setTooltipState(wrap, false);
|
||||
|
||||
const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]');
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.addEventListener('pointerenter', () => {
|
||||
if (wrap.dataset.tooltipDismissed === 'true') {
|
||||
return;
|
||||
}
|
||||
wrap.dataset.tooltipHover = 'true';
|
||||
openTooltip(wrap);
|
||||
});
|
||||
|
||||
wrap.addEventListener('pointerleave', () => {
|
||||
wrap.dataset.tooltipHover = 'false';
|
||||
wrap.dataset.tooltipDismissed = 'false';
|
||||
if (wrap.dataset.tooltipPinned !== 'true' && wrap.dataset.tooltipFocus !== 'true') {
|
||||
setTooltipState(wrap, false);
|
||||
}
|
||||
});
|
||||
|
||||
wrap.addEventListener('focusin', () => {
|
||||
if (wrap.dataset.tooltipDismissed === 'true') {
|
||||
return;
|
||||
}
|
||||
wrap.dataset.tooltipFocus = 'true';
|
||||
openTooltip(wrap);
|
||||
});
|
||||
|
||||
wrap.addEventListener('focusout', (event) => {
|
||||
if (wrap.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
wrap.dataset.tooltipFocus = 'false';
|
||||
wrap.dataset.tooltipDismissed = 'false';
|
||||
if (wrap.dataset.tooltipPinned !== 'true' && wrap.dataset.tooltipHover !== 'true') {
|
||||
setTooltipState(wrap, false);
|
||||
}
|
||||
});
|
||||
|
||||
trigger.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const isPinned = wrap.dataset.tooltipPinned === 'true';
|
||||
wrap.dataset.tooltipPinned = String(!isPinned);
|
||||
|
||||
if (isPinned) {
|
||||
wrap.dataset.tooltipDismissed = 'true';
|
||||
setTooltipState(wrap, false);
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.dataset.tooltipDismissed = 'false';
|
||||
openTooltip(wrap);
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
document.querySelectorAll('.sg-tooltip-wrap').forEach((wrap) => {
|
||||
if (!wrap.contains(event.target)) {
|
||||
closeTooltip(wrap);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const repositionOpenTooltips = () => {
|
||||
document.querySelectorAll('.sg-tooltip-wrap[data-tooltip-open="true"]').forEach(positionTooltip);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', repositionOpenTooltips);
|
||||
window.addEventListener('scroll', repositionOpenTooltips, true);
|
||||
})();
|
||||
@@ -224,6 +224,10 @@
|
||||
<tr><td>text-help-icon</td><td>color-font-dark</td><td>Textfarbe im Help-Icon.</td></tr>
|
||||
<tr><td>text-help-panel</td><td>color-font-dark</td><td>Textfarbe im Help-Panel.</td></tr>
|
||||
<tr><td>layout-help-panel-width</td><td>calc(interaction-height * 8)</td><td>Standardbreite des Help-Panels.</td></tr>
|
||||
<tr><td>surface-tooltip</td><td>color-light-grey</td><td>Fläche der generischen Tooltip-Sprechblase.</td></tr>
|
||||
<tr><td>text-tooltip</td><td>color-font-dark</td><td>Textfarbe der generischen Tooltip-Sprechblase.</td></tr>
|
||||
<tr><td>layout-tooltip-width</td><td>calc(interaction-height * 8)</td><td>Standardbreite der generischen Tooltip-Sprechblase.</td></tr>
|
||||
<tr><td>radius-tooltip</td><td>radius-interaction</td><td>Radius der generischen Tooltip-Sprechblase.</td></tr>
|
||||
<tr><td>icon-sandwich-line-portal</td><td>color-font-dark</td><td>Linienfarbe des Sandwich-Icons für große und kleine Variante.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
<tr><td>surface-object-card-lower-segment</td><td>color-white</td><td>Hintergrundfläche der unteren zwei Segmente der Object Card.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h3 class="sg-sub-heading sg-section-h3">Scrollable Card</h3>
|
||||
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
|
||||
<tr><td>layout-scrollable-card-width</td><td>dimension-scrollable-card-width</td><td>Fixe Breite der Scrollable Card; kann im konsumierenden Portal individuell gesetzt werden.</td></tr>
|
||||
<tr><td>layout-scrollable-card-height</td><td>dimension-scrollable-card-height</td><td>Fixe Höhe der Scrollable Card; kann im konsumierenden Portal individuell gesetzt werden.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h3 class="sg-sub-heading sg-section-h3">Object Group Card</h3>
|
||||
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
|
||||
<tr><td>layout-object-group-card-min-width</td><td>dimension-object-group-card-min-width</td><td>Mindestbreite der einzelnen Karten im Pattern Object Group Card.</td></tr>
|
||||
@@ -142,6 +148,14 @@
|
||||
<tr><td>layout-delete-confirmation-target-dim-opacity</td><td>opacity-delete-confirmation-target-dim</td><td>Deckkraft der grauen Überlagerung auf dem zu löschenden Objekt.</td></tr>
|
||||
<tr><td>layer-delete-confirmation-overlay</td><td>dimension-layer-delete-confirmation-overlay</td><td>Z-Index-Layer des schwebenden Bestätigungsfensters.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h3 class="sg-sub-heading sg-section-h3">Datei-Upload</h3>
|
||||
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
|
||||
<tr><td>surface-file-upload-dropzone</td><td>surface-form-preview</td><td>Hintergrundfläche des auswählbaren Upload-Bereichs.</td></tr>
|
||||
<tr><td>surface-file-upload-file-item</td><td>surface-control-default</td><td>Hintergrundfläche eines Eintrags in der Dateiliste.</td></tr>
|
||||
<tr><td>text-file-upload-meta</td><td>text-control-disabled</td><td>Textfarbe für Dateigröße und ergänzende Dateimetadaten.</td></tr>
|
||||
<tr><td>text-file-upload-error</td><td>color-signal-red</td><td>Textfarbe für Validierungs- und Upload-Fehlermeldungen.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "./styles/02-base.css";
|
||||
@import "./styles/03-typography-helpers.css";
|
||||
@import "./styles/10-components-interactive-elements.css";
|
||||
@import "./styles/47-patterns-file-upload.css";
|
||||
@import "./styles/20-patterns-portal-header.css";
|
||||
@import "./styles/21-patterns-options-row.css";
|
||||
@import "./styles/22-patterns-object-card.css";
|
||||
@@ -26,3 +27,4 @@
|
||||
@import "./styles/43-components-typography.css";
|
||||
@import "./styles/44-components-public-page-components.css";
|
||||
@import "./styles/45-patterns-public-page-patterns.css";
|
||||
@import "./styles/46-patterns-scrollable-card.css";
|
||||
@@ -62,6 +62,7 @@
|
||||
--surface-menu-panel-portal: var(--color-light-grey);
|
||||
--surface-help-icon: var(--color-medium-grey);
|
||||
--surface-help-panel: var(--color-light-grey);
|
||||
--surface-tooltip: var(--color-light-grey);
|
||||
--surface-pulldown-panel: var(--color-light-grey);
|
||||
--surface-activatable-remove: var(--surface-control-default);
|
||||
--surface-slider-track: var(--color-medium-grey);
|
||||
@@ -89,6 +90,8 @@
|
||||
--layout-mode-toggle-local-width-factor: 3;
|
||||
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
|
||||
--layout-help-panel-width: calc(var(--interaction-height) * 8);
|
||||
--layout-tooltip-width: calc(var(--interaction-height) * 8);
|
||||
--radius-tooltip: var(--radius-interaction);
|
||||
--text-control-default: var(--color-font-dark);
|
||||
--text-control-disabled: var(--color-dark-grey);
|
||||
--text-button-process: var(--color-font-light);
|
||||
@@ -98,6 +101,7 @@
|
||||
--text-menu-link-portal: var(--color-font-dark);
|
||||
--text-help-icon: var(--color-font-dark);
|
||||
--text-help-panel: var(--color-font-dark);
|
||||
--text-tooltip: var(--color-font-dark);
|
||||
--text-activatable-remove: var(--text-control-default);
|
||||
--text-hyperlink: var(--color-font-hyperlink);
|
||||
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
|
||||
@@ -175,6 +179,8 @@
|
||||
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
|
||||
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
|
||||
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
|
||||
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
|
||||
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
|
||||
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
|
||||
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
|
||||
--layout-notification-card-width: var(--dimension-notification-card-width);
|
||||
@@ -287,6 +293,8 @@
|
||||
--dimension-object-card-mobile-width: 100%;
|
||||
--dimension-object-card-mobile-height: auto;
|
||||
--dimension-object-card-desktop-breakpoint: 768px;
|
||||
--dimension-scrollable-card-width: 600px;
|
||||
--dimension-scrollable-card-height: 600px;
|
||||
--dimension-content-card-margin-top-desktop: 100px;
|
||||
--dimension-content-card-margin-top-mobile: 1rem;
|
||||
--dimension-object-group-card-min-width: 450px;
|
||||
|
||||
@@ -139,6 +139,13 @@
|
||||
color: var(--text-control-default);
|
||||
}
|
||||
|
||||
.sg-button--warning {
|
||||
--surface-button-warning: var(--color-signal-red);
|
||||
--text-button-warning: var(--color-font-light);
|
||||
background: var(--surface-button-warning);
|
||||
color: var(--text-button-warning);
|
||||
}
|
||||
|
||||
.sg-button--process {
|
||||
background: var(--surface-button-process);
|
||||
color: var(--text-button-process);
|
||||
@@ -1232,6 +1239,95 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sg-tooltip-panel {
|
||||
position: absolute;
|
||||
z-index: var(--layer-pulldown-panel);
|
||||
display: block;
|
||||
width: var(--layout-tooltip-width);
|
||||
max-width: calc(100vw - (2 * var(--spacing-large)));
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--spacing-large);
|
||||
border-radius: var(--radius-tooltip);
|
||||
color: var(--text-tooltip);
|
||||
background: var(--surface-tooltip);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.sg-tooltip-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
|
||||
top: calc(100% + var(--spacing-small));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
|
||||
bottom: calc(100% + var(--spacing-small));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
|
||||
top: 50%;
|
||||
right: calc(100% + var(--spacing-small));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
|
||||
top: 50%;
|
||||
left: calc(100% + var(--spacing-small));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-panel::before {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
|
||||
top: calc(var(--spacing-small) * -1);
|
||||
left: 50%;
|
||||
border-width: 0 var(--spacing-small) var(--spacing-small);
|
||||
border-color: transparent transparent var(--surface-tooltip);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
|
||||
bottom: calc(var(--spacing-small) * -1);
|
||||
left: 50%;
|
||||
border-width: var(--spacing-small) var(--spacing-small) 0;
|
||||
border-color: var(--surface-tooltip) transparent transparent;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
|
||||
top: 50%;
|
||||
right: calc(var(--spacing-small) * -1);
|
||||
border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small);
|
||||
border-color: transparent transparent transparent var(--surface-tooltip);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
|
||||
top: 50%;
|
||||
left: calc(var(--spacing-small) * -1);
|
||||
border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0;
|
||||
border-color: transparent var(--surface-tooltip) transparent transparent;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-pulldown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-small));
|
||||
@@ -1578,3 +1674,52 @@
|
||||
color: var(--text-hyperlink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Component: File Input */
|
||||
.sg-file-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-file-input__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-file-input__control {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sg-file-input:focus-within .sg-file-input__trigger {
|
||||
outline: 2px solid var(--text-hyperlink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sg-file-input__trigger {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sg-file-input__row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.sg-file-input--disabled {
|
||||
opacity: var(--disabled-opacity);
|
||||
}
|
||||
|
||||
.sg-file-input--disabled .sg-file-input__trigger {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* ========================================================= */
|
||||
/* Patterns: Scrollable Card */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-scrollable-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--layout-scrollable-card-width);
|
||||
max-width: 100%;
|
||||
height: var(--layout-scrollable-card-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__header,
|
||||
.sg-scrollable-card__actions-segment {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-small);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions > .sg-button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* ========================================================= */
|
||||
/* Patterns: Datei-Upload */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-file-upload {
|
||||
--surface-file-upload-dropzone: var(--surface-form-preview);
|
||||
--surface-file-upload-file-item: var(--surface-control-default);
|
||||
--text-file-upload-meta: var(--text-control-disabled);
|
||||
--text-file-upload-error: var(--color-signal-red);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
max-width: var(--layout-input-field-max-width);
|
||||
}
|
||||
|
||||
.sg-file-upload__status,
|
||||
.sg-file-upload__file-meta {
|
||||
margin: var(--spacing-small) 0 0;
|
||||
}
|
||||
|
||||
.sg-file-upload__dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
padding: var(--spacing-large);
|
||||
background: var(--surface-file-upload-dropzone);
|
||||
border: 1px dashed var(--surface-button-active);
|
||||
border-radius: var(--radius-interaction);
|
||||
}
|
||||
|
||||
.sg-file-upload__dropzone[data-drag-over="true"] {
|
||||
outline: 2px solid var(--text-hyperlink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sg-file-upload__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-small);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sg-file-upload__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
padding: var(--spacing-small) var(--spacing-large);
|
||||
color: var(--text-control-default);
|
||||
background: var(--surface-file-upload-file-item);
|
||||
border-radius: var(--radius-interaction);
|
||||
}
|
||||
|
||||
.sg-file-upload__file-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sg-file-upload__file-meta {
|
||||
color: var(--text-file-upload-meta);
|
||||
}
|
||||
|
||||
.sg-file-upload__remove {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-file-upload__submit {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.sg-file-upload__error {
|
||||
color: var(--text-file-upload-error);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sg-file-upload__dropzone {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sg-file-upload__file {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-small);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
# Technische Architektur
|
||||
|
||||
Stand: 2026-04-08
|
||||
Status: Verbindlicher Architektur-Master
|
||||
Dokumentklasse: normative
|
||||
|
||||
## 1. Zweck und Geltung
|
||||
|
||||
Dieses Dokument ist die verbindliche Architekturvorgabe fuer die Entwicklung hochmodularer Webportale in diesem Repository. Es steuert Analyse, Implementierung, Refactoring und Review durch Mensch und LLM.
|
||||
|
||||
Verbindliche Betriebsregel:
|
||||
|
||||
- Entwicklung und erste Integration erfolgen lokal auf `feature/<thema>` im lokalen Docker-Stack.
|
||||
- `main` ist der gemeinsame, auf DEV betriebene Integrationsstand. Ein Feature wird erst nach lokaler Verifikation und expliziter Freigabe nach `main` gemergt.
|
||||
- PROD wird ausschliesslich durch das bestehende Release-Skript mit einem expliziten Release-Tag eines in DEV getesteten `main`-Commits ausgerollt und nur nach expliziter User-Freigabe angefasst.
|
||||
|
||||
Es definiert verbindlich:
|
||||
|
||||
- Modul-, Prozess-, Frontend-, API- und Datenbankgrenzen
|
||||
- Ownership und oeffentliche Schnittstellen
|
||||
- Standard-Scope fuer Analyse und Implementierung
|
||||
- Wiederverwendungs- und Redundanzregeln
|
||||
- Regeln fuer neue Module, Submodule und zentrale Bausteine
|
||||
|
||||
Dieses Dokument ist kein Inventar konkreter Tabellen, Prozesse, Dateien oder Runtime-Artefakte. Solche Details liegen in den zustaendigen Modul-, Prozess- oder DB-Vertraegen.
|
||||
|
||||
## 2. Architekturprinzipien
|
||||
|
||||
### 2.1 Module
|
||||
|
||||
Ein Modul ist ein fachlich oder technisch gekapselter Contract, nicht nur eine Verzeichnisstruktur.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- jedes Modul besitzt genau eine klar abgegrenzte Primaerverantwortung
|
||||
- jedes Modul kapselt seine Domain-Logik, Datenzugriffe, externen Schnittstellen, internen Datenstrukturen und Speicherdetails
|
||||
- jedes Modul definiert eine explizite oeffentliche Schnittstelle fuer andere Module
|
||||
- jedes Modul besitzt definierte Owned Artefakte und Schreibrechte
|
||||
- interne Implementierungen anderer Module duerfen nicht direkt genutzt werden
|
||||
- jede Modulgrenze muss als potenzielle spaetere Service-Grenze behandelbar bleiben
|
||||
|
||||
### 2.2 Prozesse
|
||||
|
||||
Ein Prozess ist ein sequenzieller Hauptablauf innerhalb genau eines owning Hauptmoduls.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- ein Hauptmodul besitzt beliebig viele Hauptprozesse; jeder Hauptprozess gehoert genau einem Hauptmodul
|
||||
- ein Hauptprozess darf Submodule desselben Hauptmoduls nutzen
|
||||
- fachliche Hauptprozesse ueber Hauptmodulgrenzen hinweg sind nicht zulaessig
|
||||
- Batching, Parallelisierung, Retry, Resume, Fehlerklassifikation, Statusermittlung und Ergebnisvertrag gehoeren in den owning Prozessvertrag
|
||||
- zusaetzliche harte Schreib-, Feld- oder Datenbankschranken sind nur zulaessig, wenn sie explizit im Prozess- oder DB-Vertrag festgelegt sind
|
||||
|
||||
Verbindliche Kettenarten:
|
||||
|
||||
- asynchrone Prozessketten: ein Startprozess stoesst Folgeprozesse an, diese laufen als eigene Runs weiter
|
||||
- synchrone Prozessketten: Subprozesse haengen direkt aneinander; jeder Schritt stoesst nur den naechsten an
|
||||
- der Kettenmodus muss pro Prozessvertrag explizit festgelegt sein
|
||||
- die Wahl des Kettenmodus erfolgt jeweils nach fachlichem Sinn und Prozessvertrag
|
||||
|
||||
Aktive Prozesswahrheit:
|
||||
|
||||
`docs/modules/<modul>/<submodul>/processes/`
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- Prozess- und Sub-Prozess-Dokumente unter diesem Pfad sind die aktive fachliche Wahrheit
|
||||
- Prozessnomenklatur folgt `component.subcomponent.process_name`
|
||||
- jeder Hauptprozess und jeder Sub-Prozess besitzt genau einen owning Modulpfad
|
||||
- `docs/system_components/` darf fuer aktive Prozessreferenzen nicht verwendet werden
|
||||
|
||||
### 2.3 Implementierung
|
||||
|
||||
Module, Prozesse und Sub-Prozesse sind technisch so scharf wie moeglich zu trennen.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- jedes Modul besitzt eine interne API oder Zugriffsschicht
|
||||
- Cross-Modul-Kommunikation erfolgt nur ueber definierte Modul-Schnittstellen
|
||||
- direkte Zugriffe auf interne Implementierungen oder Daten anderer Module sind verboten
|
||||
- neue APIs im geschuetzten Bereich sind standardmaessig `protected`; die Abo-Pruefung greift vor jeder Datenlieferung
|
||||
- Module, Prozesse und Sub-Prozesse muessen getrennt entwickelbar, testbar, ausrollbar, ueberwachbar, debugbar und optimierbar sein
|
||||
- technische Kopplung zwischen Prozessen und Sub-Prozessen ist auf das notwendige Minimum zu reduzieren
|
||||
- Module duerfen keine impliziten Abhaengigkeiten besitzen, die eine spaetere Extraktion verhindern
|
||||
|
||||
### 2.4 Frontend
|
||||
|
||||
Das Frontend ist bewusst minimalistisch. Ziel ist eine kleine Zahl konsistenter, wiederverwendbarer UI-Bausteine statt vieler seiten- oder feature-spezifischer Sonderkomponenten.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- Seiten und Views komponieren Daten, Layouts und Komponenten, erzeugen aber keine Fachlogik
|
||||
- UI-Komponenten duerfen keine fachliche Primaerlogik, fachliche Dateninterpretation oder eigenstaendige Fachwahrheit besitzen
|
||||
- fachliche UI-Komponenten, fachliche Darstellungsregeln und fachlich interpretierende UI-Logik gehoeren zum owning Fachmodul
|
||||
- fachlich neutrale UI-Komponenten, Layout-Komponenten, Formatierungshelfer, Interaktionsmuster und Frontend-Utilities gehoeren nach `shared`
|
||||
- neue oder geaenderte Frontend-Komponenten muessen zuerst gegen bestehende Komponenten, Patterns und `shared`-Bausteine geprueft werden
|
||||
- Varianten bestehender Komponenten sind ueber Props, Konfiguration oder dokumentierte Erweiterungspunkte umzusetzen, nicht durch Copy-Paste
|
||||
- Sortierung, Filterung und Suche werden serverseitig im owning Modul bzw. in der API umgesetzt; das Frontend rendert nur die Ergebnisse
|
||||
- Karten, Tabellen, Filter, Tabs, Buttons, Statusanzeigen, Ladezustaende, Empty States, Fehlermeldungen und Detailansichten sind als wiederverwendbare Patterns zu behandeln
|
||||
- seitenlokale Sonderkomponenten sind nur zulaessig, wenn sie nachweislich nicht sinnvoll wiederverwendbar sind
|
||||
- API-Responses duerfen frontendnah komponiert sein, aber keine Fachlogik aus dem owning Modul in API oder Frontend verschieben
|
||||
- Seiten mit externen Redirect- oder Callback-Flows muessen Browser-History und Ruecksprungverhalten bewusst behandeln; der verbindliche Detailvertrag liegt zentral unter `docs/modules/shared/ui_foundation/`.
|
||||
|
||||
### 2.4.1 Mehrsprachigkeit
|
||||
|
||||
Mehrsprachigkeit ist ein verbindlicher Teil der Frontend-Architektur und gilt identisch fuer statische Texte, dynamisch gerenderte Texte und interaktive UI-Elemente.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- jeder sichtbare GUI-String muss ueber einen i18n-Key aufgeloest werden, unabhaengig davon, ob er serverseitig, clientseitig oder aus einer API-Komposition gerendert wird
|
||||
- dazu gehoeren insbesondere Navigationslabels, Tab-Titel, Buttontexte, Menuepunkte, Tooltips, Hilfetexte, Platzhalter, Empty States, Ladezustände, Fehlermeldungen, Bestätigungsdialoge, ARIA-Labels und sonstige Screenreader-Texte
|
||||
- source-code-Defaults sind nur Fallback- bzw. Seed-Werte; die Anzeige-Wahrheit wird pro Locale aus dem i18n-Store bezogen
|
||||
- interaktive Elemente muessen sichtbare Beschriftung und funktionalen Wert strikt trennen: der angezeigte Text ist uebersetzbar, der Wert fuer State, Transport, Routing, Filterung und Backend-Verarbeitung bleibt technisch stabil und locale-unabhaengig
|
||||
- Auswahlwerte, Keys, IDs, Data-Attribute, Option-Keys, Tab-Keys und sonstige Steuerungswerte duerfen nicht aus uebersetzten Labels abgeleitet werden
|
||||
- Locale-Wechsel darf ausschliesslich die Anzeigeschicht wechseln; Funktion, Auswahlzustand, Navigation, Filterlogik und Datenuebergabe muessen identisch weiter funktionieren
|
||||
- wenn Text im DOM sichtbar ist, gilt er als GUI-String und unterliegt dieser Regel auch dann, wenn er clientseitig zusammengesetzt wird
|
||||
- zusammengesetzte UI-Texte duerfen keine fachliche Logik enthalten; sie kombinieren nur locale-gebundene Textbausteine mit stabilen technischen Werten
|
||||
|
||||
## 3. Modulmodell
|
||||
|
||||
### 3.1 Zulaessige Modulbereiche
|
||||
|
||||
Fachliche Hauptmodule:
|
||||
|
||||
- `company`
|
||||
- `ratings`
|
||||
- `groups`
|
||||
- `lists`
|
||||
|
||||
Technische Modulbereiche:
|
||||
|
||||
- `system`
|
||||
- `shared`
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- fachliche Hauptmodule besitzen Business-Logik und fachliche Ownership
|
||||
- `system` enthaelt nur technische Runtime-, Start-, Trigger- und Laufzeitlogik
|
||||
- `shared` enthaelt nur fachlich neutrale, wiederverwendbare technische Bausteine
|
||||
- konkrete Submodule, Prozesse, Artefakte und Schnittstellen werden in Modulvertraegen dokumentiert, nicht in diesem Architektur-Master
|
||||
|
||||
### 3.2 `shared`
|
||||
|
||||
`shared` ist der zentrale technische Querschnittsbereich.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- `shared` darf keine Business-Logik, Fachwahrheit oder primaere fachliche Ownership besitzen
|
||||
- `shared` darf Services, Komponenten, Helper, technische Schnittstellen, UI-nahe und API-nahe Bausteine bereitstellen
|
||||
- ein Baustein gehoert nur nach `shared`, wenn er fachlich neutral ist und keine Verantwortung eines Fachmoduls uebernimmt
|
||||
- fachliche Regeln, Dateninterpretation, Statuslogik und Entscheidungen bleiben im owning Fachmodul
|
||||
- technische Querschnittslogik ohne fachlichen Inhalt muss in `shared` oder einem dort dokumentierten zentralen Baustein liegen, sobald Wiederverwendung ueber mehr als eine Stelle absehbar ist
|
||||
|
||||
### 3.3 `admin`
|
||||
|
||||
`admin` ist kein Hauptmodul und besitzt keine fachliche Ownership.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- Admin-Funktionen sind nur Bedienoberflaechen auf bestehende Hauptmodule
|
||||
- Admin-spezifische Fachlogik liegt im owning Hauptmodul
|
||||
- ein separates fachliches Modul `admin` ist nicht zulaessig
|
||||
|
||||
### 3.4 `public/api/`
|
||||
|
||||
`public/api/` ist die HTTP-Service- und Kompositionsschicht des modularen Monolithen.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- `public/api/` besitzt keine fachliche Primaerverantwortung
|
||||
- `public/api/` darf Modul- oder Submodul-Schnittstellen fuer konkrete Frontend-User-Stories zusammensetzen
|
||||
- `public/api/` bleibt duenn und enthaelt nur HTTP-nahe und technische Kompositionslogik
|
||||
- fachliche Ownership darf nicht aus Modulen in `public/api/` wandern
|
||||
|
||||
## 4. Datenbankgrenze
|
||||
|
||||
Die Plattform nutzt aktuell genau eine gemeinsame Datenbank. Diese gemeinsame Datenbank hebt Modul- und Ownership-Grenzen nicht auf.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- jede Tabelle, View, materialisierte View und DB-Funktion besitzt genau eine primaere Ownership
|
||||
- primaere Ownership folgt genau einem Hauptmodul oder einem technischen Shared-Bereich
|
||||
- fachliche Beziehungen sowie Ownership- und Berechtigungsschluessel verwenden stabile interne Entitaets-IDs; veraenderliche Attribute wie E-Mail, Username oder Anzeigename duerfen dafuer nicht verwendet werden
|
||||
- nur das owning Modul definiert Struktur, Write-Logik, Constraints und fachliche Semantik eines DB-Artefakts
|
||||
- direkte Cross-Modul-Writes in fremde Owned Tabellen sind verboten
|
||||
- andere Module duerfen fremde Artefakte nur ueber definierte Schnittstellen des owning Moduls nutzen
|
||||
- physische DB-Splits sind kein aktueller Standard und beduerfen einer expliziten Architekturentscheidung
|
||||
|
||||
Operative DB-Referenz:
|
||||
|
||||
`docs/database/module_db_ownership.md`
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- konkrete DB-Artefaktlisten gehoeren in die DB-Ownership-Dokumentation, nicht in diesen Architektur-Master
|
||||
- bei Analyse, Refactoring und Implementierung wird der DB-Scope zuerst ueber Modul plus Owned DB-Artefakte bestimmt
|
||||
- fremde DB-Artefakte duerfen nur bei deklarierter Leseabhaengigkeit, FK-Beziehung oder Integrationspruefung in den Scope aufgenommen werden
|
||||
|
||||
## 5. Dokumentation und Lesescope
|
||||
|
||||
Aktive Dokumentation ist komponentennah.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- aktive Modul- und Submodul-Dokumentation liegt unter `docs/modules/<modul>/...`
|
||||
- aktive technische Shared-Dokumentation liegt unter `docs/modules/shared/...`
|
||||
- technische Runtime-Dokumentation liegt unter `docs/modules/system/...`
|
||||
- fachliche Hauptprozesse liegen innerhalb des owning Modulbaums
|
||||
- historische oder abgeloeste Dokumentation liegt ausserhalb des aktiven Modulbaums
|
||||
- `docs/technical_architecture.md` bleibt der einzige aktive Architektur-Master
|
||||
|
||||
Standard-Lesescope:
|
||||
|
||||
- Arbeit an einem Modul: `docs/technical_architecture.md` plus `docs/modules/<modul>/...`
|
||||
- Arbeit an einem Submodul: `docs/technical_architecture.md` plus `docs/modules/<modul>/<submodul>/...`
|
||||
- `docs/modules/shared/...`: nur bei betroffener Shared-Schnittstelle oder moeglicher Wiederverwendung
|
||||
- `docs/modules/system/...`: nur bei Runtime-, Queue-, Scheduler-, Trigger- oder Supervisor-Themen
|
||||
- andere Module: nur bei explizitem Integrationsbedarf
|
||||
|
||||
## 6. Wiederverwendung und Redundanzverbot
|
||||
|
||||
Vor jeder Aenderung an App, Frontend, UI-Komponenten, UI-Patterns, API, Prozesslogik, Datenzugriff, Infrastruktur oder Dokumentation ist Wiederverwendung vor Neuerstellung zu pruefen.
|
||||
|
||||
Ziel ist eine Architektur ohne redundante Fachlogik, technische Inselloesungen, doppelte UI-Komponenten oder mehrfach gepflegte Varianten desselben Musters.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- zuerst bestehende Muster, Komponenten, Services, Modul-Schnittstellen und `shared`-Bausteine pruefen
|
||||
- vorhandene passende Muster muessen wiederverwendet, erweitert oder als zentrale Schnittstelle bereitgestellt werden
|
||||
- fachliche Inhalte gehoeren immer in das owning Fachmodul und werden von dort ueber definierte Schnittstellen bereitgestellt
|
||||
- fachliche Logik darf nicht in `shared`, `public/api/`, `admin`, Skripte oder fachfremde Bereiche ausgelagert oder dupliziert werden
|
||||
- fachlich neutrale technische Logik gehoert nach `shared`, sofern sie wiederverwendbar ist oder Wiederverwendung absehbar ist
|
||||
- Frontend-Darstellung, Interaktionslogik, Layout, Formatierung und Zustandsdarstellung sind als bestehende oder neue zentrale UI-Komponenten beziehungsweise UI-Patterns in `shared` zu pruefen
|
||||
- neue `shared`-Bausteine sind nur zulaessig, wenn sie keine Fachwahrheit enthalten, keine fachliche Primaerverantwortung uebernehmen und wiederverwendbar sind
|
||||
- technische Querschnittsbausteine duerfen Module entkoppeln, aber keine fachlichen Entscheidungen aus owning Modulen herausziehen
|
||||
- neue Loesungen sind nur zulaessig, wenn kein passendes bestehendes Muster, keine passende Modul-Schnittstelle und kein geeigneter `shared`-Baustein existiert
|
||||
- Quickfixes, Workarounds, Copy-Paste-Varianten, seitenlokale UI-Duplikate und isolierte Sonderlogik fuer Einzelstellen sind verboten
|
||||
- Redundanzvermeidung muss bei Analyse, Umsetzung, Review und Refactoring aktiv nachgewiesen werden koennen
|
||||
|
||||
## 7. Verbotene Strukturen
|
||||
|
||||
Nicht zulaessig sind:
|
||||
|
||||
- Module als reine Ordner ohne Contract
|
||||
- direkte Cross-Modul-Zugriffe auf DB-Tabellen, interne Implementierungen oder Daten anderer Module
|
||||
- versteckte oder implizite Modulabhaengigkeiten
|
||||
- Vermischung mehrerer Primaerverantwortungen innerhalb eines Moduls
|
||||
- parallele Fachlogik ausserhalb des owning Moduls
|
||||
- redundante fachliche, technische oder UI-Loesungen, obwohl ein bestehendes Modul, eine Schnittstelle, ein Pattern oder ein `shared`-Baustein erweitert werden koennte
|
||||
- neue generische Frameworks oder Orchestrator-Umbauten ohne explizite Architekturentscheidung
|
||||
- implizite Schutz-, Schreib- oder Datenbankschranken ohne Prozess- oder DB-Vertrag
|
||||
|
||||
## 8. Erweiterungsregeln
|
||||
|
||||
Neue Hauptmodule, Submodule oder zentrale Bausteine duerfen nur eingefuehrt werden, wenn bestehende Strukturen die Verantwortung nicht sauber tragen koennen.
|
||||
|
||||
Prueffragen vor jeder Erweiterung:
|
||||
|
||||
1. Welches bestehende Hauptmodul ist primaer betroffen?
|
||||
2. Reicht eine neue Datei, ein neues Submodul oder eine neue Schnittstelle im owning Hauptmodul aus?
|
||||
3. Handelt es sich um fachlich neutrale Wiederverwendung, die nach `shared` gehoert?
|
||||
4. Entsteht wirklich eine neue Primaerverantwortung mit eigenen Owned Artefakten und Schreibrechten?
|
||||
5. Bleibt der Handshake zum Rest des Systems auf minimale Scope-Identifier begrenzt?
|
||||
6. Reduziert der neue Schnitt Kopplung und Redundanz, statt sie zu erhoehen?
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- neue Hauptmodule sind nur zulaessig, wenn eine neue fachliche oder technische Primaerverantwortung entsteht, die nicht sauber in `company`, `ratings`, `groups`, `lists`, `system` oder `shared` aufgeht
|
||||
- neue Submodule sind nur zulaessig, wenn innerhalb eines bestehenden Hauptmoduls eine klar isolierbare Verantwortung mit eigener Schnittstelle entsteht
|
||||
- neue Prozess- und Sub-Prozess-Dokumente werden im owning Modulpfad angelegt und folgen `component.subcomponent.process_name`
|
||||
- `admin`, `public/api/`, `scripts/` oder `lib/` begruenden kein neues fachliches Hauptmodul
|
||||
- Bequemlichkeit, Dateigroesse oder kurzfristiger Umbauaufwand rechtfertigen kein neues Hauptmodul
|
||||
- vor jeder normativen Architektur- oder Modulerweiterung muss dieses Dokument aktualisiert werden
|
||||
|
||||
## 9. Aenderungsregel
|
||||
|
||||
Jede strukturelle Aenderung an Hauptmodulen, Modulgrenzen, Prozesszuordnungen, Frontend-Prinzipien, API-Grenzen, DB-Ownership oder primaeren Ownership-Prinzipien muss zuerst in diesem Dokument normativ festgelegt und danach in Detaildokumenten nachgezogen werden.
|
||||
|
||||
Verbindlich ist:
|
||||
|
||||
- `docs/technical_architecture.md` ist der einzige aktive Architektur-Master des Repositories
|
||||
- andere Dokumente duerfen diese Architektur erklaeren, anwenden oder historisieren, aber keine konkurrierende Master-Struktur definieren
|
||||
- konkrete Inventare wie Tabellenlisten, Submodullisten, Prozesslisten oder Runtime-Artefakte werden in Detaildokumenten gepflegt und nicht hier dupliziert
|
||||
|
||||
## 10. Projektspezifische Architekturvorgaben
|
||||
|
||||
Dieses Kapitel enthaelt Architekturentscheidungen, die fuer dieses Projekt
|
||||
gelten und nicht Teil der allgemeinen, projektuebergreifend wiederverwendbaren
|
||||
Modularitaet sind.
|
||||
|
||||
### 10.1 Fachliches Hauptmodul `market_management`
|
||||
|
||||
Dieses Projekt besitzt zusaetzlich das fachliche Hauptmodul
|
||||
`market_management`. Seine fachliche Ownership, Datenstruktur und
|
||||
Schnittstellen werden in den Modulvertraegen unter
|
||||
`docs/modules/market_management/` dokumentiert.
|
||||
|
||||
### 10.2 GUI-/MCP-Paritaet
|
||||
|
||||
Die MCP-Schnittstelle ist im Grundsatz funktional paritaetisch zur GUI. Ein
|
||||
MCP-Nutzer ist hinsichtlich der fachlichen Nutzung einem GUI-Nutzer
|
||||
gleichzustellen.
|
||||
|
||||
Verbindliche Regel:
|
||||
|
||||
- jede fachliche Funktion, die technisch ueber das GUI-Interface verfuegbar
|
||||
ist, muss auch ueber die MCP-Schnittstelle ausfuehrbar sein
|
||||
- die Paritaet bezieht sich auf die technischen Moeglichkeiten des Interfaces,
|
||||
nicht nur auf aktuell im GUI dargestellte Funktionen; technisch vorhandene,
|
||||
im GUI nicht angezeigte Funktionen muessen trotzdem ueber MCP verfuegbar sein
|
||||
- alle fachlichen Lese-, Anlege-, Aenderungs-, Archivierungs- und
|
||||
Reaktivierungsmoeglichkeiten des GUI-Interfaces muessen im MCP-Interface
|
||||
abgebildet sein
|
||||
- GUI und MCP verwenden dieselben fachlichen Validierungen, Berechtigungen,
|
||||
Statusregeln und Ownership-Grenzen; fachliche Logik darf nicht doppelt in
|
||||
GUI und MCP implementiert werden
|
||||
- jede Erweiterung oder Aenderung eines GUI-Anwendungsfalls erfordert zwingend
|
||||
die gleichzeitige Pruefung und, falls erforderlich, Anpassung des MCP-Interfaces
|
||||
- ein MCP-Nutzer darf gegenueber einem gleich berechtigten GUI-Nutzer weder
|
||||
fachlich eingeschraenkt noch durch abweichende Regeln bevorzugt werden
|
||||
- die Paritaet betrifft die fachliche Funktion und den Datenzugriff, nicht die
|
||||
visuelle Darstellung oder den Interaktionsablauf der GUI
|
||||
@@ -129,10 +129,20 @@ function sellable_item_exists(PDO $pdo, int $sellableItemId): bool
|
||||
function find_sellable_item_for_product(PDO $pdo, int $productId): ?int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT sellable_item_id
|
||||
FROM sellable_item_component
|
||||
WHERE product_id = :product_id
|
||||
ORDER BY id
|
||||
'SELECT sic.sellable_item_id
|
||||
FROM sellable_item_component sic
|
||||
INNER JOIN sellable_item si ON si.id = sic.sellable_item_id
|
||||
INNER JOIN product p ON p.id = sic.product_id
|
||||
WHERE sic.product_id = :product_id
|
||||
AND sic.qty_per_item = 1
|
||||
AND si.item_code = p.sku || \'.01\'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sellable_item_component other_sic
|
||||
WHERE other_sic.sellable_item_id = sic.sellable_item_id
|
||||
AND other_sic.id <> sic.id
|
||||
)
|
||||
ORDER BY sic.id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':product_id' => $productId]);
|
||||
|
||||
@@ -146,10 +146,12 @@ function create_direct_sales_order(PDO $pdo, array $fields): array
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO sales_order (
|
||||
id, external_ref, party_id, order_source, order_date, order_status, payment_status, payment_method_id,
|
||||
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, imported_at, created_at, updated_at
|
||||
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency,
|
||||
market_occurrence_id, market_occurrence_day_id, imported_at, created_at, updated_at
|
||||
) VALUES (
|
||||
:id, :external_ref, :party_id, \'direct\', :order_date, \'imported\', \'paid\', :payment_method_id,
|
||||
:amount_net, 0, 0, 0, :total_amount, \'CHF\', NOW(), NOW(), NOW()
|
||||
:amount_net, 0, 0, 0, :total_amount, \'CHF\',
|
||||
:market_occurrence_id, :market_occurrence_day_id, NOW(), NOW(), NOW()
|
||||
)
|
||||
RETURNING id, external_ref'
|
||||
);
|
||||
@@ -161,6 +163,8 @@ function create_direct_sales_order(PDO $pdo, array $fields): array
|
||||
':payment_method_id' => $fields[':payment_method_id'] ?? null,
|
||||
':amount_net' => $fields[':amount_net'] ?? null,
|
||||
':total_amount' => $fields[':total_amount'] ?? null,
|
||||
':market_occurrence_id' => $fields[':market_occurrence_id'] ?? null,
|
||||
':market_occurrence_day_id' => $fields[':market_occurrence_day_id'] ?? null,
|
||||
]);
|
||||
|
||||
$row = $stmt->fetch();
|
||||
@@ -374,6 +378,150 @@ function get_sales_order_overview(PDO $pdo, array $filters = []): array
|
||||
];
|
||||
}
|
||||
|
||||
function get_sales_order_detail(PDO $pdo, int $orderId): ?array
|
||||
{
|
||||
$orderStmt = $pdo->prepare(
|
||||
"SELECT
|
||||
so.id,
|
||||
so.external_ref,
|
||||
so.order_date,
|
||||
so.order_source,
|
||||
so.order_status,
|
||||
so.payment_status,
|
||||
so.amount_net,
|
||||
so.amount_shipping,
|
||||
so.amount_tax,
|
||||
so.amount_discount,
|
||||
so.total_amount,
|
||||
so.currency,
|
||||
COALESCE(ad.first_name, '') AS first_name,
|
||||
COALESCE(ad.last_name, '') AS last_name,
|
||||
COALESCE(ad.street, '') AS street,
|
||||
COALESCE(ad.house_number, '') AS house_number,
|
||||
COALESCE(ad.zip, '') AS zip,
|
||||
COALESCE(ad.city, '') AS city,
|
||||
COALESCE(ad.country_name, '') AS country_name,
|
||||
COALESCE(pm.label, '') AS payment_method,
|
||||
COALESCE(sm.label, '') AS shipping_method
|
||||
FROM sales_order so
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT a.first_name, a.last_name, a.street, a.house_number, a.zip, a.city, a.country_name
|
||||
FROM address a
|
||||
WHERE a.party_id = so.party_id AND a.type IN ('shipping', 'billing')
|
||||
ORDER BY CASE WHEN a.type = 'shipping' THEN 0 ELSE 1 END, a.id DESC
|
||||
LIMIT 1
|
||||
) ad ON TRUE
|
||||
LEFT JOIN payment_method pm ON pm.id = so.payment_method_id
|
||||
LEFT JOIN shipping_method sm ON sm.id = so.shipping_method_id
|
||||
WHERE so.id = :order_id"
|
||||
);
|
||||
$orderStmt->execute([':order_id' => $orderId]);
|
||||
$order = $orderStmt->fetch();
|
||||
if (!is_array($order)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lineStmt = $pdo->prepare(
|
||||
"SELECT
|
||||
sol.id,
|
||||
sol.line_no,
|
||||
sol.sellable_item_id,
|
||||
COALESCE(si.item_code, sol.raw_external_article_number, '') AS item_code,
|
||||
COALESCE(si.display_name, sol.raw_external_title, '') AS item_name,
|
||||
COALESCE(sol.raw_external_article_number, '') AS raw_article_number,
|
||||
COALESCE(sol.raw_external_title, '') AS raw_title,
|
||||
sol.sku,
|
||||
sol.qty,
|
||||
sol.qty_cancelled,
|
||||
sol.line_status,
|
||||
sol.unit_price,
|
||||
sol.line_total,
|
||||
sic.product_id,
|
||||
p.name AS product_name,
|
||||
sic.qty_per_item,
|
||||
a.id AS allocation_id,
|
||||
a.qty AS allocated_qty,
|
||||
a.allocation_status,
|
||||
sl.lot_number,
|
||||
sl.status AS lot_status
|
||||
FROM sales_order_line sol
|
||||
LEFT JOIN sellable_item si ON si.id = sol.sellable_item_id
|
||||
LEFT JOIN sellable_item_component sic ON sic.sellable_item_id = sol.sellable_item_id
|
||||
LEFT JOIN product p ON p.id = sic.product_id
|
||||
LEFT JOIN sales_order_line_lot_allocation a
|
||||
ON a.sales_order_line_id = sol.id AND a.product_id = sic.product_id
|
||||
LEFT JOIN stock_lot sl ON sl.id = a.lot_id
|
||||
WHERE sol.sales_order_id = :order_id
|
||||
ORDER BY sol.line_no, p.name NULLS LAST, a.id"
|
||||
);
|
||||
$lineStmt->execute([':order_id' => $orderId]);
|
||||
|
||||
$lines = [];
|
||||
foreach ($lineStmt->fetchAll() as $row) {
|
||||
$lineId = (int) $row['id'];
|
||||
if (!isset($lines[$lineId])) {
|
||||
$lines[$lineId] = [
|
||||
'id' => $lineId,
|
||||
'line_no' => (int) $row['line_no'],
|
||||
'item_code' => (string) $row['item_code'],
|
||||
'item_name' => (string) $row['item_name'],
|
||||
'raw_article_number' => (string) $row['raw_article_number'],
|
||||
'raw_title' => (string) $row['raw_title'],
|
||||
'sku' => (string) ($row['sku'] ?? ''),
|
||||
'qty' => (float) $row['qty'],
|
||||
'qty_cancelled' => (float) $row['qty_cancelled'],
|
||||
'line_status' => (string) $row['line_status'],
|
||||
'unit_price' => $row['unit_price'] !== null ? (float) $row['unit_price'] : null,
|
||||
'line_total' => $row['line_total'] !== null ? (float) $row['line_total'] : null,
|
||||
'product_allocations' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($row['product_id'] !== null) {
|
||||
$lines[$lineId]['product_allocations'][] = [
|
||||
'product_id' => (int) $row['product_id'],
|
||||
'product_name' => (string) $row['product_name'],
|
||||
'requested_qty' => (float) $row['qty'] * (float) $row['qty_per_item'],
|
||||
'allocated_qty' => $row['allocated_qty'] !== null ? (float) $row['allocated_qty'] : null,
|
||||
'allocation_status' => $row['allocation_status'] !== null ? (string) $row['allocation_status'] : null,
|
||||
'lot_number' => $row['lot_number'] !== null ? (string) $row['lot_number'] : null,
|
||||
'lot_status' => $row['lot_status'] !== null ? (string) $row['lot_status'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$detail = [
|
||||
'id' => (int) $order['id'],
|
||||
'external_ref' => (string) $order['external_ref'],
|
||||
'order_date' => (string) $order['order_date'],
|
||||
'order_source' => (string) $order['order_source'],
|
||||
'order_status' => (string) $order['order_status'],
|
||||
'payment_status' => (string) $order['payment_status'],
|
||||
'payment_method' => (string) $order['payment_method'],
|
||||
'shipping_method' => (string) $order['shipping_method'],
|
||||
'customer' => [
|
||||
'first_name' => (string) $order['first_name'],
|
||||
'last_name' => (string) $order['last_name'],
|
||||
'street' => (string) $order['street'],
|
||||
'house_number' => (string) $order['house_number'],
|
||||
'zip' => (string) $order['zip'],
|
||||
'city' => (string) $order['city'],
|
||||
'country_name' => (string) $order['country_name'],
|
||||
],
|
||||
'amounts' => [
|
||||
'net' => $order['amount_net'] !== null ? (float) $order['amount_net'] : null,
|
||||
'shipping' => $order['amount_shipping'] !== null ? (float) $order['amount_shipping'] : null,
|
||||
'tax' => $order['amount_tax'] !== null ? (float) $order['amount_tax'] : null,
|
||||
'discount' => $order['amount_discount'] !== null ? (float) $order['amount_discount'] : null,
|
||||
'total' => $order['total_amount'] !== null ? (float) $order['total_amount'] : null,
|
||||
'currency' => (string) $order['currency'],
|
||||
],
|
||||
'lines' => array_values($lines),
|
||||
];
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
function get_sales_order_realtime_snapshot(PDO $pdo, int $limit = 1000): array
|
||||
{
|
||||
$limit = max(1, min(2000, $limit));
|
||||
|
||||
@@ -30,7 +30,7 @@ if (!is_array($data)) {
|
||||
json_response(400, ['ok' => false, 'error' => 'JSON object expected']);
|
||||
}
|
||||
|
||||
$required = ['products', 'totalPrice', 'orderDate', 'payments', 'billing'];
|
||||
$required = ['products', 'orderDate', 'payments', 'billing'];
|
||||
foreach ($required as $field) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
json_response(422, ['ok' => false, 'error' => "Missing field: {$field}"]);
|
||||
@@ -38,10 +38,23 @@ foreach ($required as $field) {
|
||||
}
|
||||
|
||||
$products = $data['products'];
|
||||
$totalPrice = parse_number($data['totalPrice']);
|
||||
$orderDate = trim((string) $data['orderDate']);
|
||||
$payments = $data['payments'];
|
||||
$billing = is_array($data['billing']) ? $data['billing'] : [];
|
||||
$marketOccurrenceId = $data['marketOccurrenceId'] ?? null;
|
||||
$marketOccurrenceDayId = $data['marketOccurrenceDayId'] ?? null;
|
||||
|
||||
if (($marketOccurrenceId === null) !== ($marketOccurrenceDayId === null)) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Markt und Markttag müssen gemeinsam angegeben werden.']);
|
||||
}
|
||||
|
||||
if ($marketOccurrenceId !== null && (!ctype_digit((string) $marketOccurrenceId) || (int) $marketOccurrenceId <= 0)) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Ungültiger Markt.']);
|
||||
}
|
||||
|
||||
if ($marketOccurrenceDayId !== null && (!ctype_digit((string) $marketOccurrenceDayId) || (int) $marketOccurrenceDayId <= 0)) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Ungültiger Markttag.']);
|
||||
}
|
||||
|
||||
if (!is_array($products) || count($products) === 0) {
|
||||
json_response(422, ['ok' => false, 'error' => 'No products specified']);
|
||||
@@ -51,10 +64,6 @@ if (!is_array($payments) || count($payments) === 0) {
|
||||
json_response(422, ['ok' => false, 'error' => 'No payment methods specified']);
|
||||
}
|
||||
|
||||
if ($totalPrice === null || $totalPrice <= 0) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Invalid total price']);
|
||||
}
|
||||
|
||||
$orderDateTime = DateTimeImmutable::createFromFormat('!Y-m-d', $orderDate);
|
||||
$orderDateErrors = DateTimeImmutable::getLastErrors();
|
||||
if ($orderDateTime === false) {
|
||||
@@ -109,15 +118,38 @@ foreach ($payments as $payment) {
|
||||
$paymentTotal = round($paymentTotal + (float) $amount, 2);
|
||||
}
|
||||
|
||||
if (abs($paymentTotal - (float) $totalPrice) > 0.005) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Payment amounts must equal total price']);
|
||||
if ($paymentTotal <= 0) {
|
||||
json_response(422, ['ok' => false, 'error' => 'Payment total must be greater than zero']);
|
||||
}
|
||||
|
||||
$totalPrice = $paymentTotal;
|
||||
|
||||
try {
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
$pdo->beginTransaction();
|
||||
|
||||
if ($marketOccurrenceId !== null) {
|
||||
$marketLinkStmt = $pdo->prepare(
|
||||
'SELECT 1
|
||||
FROM market_occurrence o
|
||||
INNER JOIN market_definition m ON m.id = o.market_definition_id
|
||||
INNER JOIN market_occurrence_day d ON d.occurrence_id = o.id
|
||||
WHERE o.id = :occurrence_id
|
||||
AND d.id = :day_id
|
||||
AND m.archived_at IS NULL
|
||||
AND o.archived_at IS NULL
|
||||
LIMIT 1'
|
||||
);
|
||||
$marketLinkStmt->execute([
|
||||
':occurrence_id' => (int) $marketOccurrenceId,
|
||||
':day_id' => (int) $marketOccurrenceDayId,
|
||||
]);
|
||||
if ($marketLinkStmt->fetchColumn() === false) {
|
||||
throw new InvalidArgumentException('Der Markt oder der Markttag ist nicht aktiv oder nicht miteinander verknüpft.');
|
||||
}
|
||||
}
|
||||
|
||||
$locations = get_default_location_ids($pdo);
|
||||
|
||||
$paymentMethodIds = [];
|
||||
@@ -138,6 +170,8 @@ try {
|
||||
':amount_net' => $totalPrice,
|
||||
':total_amount' => $totalPrice,
|
||||
':order_date' => $orderDateTime->format('Y-m-d H:i:s'),
|
||||
':market_occurrence_id' => $marketOccurrenceId === null ? null : (int) $marketOccurrenceId,
|
||||
':market_occurrence_day_id' => $marketOccurrenceDayId === null ? null : (int) $marketOccurrenceDayId,
|
||||
]);
|
||||
|
||||
$orderId = (int) $order['id'];
|
||||
@@ -224,7 +258,16 @@ try {
|
||||
'excelTrigger' => $excelTrigger,
|
||||
'message' => 'OTC order created successfully',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
json_response(422, [
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
@@ -33,6 +33,77 @@ function shopify_delta_start_run(PDO $pdo, string $cutoff, bool $dryRun): int
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function shopify_delta_reconciliation_cutoff(PDO $pdo): string
|
||||
{
|
||||
$stateStmt = $pdo->prepare(
|
||||
'SELECT last_successful_cutoff
|
||||
FROM shopify_delta_sync_state
|
||||
WHERE process_name = :process_name'
|
||||
);
|
||||
$stateStmt->execute([':process_name' => 'erp.import-integration.shopify_delta_order_import']);
|
||||
$state = $stateStmt->fetchColumn();
|
||||
if ($state === false || trim((string) $state) === '') {
|
||||
throw new RuntimeException('Shopify-Delta-Wasserstand fehlt');
|
||||
}
|
||||
|
||||
$pendingStmt = $pdo->query(
|
||||
"SELECT MIN(received_at)
|
||||
FROM shopify_webhook_event
|
||||
WHERE status IN ('received', 'processing', 'failed')
|
||||
AND shopify_order_gid IS NOT NULL"
|
||||
);
|
||||
$pending = $pendingStmt->fetchColumn();
|
||||
if ($pending === false || $pending === null) {
|
||||
return (string) $state;
|
||||
}
|
||||
|
||||
$stateDate = new DateTimeImmutable((string) $state);
|
||||
$pendingDate = new DateTimeImmutable((string) $pending);
|
||||
return ($pendingDate < $stateDate ? $pendingDate : $stateDate)->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
function shopify_delta_load_pending_order_gids(PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT DISTINCT shopify_order_gid
|
||||
FROM shopify_webhook_event
|
||||
WHERE status IN ('received', 'processing', 'failed')
|
||||
AND shopify_order_gid IS NOT NULL
|
||||
ORDER BY shopify_order_gid"
|
||||
);
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $value): string => trim((string) $value),
|
||||
$stmt->fetchAll(PDO::FETCH_COLUMN)
|
||||
), static fn (string $value): bool => $value !== ''));
|
||||
}
|
||||
|
||||
function shopify_delta_mark_order_events_processed(PDO $pdo, string $orderGid): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE shopify_webhook_event
|
||||
SET status = 'processed', processed_at = NOW(), error_code = NULL, error_message = NULL
|
||||
WHERE shopify_order_gid = :shopify_order_gid
|
||||
AND status IN ('received', 'processing', 'failed')"
|
||||
);
|
||||
$stmt->execute([':shopify_order_gid' => $orderGid]);
|
||||
}
|
||||
|
||||
function shopify_delta_store_watermark(PDO $pdo, string $cutoff): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE shopify_delta_sync_state
|
||||
SET last_successful_cutoff = :cutoff, updated_at = NOW()
|
||||
WHERE process_name = :process_name'
|
||||
);
|
||||
$stmt->execute([
|
||||
':cutoff' => $cutoff,
|
||||
':process_name' => 'erp.import-integration.shopify_delta_order_import',
|
||||
]);
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
throw new RuntimeException('Shopify-Delta-Wasserstand konnte nicht gespeichert werden');
|
||||
}
|
||||
}
|
||||
|
||||
function shopify_delta_finish_run(PDO $pdo, int $runId, string $status, array $result, ?Throwable $error = null): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
@@ -75,7 +146,7 @@ GQL;
|
||||
return $orders;
|
||||
}
|
||||
|
||||
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun): array
|
||||
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, array $recoveryOrderGids = []): array
|
||||
{
|
||||
$cutoffDate = new DateTimeImmutable($cutoff);
|
||||
$searchQuery = 'created_at:>' . $cutoffDate->format('Y-m-d');
|
||||
@@ -90,14 +161,31 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun):
|
||||
'clarification_order_gids' => [],
|
||||
];
|
||||
|
||||
$ordersByGid = [];
|
||||
foreach (shopify_delta_load_orders($env, $searchQuery) as $summaryOrder) {
|
||||
$orderGid = trim((string) ($summaryOrder['id'] ?? ''));
|
||||
if ($orderGid !== '') {
|
||||
$ordersByGid[$orderGid] = $summaryOrder;
|
||||
}
|
||||
}
|
||||
foreach ($recoveryOrderGids as $recoveryOrderGid) {
|
||||
if (!isset($ordersByGid[$recoveryOrderGid])) {
|
||||
$ordersByGid[$recoveryOrderGid] = shopify_load_order_by_gid($env, $recoveryOrderGid);
|
||||
}
|
||||
}
|
||||
|
||||
$recoverySet = array_fill_keys($recoveryOrderGids, true);
|
||||
foreach ($ordersByGid as $orderGid => $summaryOrder) {
|
||||
$createdAt = new DateTimeImmutable((string) ($summaryOrder['createdAt'] ?? ''));
|
||||
if ($createdAt <= $cutoffDate) {
|
||||
if (!isset($recoverySet[$orderGid]) && $createdAt <= $cutoffDate) {
|
||||
continue;
|
||||
}
|
||||
$counters['checked_orders']++;
|
||||
if (($summaryOrder['cancelledAt'] ?? null) !== null || strtoupper((string) ($summaryOrder['displayFinancialStatus'] ?? '')) === 'REFUNDED') {
|
||||
$counters['skipped_cancelled_or_refunded']++;
|
||||
if (!$dryRun) {
|
||||
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -121,8 +209,14 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun):
|
||||
}
|
||||
if (($result['status'] ?? '') === 'already_imported') {
|
||||
$counters['already_imported']++;
|
||||
if (!$dryRun && isset($recoverySet[(string) $summaryOrder['id']])) {
|
||||
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
|
||||
}
|
||||
} else {
|
||||
$counters['handed_off']++;
|
||||
if (!$dryRun) {
|
||||
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $error) {
|
||||
$counters['status'] = 'partial_success';
|
||||
|
||||
@@ -132,19 +132,21 @@ function shopify_webhook_worker_run(PDO $pdo, array $env, int $limit): array
|
||||
}
|
||||
}
|
||||
|
||||
$argvLimit = 20;
|
||||
foreach ($argv as $argument) {
|
||||
if (str_starts_with($argument, '--limit=')) {
|
||||
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
|
||||
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
||||
$argvLimit = 20;
|
||||
foreach ($argv as $argument) {
|
||||
if (str_starts_with($argument, '--limit=')) {
|
||||
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
exit(0);
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
exit(0);
|
||||
} catch (Throwable $error) {
|
||||
fwrite(STDERR, $error->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -103,3 +103,180 @@ 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 get_party_reference(PDO $pdo, int $partyId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, name, email, phone
|
||||
FROM party
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([':id' => $partyId]);
|
||||
$party = $stmt->fetch();
|
||||
|
||||
return is_array($party) ? $party : null;
|
||||
}
|
||||
|
||||
function get_contact_reference(PDO $pdo, int $contactId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, party_id, first_name, last_name, email, phone, position_title
|
||||
FROM contact
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([':id' => $contactId]);
|
||||
$contact = $stmt->fetch();
|
||||
|
||||
return is_array($contact) ? $contact : null;
|
||||
}
|
||||
|
||||
function get_party_options(PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, name
|
||||
FROM party
|
||||
WHERE status = 'active'
|
||||
ORDER BY name ASC, id ASC"
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): array => ['id' => (int) $row['id'], 'name' => (string) ($row['name'] ?? '')],
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
function get_contact_options(PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT c.id, c.party_id, c.first_name, c.last_name, p.name AS party_name
|
||||
FROM contact c
|
||||
INNER JOIN party p ON p.id = c.party_id
|
||||
WHERE p.status = 'active'
|
||||
ORDER BY p.name ASC, c.last_name ASC, c.first_name ASC, c.id ASC"
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): array => [
|
||||
'id' => (int) $row['id'],
|
||||
'party_id' => (int) $row['party_id'],
|
||||
'first_name' => (string) ($row['first_name'] ?? ''),
|
||||
'last_name' => (string) ($row['last_name'] ?? ''),
|
||||
'party_name' => (string) ($row['party_name'] ?? ''),
|
||||
],
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
function update_contact_details(PDO $pdo, int $contactId, array $input): array
|
||||
{
|
||||
if ($contactId <= 0) {
|
||||
throw new InvalidArgumentException('contact_person_id muss positiv sein.');
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
$params = [':id' => $contactId];
|
||||
foreach (['email', 'phone'] as $field) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$sets[] = $field . ' = :' . $field;
|
||||
$value = trim((string) ($input[$field] ?? ''));
|
||||
$params[':' . $field] = $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
if ($sets === []) {
|
||||
throw new InvalidArgumentException('Keine änderbaren Kontaktdaten übergeben.');
|
||||
}
|
||||
|
||||
$sets[] = 'updated_at = NOW()';
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE contact SET ' . implode(', ', $sets) . ' WHERE id = :id
|
||||
RETURNING id, party_id, first_name, last_name, email, phone, position_title'
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$contact = $stmt->fetch();
|
||||
if (!is_array($contact)) {
|
||||
throw new RuntimeException('Kontaktperson nicht gefunden.');
|
||||
}
|
||||
return $contact;
|
||||
}
|
||||
|
||||
function create_manufacturer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array
|
||||
{
|
||||
$name = trim($name);
|
||||
$email = $email !== null ? trim($email) : null;
|
||||
$phone = $phone !== null ? trim($phone) : null;
|
||||
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Herstellername fehlt');
|
||||
}
|
||||
|
||||
$existingStmt = $pdo->prepare(
|
||||
"SELECT p.id, p.name, p.email, p.phone
|
||||
FROM party p
|
||||
WHERE LOWER(TRIM(p.name)) = LOWER(:name)
|
||||
LIMIT 1
|
||||
FOR UPDATE"
|
||||
);
|
||||
$existingStmt->execute([':name' => $name]);
|
||||
$existing = $existingStmt->fetch();
|
||||
if (is_array($existing)) {
|
||||
$roleStmt = $pdo->prepare(
|
||||
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')
|
||||
ON CONFLICT (party_id, role_code) DO NOTHING"
|
||||
);
|
||||
$roleStmt->execute([':party_id' => (int) $existing['id']]);
|
||||
return [
|
||||
'id' => (int) $existing['id'],
|
||||
'name' => (string) $existing['name'],
|
||||
'email' => (string) ($existing['email'] ?? ''),
|
||||
'phone' => (string) ($existing['phone'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO party (type, name, email, phone, status, created_at, updated_at)
|
||||
VALUES ('supplier', :name, :email, :phone, 'active', NOW(), NOW())
|
||||
RETURNING id, name, email, phone"
|
||||
);
|
||||
$stmt->execute([
|
||||
':name' => $name,
|
||||
':email' => $email !== '' ? $email : null,
|
||||
':phone' => $phone !== '' ? $phone : null,
|
||||
]);
|
||||
$party = $stmt->fetch();
|
||||
if (!is_array($party)) {
|
||||
throw new RuntimeException('Hersteller konnte nicht angelegt werden');
|
||||
}
|
||||
|
||||
$roleStmt = $pdo->prepare(
|
||||
"INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')"
|
||||
);
|
||||
$roleStmt->execute([':party_id' => (int) $party['id']]);
|
||||
|
||||
return [
|
||||
'id' => (int) $party['id'],
|
||||
'name' => (string) $party['name'],
|
||||
'email' => (string) ($party['email'] ?? ''),
|
||||
'phone' => (string) ($party['phone'] ?? ''),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../shared/db.php';
|
||||
require_once __DIR__ . '/../../../shared/auth/service.php';
|
||||
require_once __DIR__ . '/../../kontakte/service.php';
|
||||
require_once __DIR__ . '/../service.php';
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../../.env'));
|
||||
|
||||
try {
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
$currentUser = auth_current_user($pdo);
|
||||
if ($currentUser === null) {
|
||||
json_response(401, ['ok' => false, 'error' => 'Unauthorized']);
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']);
|
||||
}
|
||||
|
||||
$jsonInput = file_get_contents('php://input');
|
||||
if ($jsonInput === false || trim($jsonInput) === '') {
|
||||
json_response(400, ['ok' => false, 'error' => 'Empty payload']);
|
||||
}
|
||||
|
||||
$data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data) || !auth_validate_csrf_token($data['csrfToken'] ?? null)) {
|
||||
json_response(403, ['ok' => false, 'error' => 'Invalid security token']);
|
||||
}
|
||||
|
||||
$action = trim((string) ($data['action'] ?? ''));
|
||||
if ($action === 'create_manufacturer') {
|
||||
$manufacturer = create_manufacturer(
|
||||
$pdo,
|
||||
(string) ($data['name'] ?? ''),
|
||||
isset($data['email']) ? (string) $data['email'] : null,
|
||||
isset($data['phone']) ? (string) $data['phone'] : null
|
||||
);
|
||||
json_response(201, ['ok' => true, 'manufacturer' => $manufacturer]);
|
||||
}
|
||||
|
||||
if ($action === 'update_lot') {
|
||||
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
|
||||
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === ''
|
||||
? null
|
||||
: (int) $manufacturerPartyId;
|
||||
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
|
||||
$adjustmentQty = isset($data['adjustmentQty']) && $data['adjustmentQty'] !== '' ? (float) $data['adjustmentQty'] : 0.0;
|
||||
$outQty = isset($data['outQty']) && $data['outQty'] !== '' ? (float) $data['outQty'] : 0.0;
|
||||
$updatedLot = update_stock_lot_traceability(
|
||||
$pdo,
|
||||
(int) ($data['lotId'] ?? 0),
|
||||
$manufacturerPartyId,
|
||||
isset($data['manufacturerLotNumber']) ? (string) $data['manufacturerLotNumber'] : null,
|
||||
isset($data['mfgDate']) ? (string) $data['mfgDate'] : null,
|
||||
isset($data['expiryDate']) ? (string) $data['expiryDate'] : null,
|
||||
isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null,
|
||||
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
|
||||
$receiptQty,
|
||||
$adjustmentQty,
|
||||
$outQty
|
||||
);
|
||||
$productId = (int) $updatedLot['product_id'];
|
||||
if (($updatedLot['status'] ?? '') === 'open') {
|
||||
sync_open_lot_activation_selection($pdo, $productId);
|
||||
$updatedLot['next_active'] = is_open_lot_selected_for_activation($pdo, (int) $updatedLot['id']);
|
||||
} else {
|
||||
ensure_current_lot_and_reconcile_virtual_shortage(
|
||||
$pdo,
|
||||
$productId,
|
||||
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
|
||||
'Verrechnung nach Pflege der aktiven Charge'
|
||||
);
|
||||
}
|
||||
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
|
||||
$forecast = get_lager_product_forecast($pdo, $productId);
|
||||
$currentLot = get_current_lot_snapshot($pdo, $productId);
|
||||
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $currentLot, 'balance' => [
|
||||
'qty_net' => $currentLot['qty_net'],
|
||||
'qty_in' => $currentLot['qty_in'],
|
||||
'qty_out' => $currentLot['qty_out'],
|
||||
], 'forecast' => $forecast]);
|
||||
}
|
||||
|
||||
if ($action === 'delete_open_lot') {
|
||||
$productId = delete_open_stock_lot(
|
||||
$pdo,
|
||||
(int) ($data['lotId'] ?? 0),
|
||||
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown')
|
||||
);
|
||||
sync_open_lot_activation_selection($pdo, $productId);
|
||||
ensure_current_lot_and_reconcile_virtual_shortage(
|
||||
$pdo,
|
||||
$productId,
|
||||
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
|
||||
'Verrechnung nach Loeschung einer offenen Charge'
|
||||
);
|
||||
json_response(200, ['ok' => true, 'productId' => $productId, 'currentLot' => get_current_lot_snapshot($pdo, $productId), 'availableQty' => get_lager_product_available_qty($pdo, $productId)]);
|
||||
}
|
||||
|
||||
if ($action === 'prepare_lot') {
|
||||
$productId = (int) ($data['productId'] ?? 0);
|
||||
$lotNumber = trim((string) ($data['lotNumber'] ?? ''));
|
||||
$lotId = (int) ($data['lotId'] ?? 0);
|
||||
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
|
||||
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId;
|
||||
$manufacturerLotNumber = trim((string) ($data['manufacturerLotNumber'] ?? ''));
|
||||
$mfgDate = trim((string) ($data['mfgDate'] ?? ''));
|
||||
$expiryDate = trim((string) ($data['expiryDate'] ?? ''));
|
||||
$purchaseDate = trim((string) ($data['purchaseDate'] ?? ''));
|
||||
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
|
||||
$targetQty = isset($data['targetQty']) && $data['targetQty'] !== '' ? (float) $data['targetQty'] : null;
|
||||
$pdo->beginTransaction();
|
||||
if ($lotId > 0) {
|
||||
$lotStmt = $pdo->prepare(
|
||||
"SELECT id
|
||||
FROM stock_lot
|
||||
WHERE id = :lot_id
|
||||
AND product_id = :product_id
|
||||
AND status = 'open'"
|
||||
);
|
||||
$lotStmt->execute([':lot_id' => $lotId, ':product_id' => $productId]);
|
||||
if ($lotStmt->fetchColumn() === false) {
|
||||
throw new InvalidArgumentException('Offene Charge nicht gefunden');
|
||||
}
|
||||
} else {
|
||||
$lotId = create_open_lot($pdo, $productId);
|
||||
}
|
||||
$preparedLot = update_stock_lot_traceability(
|
||||
$pdo,
|
||||
$lotId,
|
||||
$manufacturerPartyId,
|
||||
$manufacturerLotNumber,
|
||||
$mfgDate,
|
||||
$expiryDate,
|
||||
$purchaseDate,
|
||||
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
|
||||
$receiptQty,
|
||||
0.0,
|
||||
0.0,
|
||||
$lotNumber,
|
||||
$targetQty,
|
||||
$lotNumber === ''
|
||||
);
|
||||
sync_open_lot_activation_selection($pdo, $productId);
|
||||
if (promote_open_lot_when_virtual_shortage_is_current($pdo, $productId, $lotId, (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'))) {
|
||||
$preparedLot['status'] = 'current';
|
||||
$preparedLot['next_active'] = false;
|
||||
} else {
|
||||
$preparedLot['next_active'] = is_open_lot_selected_for_activation($pdo, $lotId);
|
||||
}
|
||||
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
|
||||
$forecast = get_lager_product_forecast($pdo, $productId);
|
||||
$pdo->commit();
|
||||
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $preparedLot, 'balance' => get_stock_lot_balance($pdo, $lotId), 'availableQty' => get_lager_product_available_qty($pdo, $productId), 'forecast' => $forecast]);
|
||||
}
|
||||
|
||||
if ($action === 'select_open_lot') {
|
||||
$productId = (int) ($data['productId'] ?? 0);
|
||||
$lotId = (int) ($data['lotId'] ?? 0);
|
||||
select_open_lot_for_activation($pdo, $productId, $lotId);
|
||||
ensure_current_lot_and_reconcile_virtual_shortage(
|
||||
$pdo,
|
||||
$productId,
|
||||
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
|
||||
'Verrechnung nach Auswahl der naechsten aktiven Charge'
|
||||
);
|
||||
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
|
||||
$currentLot = get_current_lot_snapshot($pdo, $productId);
|
||||
json_response(200, ['ok' => true, 'productId' => $productId, 'selectedLotId' => $lotId, 'currentLot' => $currentLot, 'forecast' => get_lager_product_forecast($pdo, $productId)]);
|
||||
}
|
||||
|
||||
json_response(422, ['ok' => false, 'error' => 'Unsupported action']);
|
||||
} catch (JsonException) {
|
||||
json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']);
|
||||
} catch (InvalidArgumentException $error) {
|
||||
json_response(422, ['ok' => false, 'error' => $error->getMessage()]);
|
||||
} catch (Throwable $error) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
try {
|
||||
system_write_audit_log($pdo, 'lager_api', 'lager-charge.php', 'request_failed', $currentUser['username'] ?? $currentUser['email'] ?? 'unknown', [], [], [
|
||||
'action' => $action ?? '',
|
||||
'product_id' => $data['productId'] ?? null,
|
||||
'lot_id' => $data['lotId'] ?? null,
|
||||
'exception_class' => get_class($error),
|
||||
'exception_message' => $error->getMessage(),
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
error_log('lager-charge API failure audit logging failed');
|
||||
}
|
||||
json_response(500, ['ok' => false, 'error' => 'Interner Serverfehler']);
|
||||
}
|
||||
+123
-16
@@ -35,21 +35,67 @@ function lager_forecast_load_current_lots(PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
|
||||
COALESCE(v.qty_net, 0) AS physical_qty
|
||||
COALESCE(v.qty_net, 0) AS physical_qty,
|
||||
next_sl.id AS next_lot_id,
|
||||
COALESCE(next_v.qty_net, 0) AS next_physical_qty
|
||||
FROM product p
|
||||
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
|
||||
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
||||
LEFT JOIN stock_lot next_sl
|
||||
ON next_sl.product_id = p.id
|
||||
AND next_sl.status = 'open'
|
||||
AND next_sl.next_active = TRUE
|
||||
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
|
||||
AND next_sl.manufacturer_party_id IS NOT NULL
|
||||
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
|
||||
AND next_sl.mfg_date IS NOT NULL
|
||||
AND next_sl.expiry_date IS NOT NULL
|
||||
AND next_sl.purchase_date IS NOT NULL
|
||||
LEFT JOIN v_stock_lot_balance next_v
|
||||
ON next_v.stock_lot_id = next_sl.id
|
||||
AND next_v.qty_net > 0
|
||||
WHERE p.status = 'active'
|
||||
ORDER BY p.id"
|
||||
);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays): array
|
||||
function lager_forecast_load_current_lot(PDO $pdo, int $productId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
|
||||
COALESCE(v.qty_net, 0) AS physical_qty,
|
||||
next_sl.id AS next_lot_id,
|
||||
COALESCE(next_v.qty_net, 0) AS next_physical_qty
|
||||
FROM product p
|
||||
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
|
||||
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
||||
LEFT JOIN stock_lot next_sl
|
||||
ON next_sl.product_id = p.id
|
||||
AND next_sl.status = 'open'
|
||||
AND next_sl.next_active = TRUE
|
||||
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
|
||||
AND next_sl.manufacturer_party_id IS NOT NULL
|
||||
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
|
||||
AND next_sl.mfg_date IS NOT NULL
|
||||
AND next_sl.expiry_date IS NOT NULL
|
||||
AND next_sl.purchase_date IS NOT NULL
|
||||
LEFT JOIN v_stock_lot_balance next_v
|
||||
ON next_v.stock_lot_id = next_sl.id
|
||||
AND next_v.qty_net > 0
|
||||
WHERE p.status = 'active' AND p.id = :product_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([':product_id' => $productId]);
|
||||
$row = $stmt->fetch();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays, ?int $productId = null): array
|
||||
{
|
||||
$startDate = $asOfDate->modify('-' . ($lookbackDays - 1) . ' days')->format('Y-m-d');
|
||||
$endDate = $asOfDate->modify('+1 day')->format('Y-m-d');
|
||||
$stmt = $pdo->prepare(
|
||||
$sql =
|
||||
"SELECT a.product_id, so.order_date::DATE AS sale_date,
|
||||
SUM(a.qty * GREATEST(sol.qty - sol.qty_cancelled, 0) / NULLIF(sol.qty, 0)) AS units
|
||||
FROM sales_order_line_lot_allocation a
|
||||
@@ -61,11 +107,15 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
|
||||
AND sol.line_status <> 'cancelled'
|
||||
AND so.order_status <> 'cancelled'
|
||||
AND so.order_date >= :start_date::DATE
|
||||
AND so.order_date < :end_date::DATE
|
||||
GROUP BY a.product_id, so.order_date::DATE
|
||||
ORDER BY a.product_id, sale_date"
|
||||
);
|
||||
$stmt->execute([':start_date' => $startDate, ':end_date' => $endDate]);
|
||||
AND so.order_date < :end_date::DATE";
|
||||
$params = [':start_date' => $startDate, ':end_date' => $endDate];
|
||||
if ($productId !== null) {
|
||||
$sql .= ' AND a.product_id = :product_id';
|
||||
$params[':product_id'] = $productId;
|
||||
}
|
||||
$sql .= ' GROUP BY a.product_id, so.order_date::DATE ORDER BY a.product_id, sale_date';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$sales = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
@@ -74,15 +124,22 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
|
||||
return $sales;
|
||||
}
|
||||
|
||||
function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQty, array $dailySales): array
|
||||
function lager_forecast_calculate(
|
||||
DateTimeImmutable $asOfDate,
|
||||
float $physicalQty,
|
||||
array $dailySales,
|
||||
?DateTimeImmutable $forecastStartDate = null
|
||||
): array
|
||||
{
|
||||
$lookbackDays = 365;
|
||||
$firstSaleDate = empty($dailySales) ? null : min(array_keys($dailySales));
|
||||
$lookbackDays = $firstSaleDate === null
|
||||
? 0
|
||||
: $asOfDate->diff(new DateTimeImmutable($firstSaleDate))->days + 1;
|
||||
$halfLifeDays = 60;
|
||||
$weightedUnits = 0.0;
|
||||
$weightTotal = 0.0;
|
||||
$salesUnits = 0.0;
|
||||
$salesDays = 0;
|
||||
$firstSaleDate = null;
|
||||
$lastSaleDate = null;
|
||||
|
||||
for ($age = 0; $age < $lookbackDays; $age++) {
|
||||
@@ -95,7 +152,6 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt
|
||||
if ($units > 0) {
|
||||
$salesUnits += $units;
|
||||
$salesDays++;
|
||||
$firstSaleDate = $dateKey;
|
||||
$lastSaleDate ??= $dateKey;
|
||||
}
|
||||
}
|
||||
@@ -126,8 +182,9 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt
|
||||
? 'high'
|
||||
: ($salesDays >= 10 ? 'medium' : 'low');
|
||||
$daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand);
|
||||
$forecastStartDate ??= $asOfDate;
|
||||
return [
|
||||
'forecast_date' => $asOfDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
|
||||
'forecast_date' => $forecastStartDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
|
||||
'forecast_status' => 'forecast', 'confidence' => $confidence, 'daily_demand' => $dailyDemand,
|
||||
'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays,
|
||||
'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays,
|
||||
@@ -136,7 +193,10 @@ function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQt
|
||||
|
||||
function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeImmutable $asOfDate): void
|
||||
{
|
||||
$pdo->beginTransaction();
|
||||
$startedTransaction = !$pdo->inTransaction();
|
||||
if ($startedTransaction) {
|
||||
$pdo->beginTransaction();
|
||||
}
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO stock_lot_forecast_history (
|
||||
@@ -172,15 +232,50 @@ function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeI
|
||||
':confidence' => $forecast['confidence'], ':daily_demand' => $forecast['daily_demand'],
|
||||
':lot_id' => (int) $lot['lot_id'],
|
||||
]);
|
||||
$pdo->commit();
|
||||
if ($startedTransaction) {
|
||||
$pdo->commit();
|
||||
}
|
||||
} catch (Throwable $error) {
|
||||
if ($pdo->inTransaction()) {
|
||||
if ($startedTransaction && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId): void
|
||||
{
|
||||
if ($productId <= 0) {
|
||||
throw new InvalidArgumentException('Produkt-ID fuer Forecast fehlt');
|
||||
}
|
||||
|
||||
$lot = lager_forecast_load_current_lot($pdo, $productId);
|
||||
if ($lot === null || $lot['lot_id'] === null) {
|
||||
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
|
||||
}
|
||||
|
||||
$asOfDate = new DateTimeImmutable('today', new DateTimeZone('Europe/Zurich'));
|
||||
$dailySales = lager_forecast_load_daily_sales($pdo, $asOfDate, 365, $productId);
|
||||
$forecast = lager_forecast_calculate(
|
||||
$asOfDate,
|
||||
(float) $lot['physical_qty'],
|
||||
$dailySales[$productId] ?? []
|
||||
);
|
||||
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
|
||||
if ($lot['next_lot_id'] !== null && $forecast['forecast_date'] !== null) {
|
||||
lager_forecast_persist($pdo, [
|
||||
'lot_id' => $lot['next_lot_id'],
|
||||
'product_id' => $productId,
|
||||
'physical_qty' => $lot['next_physical_qty'],
|
||||
], lager_forecast_calculate(
|
||||
$asOfDate,
|
||||
(float) $lot['next_physical_qty'],
|
||||
$dailySales[$productId] ?? [],
|
||||
new DateTimeImmutable($forecast['forecast_date'])
|
||||
), $asOfDate);
|
||||
}
|
||||
}
|
||||
|
||||
function run_lager_stock_lot_forecast(PDO $pdo): array
|
||||
{
|
||||
$runId = lager_forecast_start_run($pdo);
|
||||
@@ -204,6 +299,18 @@ function run_lager_stock_lot_forecast(PDO $pdo): array
|
||||
try {
|
||||
$forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []);
|
||||
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
|
||||
if ($lot['next_lot_id'] !== null && $forecast['forecast_date'] !== null) {
|
||||
lager_forecast_persist($pdo, [
|
||||
'lot_id' => $lot['next_lot_id'],
|
||||
'product_id' => $productId,
|
||||
'physical_qty' => $lot['next_physical_qty'],
|
||||
], lager_forecast_calculate(
|
||||
$asOfDate,
|
||||
(float) $lot['next_physical_qty'],
|
||||
$dailySales[$productId] ?? [],
|
||||
new DateTimeImmutable($forecast['forecast_date'])
|
||||
), $asOfDate);
|
||||
}
|
||||
$result['processed_products']++;
|
||||
} catch (Throwable $error) {
|
||||
$result['failed_products']++;
|
||||
|
||||
+1159
-8
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/service.php';
|
||||
|
||||
function market_management_mcp_token(array $env): string
|
||||
{
|
||||
$token = env_value('MARKET_MCP_TOKEN', $env);
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
function market_management_mcp_authorize(array $env): string
|
||||
{
|
||||
$header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? '');
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', trim($header), $matches)) {
|
||||
throw new RuntimeException('Bearer-Token fehlt.');
|
||||
}
|
||||
|
||||
$token = trim($matches[1]);
|
||||
$expected = market_management_mcp_token($env);
|
||||
if ($token === '' || !hash_equals($expected, $token)) {
|
||||
throw new RuntimeException('Bearer-Token ist ungültig.');
|
||||
}
|
||||
|
||||
return 'bearer:' . hash('sha256', $token);
|
||||
}
|
||||
|
||||
function market_management_mcp_tools(): array
|
||||
{
|
||||
$occurrenceProperties = [
|
||||
'status' => ['type' => 'string'], 'place' => ['type' => ['string', 'null']], 'address' => ['type' => ['string', 'null']],
|
||||
'municipality_reference_id' => ['type' => ['integer', 'null']], 'notes' => ['type' => ['string', 'null']],
|
||||
'registration_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
|
||||
'request_or_registration_date' => ['type' => ['string', 'null'], 'format' => 'date'],
|
||||
'confirmation_date' => ['type' => ['string', 'null'], 'format' => 'date'],
|
||||
'payment_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
|
||||
'payment_status' => ['type' => ['string', 'null'], 'enum' => ['paid', 'unpaid']],
|
||||
'payment_date' => ['type' => ['string', 'null'], 'format' => 'date'], 'paid_amount' => ['type' => ['number', 'null']],
|
||||
'payment_method_code' => ['type' => ['string', 'null']], 'stand_size' => ['type' => ['string', 'null']],
|
||||
'gross_cost' => ['type' => ['number', 'null'], 'minimum' => 0], 'tax_status' => ['type' => ['string', 'null'], 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => ['number', 'null'], 'minimum' => 0], 'cost_items_text' => ['type' => ['string', 'null']],
|
||||
'table_available' => ['type' => ['boolean', 'null']], 'table_rentable' => ['type' => ['boolean', 'null']],
|
||||
'own_table_required' => ['type' => ['boolean', 'null']], 'shelter_available' => ['type' => ['boolean', 'null']],
|
||||
'power_available' => ['type' => ['boolean', 'null']], 'water_available' => ['type' => ['boolean', 'null']],
|
||||
'lighting_available' => ['type' => ['boolean', 'null']], 'other_infrastructure' => ['type' => ['string', 'null']],
|
||||
'own_stand_requirements' => ['type' => ['string', 'null']], 'travel_address' => ['type' => ['string', 'null']],
|
||||
'travel_distance_km' => ['type' => ['number', 'null']], 'travel_duration_minutes' => ['type' => ['integer', 'null']],
|
||||
'parking_options' => ['type' => ['string', 'null']], 'delivery_access' => ['type' => ['string', 'null']],
|
||||
'direct_unload_possible' => ['type' => ['boolean', 'null']], 'storage_options' => ['type' => ['string', 'null']],
|
||||
'travel_notes' => ['type' => ['string', 'null']], 'cancellation_terms' => ['type' => ['string', 'null']],
|
||||
'revenue_share_terms' => ['type' => ['string', 'null']], 'other_participation_conditions' => ['type' => ['string', 'null']],
|
||||
'contract_folder_url' => ['type' => ['string', 'null'], 'format' => 'uri'],
|
||||
'contact_person_name' => ['type' => ['string', 'null']],
|
||||
'contact_email' => ['type' => ['string', 'null'], 'format' => 'email'], 'contact_phone' => ['type' => ['string', 'null']],
|
||||
];
|
||||
$dayProperties = [
|
||||
'date' => ['type' => 'string', 'format' => 'date'], 'observations' => ['type' => ['string', 'null']],
|
||||
'stand_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'stand_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
|
||||
'setup_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'setup_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
|
||||
'delivery_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'delivery_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
|
||||
'inherit_from_first_day' => ['type' => ['boolean', 'null']],
|
||||
];
|
||||
$searchProperties = [
|
||||
'search' => ['type' => 'string'], 'market_definition_id' => ['type' => 'integer'], 'category_id' => ['type' => 'integer'],
|
||||
'status' => ['type' => 'string'], 'place' => ['type' => 'string'], 'address' => ['type' => 'string'],
|
||||
'municipality_reference_id' => ['type' => 'integer'], 'date_from' => ['type' => 'string', 'format' => 'date'],
|
||||
'date_to' => ['type' => 'string', 'format' => 'date'], 'include_archived' => ['type' => 'boolean'],
|
||||
'sort' => ['type' => 'string'], 'sort_direction' => ['type' => 'string', 'enum' => ['asc', 'desc']],
|
||||
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'offset' => ['type' => 'integer', 'minimum' => 0],
|
||||
];
|
||||
return [
|
||||
['name' => 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
|
||||
['name' => 'market_management.search_occurrences', 'description' => 'Marktdurchführungen suchen und auflisten', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
|
||||
['name' => 'market_management.get_market', 'description' => 'Markt mit Marktdurchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
|
||||
['name' => 'market_management.create_market', 'description' => 'Markt mit verpflichtendem Veranstalter, Kategorie und optionaler Website-URL anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id', 'organizer_id'], 'properties' => ['name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]],
|
||||
['name' => 'market_management.update_market', 'description' => 'Markt inklusive Veranstalter und Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]],
|
||||
['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer']]]],
|
||||
['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer']]]],
|
||||
['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
|
||||
['name' => 'market_management.list_market_organizers', 'description' => 'Aktive Veranstalter lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
|
||||
['name' => 'market_management.create_market_organizer', 'description' => 'Veranstalter mit optionalen Kontaktdaten anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'email' => ['type' => ['string', 'null'], 'format' => 'email'], 'phone' => ['type' => ['string', 'null']]]]],
|
||||
['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
|
||||
['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string']]]],
|
||||
['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
|
||||
['name' => 'market_management.create_occurrence', 'description' => 'Marktdurchführung mit mindestens einem Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days'], 'properties' => ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string'], 'days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]] + $occurrenceProperties]]],
|
||||
['name' => 'market_management.get_occurrence', 'description' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
|
||||
['name' => 'market_management.update_occurrence', 'description' => 'Marktdurchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $occurrenceProperties]],
|
||||
['name' => 'market_management.archive_occurrence', 'description' => 'Marktdurchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']]]],
|
||||
['name' => 'market_management.reactivate_occurrence', 'description' => 'Marktdurchführung reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']]]],
|
||||
['name' => 'market_management.create_day', 'description' => 'Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $dayProperties]],
|
||||
['name' => 'market_management.update_day', 'description' => 'Markttag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']] + $dayProperties]],
|
||||
['name' => 'market_management.delete_day', 'description' => 'Markttag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']]]],
|
||||
];
|
||||
}
|
||||
|
||||
function market_management_mcp_is_write_tool(string $tool): bool
|
||||
{
|
||||
return in_array($tool, [
|
||||
'market_management.create_market', 'market_management.update_market',
|
||||
'market_management.create_market_organizer',
|
||||
'market_management.create_municipality',
|
||||
'market_management.archive_market', 'market_management.reactivate_market',
|
||||
'market_management.create_occurrence', 'market_management.update_occurrence',
|
||||
'market_management.archive_occurrence', 'market_management.reactivate_occurrence',
|
||||
'market_management.create_day', 'market_management.update_day', 'market_management.delete_day',
|
||||
], true);
|
||||
}
|
||||
|
||||
function market_management_mcp_call_idempotent(PDO $pdo, string $userSubject, string $tool, array $arguments): array
|
||||
{
|
||||
if (!market_management_mcp_is_write_tool($tool)) {
|
||||
return market_management_mcp_call($pdo, $tool, $arguments);
|
||||
}
|
||||
|
||||
$idempotencyKey = trim((string) ($arguments['idempotency_key'] ?? ''));
|
||||
if ($idempotencyKey === '') {
|
||||
throw new InvalidArgumentException('Schreibende MCP-Aufrufe benötigen idempotency_key.');
|
||||
}
|
||||
$clientId = trim((string) ($arguments['client_id'] ?? 'hermes'));
|
||||
if ($clientId === '') {
|
||||
throw new InvalidArgumentException('client_id darf nicht leer sein.');
|
||||
}
|
||||
$businessArguments = $arguments;
|
||||
unset($businessArguments['idempotency_key'], $businessArguments['client_id']);
|
||||
$requestHash = hash('sha256', json_encode([
|
||||
'tool' => $tool,
|
||||
'arguments' => $businessArguments,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$existingStmt = $pdo->prepare(
|
||||
'SELECT user_subject, request_hash, result_status, result_payload
|
||||
FROM market_mcp_idempotency
|
||||
WHERE client_id = :client_id AND idempotency_key = :idempotency_key
|
||||
FOR UPDATE'
|
||||
);
|
||||
$existingStmt->execute([':client_id' => $clientId, ':idempotency_key' => $idempotencyKey]);
|
||||
$existing = $existingStmt->fetch();
|
||||
if (is_array($existing)) {
|
||||
if ((string) $existing['user_subject'] !== $userSubject || (string) $existing['request_hash'] !== $requestHash) {
|
||||
throw new InvalidArgumentException('Idempotency-Schlüssel wurde bereits für eine andere Anfrage verwendet.');
|
||||
}
|
||||
if ((string) $existing['result_status'] === 'succeeded' && $existing['result_payload'] !== null) {
|
||||
$pdo->commit();
|
||||
return (array) json_decode((string) $existing['result_payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
throw new RuntimeException('Idempotency-Anfrage wird bereits verarbeitet oder ist fehlgeschlagen.');
|
||||
}
|
||||
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO market_mcp_idempotency
|
||||
(user_subject, client_id, idempotency_key, tool_name, request_hash, result_status, expires_at)
|
||||
VALUES (:user_subject, :client_id, :idempotency_key, :tool_name, :request_hash, \'processing\', NOW() + INTERVAL \'24 hours\')'
|
||||
);
|
||||
$insert->execute([
|
||||
':user_subject' => $userSubject,
|
||||
':client_id' => $clientId,
|
||||
':idempotency_key' => $idempotencyKey,
|
||||
':tool_name' => $tool,
|
||||
':request_hash' => $requestHash,
|
||||
]);
|
||||
$result = market_management_mcp_call($pdo, $tool, $businessArguments);
|
||||
$update = $pdo->prepare(
|
||||
'UPDATE market_mcp_idempotency
|
||||
SET result_status = \'succeeded\', result_payload = CAST(:result_payload AS JSONB)
|
||||
WHERE client_id = :client_id AND idempotency_key = :idempotency_key'
|
||||
);
|
||||
$update->execute([
|
||||
':result_payload' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
||||
':client_id' => $clientId,
|
||||
':idempotency_key' => $idempotencyKey,
|
||||
]);
|
||||
$pdo->commit();
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function market_management_mcp_get_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): array
|
||||
{
|
||||
$occurrence = market_management_get_occurrence($pdo, $occurrenceId, $includeArchived);
|
||||
if ($occurrence === null) {
|
||||
throw new RuntimeException('Marktdurchführung nicht gefunden.');
|
||||
}
|
||||
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id'], $includeArchived);
|
||||
if ($market === null) {
|
||||
throw new RuntimeException('Markt nicht gefunden.');
|
||||
}
|
||||
unset($market['occurrences']);
|
||||
$occurrence['market'] = $market;
|
||||
return $occurrence;
|
||||
}
|
||||
|
||||
function market_management_mcp_get_market(PDO $pdo, int $marketId, bool $includeArchived = false): array
|
||||
{
|
||||
$market = market_management_get_market($pdo, $marketId, $includeArchived);
|
||||
if ($market === null) {
|
||||
throw new RuntimeException('Markt nicht gefunden.');
|
||||
}
|
||||
foreach ($market['occurrences'] as $index => $occurrence) {
|
||||
$enriched = market_management_get_occurrence($pdo, (int) $occurrence['id'], $includeArchived);
|
||||
if ($enriched !== null) {
|
||||
$market['occurrences'][$index] = $enriched;
|
||||
}
|
||||
}
|
||||
return $market;
|
||||
}
|
||||
|
||||
function market_management_mcp_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array
|
||||
{
|
||||
unset($input['occurrence_id']);
|
||||
$simpleCostFields = [];
|
||||
foreach (['gross_cost', 'cost_items_text', 'tax_status', 'tax_rate'] as $field) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$simpleCostFields[$field] = $input[$field];
|
||||
unset($input[$field]);
|
||||
}
|
||||
}
|
||||
if ($simpleCostFields !== []) {
|
||||
market_management_update_occurrence_simple_costs($pdo, $occurrenceId, $simpleCostFields);
|
||||
}
|
||||
$contactFields = [];
|
||||
foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $field) {
|
||||
if (array_key_exists($field, $input)) {
|
||||
$contactFields[$field] = $input[$field];
|
||||
unset($input[$field]);
|
||||
}
|
||||
}
|
||||
$existing = null;
|
||||
if ($contactFields !== []) {
|
||||
$existing = market_management_get_occurrence($pdo, $occurrenceId, true);
|
||||
if ($existing === null) {
|
||||
throw new RuntimeException('Marktdurchführung nicht gefunden.');
|
||||
}
|
||||
}
|
||||
if ($contactFields !== []) {
|
||||
$contactFields['occurrence_id'] = $occurrenceId;
|
||||
market_management_set_occurrence_contact($pdo, $contactFields);
|
||||
}
|
||||
if ($input !== []) {
|
||||
market_management_update_occurrence($pdo, $occurrenceId, $input);
|
||||
}
|
||||
return market_management_mcp_get_occurrence($pdo, $occurrenceId, true);
|
||||
}
|
||||
|
||||
function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): array
|
||||
{
|
||||
return match ($tool) {
|
||||
'market_management.search_markets' => market_management_search_markets($pdo, $arguments),
|
||||
'market_management.search_occurrences' => market_management_search_occurrences($pdo, $arguments),
|
||||
'market_management.get_market' => market_management_mcp_get_market(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
||||
(bool) ($arguments['include_archived'] ?? false)
|
||||
),
|
||||
'market_management.create_market' => market_management_create_market($pdo, $arguments),
|
||||
'market_management.update_market' => market_management_update_market(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
||||
$arguments
|
||||
),
|
||||
'market_management.archive_market' => market_management_set_market_archive(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
||||
true
|
||||
),
|
||||
'market_management.reactivate_market' => market_management_set_market_archive(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
|
||||
false
|
||||
),
|
||||
'market_management.list_categories' => market_management_list_categories($pdo),
|
||||
'market_management.list_market_organizers' => market_management_list_organizers($pdo),
|
||||
'market_management.create_market_organizer' => market_management_create_organizer(
|
||||
$pdo,
|
||||
(string) ($arguments['name'] ?? ''),
|
||||
$arguments['email'] ?? null,
|
||||
$arguments['phone'] ?? null
|
||||
),
|
||||
'market_management.list_reference_data' => market_management_list_reference_data($pdo, $arguments),
|
||||
'market_management.create_municipality' => market_management_create_municipality(
|
||||
$pdo,
|
||||
(string) ($arguments['name'] ?? '')
|
||||
),
|
||||
'market_management.get_overview' => [
|
||||
'markets' => market_management_search_markets($pdo, $arguments),
|
||||
'occurrences' => market_management_search_occurrences($pdo, $arguments),
|
||||
],
|
||||
'market_management.create_occurrence' => (static function () use ($pdo, $arguments): array {
|
||||
$occurrence = market_management_create_occurrence($pdo, $arguments);
|
||||
return $occurrence;
|
||||
})(),
|
||||
'market_management.get_occurrence' => market_management_mcp_get_occurrence(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
||||
(bool) ($arguments['include_archived'] ?? false)
|
||||
),
|
||||
'market_management.update_occurrence' => market_management_mcp_update_occurrence(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
||||
$arguments
|
||||
),
|
||||
'market_management.archive_occurrence' => market_management_set_occurrence_archive(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
||||
true
|
||||
),
|
||||
'market_management.reactivate_occurrence' => market_management_set_occurrence_archive(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
|
||||
false
|
||||
),
|
||||
'market_management.create_day' => market_management_create_day($pdo, $arguments),
|
||||
'market_management.update_day' => market_management_update_day(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id'),
|
||||
$arguments
|
||||
),
|
||||
'market_management.delete_day' => market_management_delete_day(
|
||||
$pdo,
|
||||
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id')
|
||||
),
|
||||
default => throw new InvalidArgumentException('Unbekanntes MCP-Tool: ' . $tool),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+897
-281
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ function throttle_webhook_channel(string $channel, int $minIntervalSeconds): voi
|
||||
$sanitizedChannel = preg_replace('/[^a-z0-9_-]+/i', '_', $channel) ?? 'default';
|
||||
$lockPath = sys_get_temp_dir() . '/erp_naurua_webhook_throttle_' . $sanitizedChannel . '.lock';
|
||||
|
||||
$handle = fopen($lockPath, 'c+');
|
||||
$handle = @fopen($lockPath, 'c+');
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function system_write_audit_log(
|
||||
PDO $pdo,
|
||||
string $entityName,
|
||||
string $entityId,
|
||||
string $action,
|
||||
?string $changedBy,
|
||||
array $beforeData,
|
||||
array $afterData,
|
||||
array $context = []
|
||||
): void {
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO audit_log (
|
||||
entity_name, entity_id, action, changed_by, before_data, after_data, context
|
||||
) VALUES (
|
||||
:entity_name, :entity_id, :action, :changed_by, :before_data::jsonb, :after_data::jsonb, :context::jsonb
|
||||
)"
|
||||
);
|
||||
$stmt->execute([
|
||||
':entity_name' => $entityName,
|
||||
':entity_id' => $entityId,
|
||||
':action' => $action,
|
||||
':changed_by' => $changedBy,
|
||||
':before_data' => json_encode($beforeData, JSON_THROW_ON_ERROR),
|
||||
':after_data' => json_encode($afterData, JSON_THROW_ON_ERROR),
|
||||
':context' => json_encode($context, JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../shared/db.php';
|
||||
require_once __DIR__ . '/../erp/lager/forecast.php';
|
||||
require_once __DIR__ . '/../erp/import-integration/shopify-webhook-worker.php';
|
||||
require_once __DIR__ . '/../erp/import-integration/shopify-delta-run.php';
|
||||
require_once __DIR__ . '/../market_management/service.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
@@ -19,8 +22,28 @@ $env = expand_env_values(parse_env_file(dirname(__DIR__, 2) . '/.env'));
|
||||
$pdo = connect_database($env);
|
||||
|
||||
try {
|
||||
$reconciliationStartedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Zurich'));
|
||||
$jobs = [
|
||||
'erp.import-integration.shopify_webhook_worker' => static fn (): array => shopify_webhook_worker_run($pdo, $env, 100),
|
||||
'erp.import-integration.shopify_delta_order_import' => static function () use ($pdo, $env, $reconciliationStartedAt): array {
|
||||
$cutoff = shopify_delta_reconciliation_cutoff($pdo);
|
||||
$recoveryOrderGids = shopify_delta_load_pending_order_gids($pdo);
|
||||
$runId = shopify_delta_start_run($pdo, $cutoff, false);
|
||||
try {
|
||||
$result = run_shopify_delta($env, $pdo, $cutoff, false, $recoveryOrderGids);
|
||||
$result['technical_run_id'] = $runId;
|
||||
if (($result['status'] ?? '') === 'done') {
|
||||
shopify_delta_store_watermark($pdo, $reconciliationStartedAt->format(DATE_ATOM));
|
||||
}
|
||||
shopify_delta_finish_run($pdo, $runId, (string) $result['status'], $result);
|
||||
return $result;
|
||||
} catch (Throwable $error) {
|
||||
shopify_delta_finish_run($pdo, $runId, 'failed', ['status' => 'failed', 'technical_run_id' => $runId], $error);
|
||||
throw $error;
|
||||
}
|
||||
},
|
||||
'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo),
|
||||
'market_management.occurrence_status_completion' => static fn (): array => market_management_auto_complete_occurrences($pdo),
|
||||
];
|
||||
$results = [];
|
||||
foreach ($jobs as $name => $job) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
|
||||
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
|
||||
if (auth_current_user($pdo) === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$orderId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
|
||||
if (!is_int($orderId) || $orderId <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid order id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$detail = get_sales_order_detail($pdo, $orderId);
|
||||
if ($detail === null) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'Order not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'order' => $detail], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../modules/erp/lager/api/lot.php';
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../modules/shared/auth/service.php';
|
||||
require_once __DIR__ . '/../../modules/market_management/service.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
|
||||
if (auth_current_user($pdo) === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'Nicht autorisiert.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'Methode nicht erlaubt.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$csrf = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
|
||||
if (!auth_validate_csrf_token($csrf)) {
|
||||
http_response_code(419);
|
||||
echo json_encode(['ok' => false, 'error' => 'Ungültiges Sicherheits-Token.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents('php://input'), true);
|
||||
if (!is_array($payload)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Ungültige Anfrage.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
|
||||
if (($fields['organizer_id'] ?? '') === '__new__') {
|
||||
$organizer = market_management_create_organizer(
|
||||
$pdo,
|
||||
(string) ($fields['organizer_name'] ?? ''),
|
||||
(string) ($fields['organizer_email'] ?? ''),
|
||||
(string) ($fields['organizer_phone'] ?? '')
|
||||
);
|
||||
$fields['organizer_id'] = $organizer['id'];
|
||||
}
|
||||
if (($payload['action'] ?? 'create') === 'delete') {
|
||||
$marketId = (int) ($payload['id'] ?? 0);
|
||||
if ($marketId < 1) {
|
||||
throw new InvalidArgumentException('id ist erforderlich.');
|
||||
}
|
||||
if (($payload['confirmation'] ?? '') !== 'DELETE') {
|
||||
throw new InvalidArgumentException('Die Löschbestätigung muss exakt DELETE lauten.');
|
||||
}
|
||||
$market = market_management_set_market_archive($pdo, $marketId, true);
|
||||
} elseif (($payload['action'] ?? 'create') === 'update') {
|
||||
$marketId = (int) ($payload['id'] ?? 0);
|
||||
if ($marketId < 1) {
|
||||
throw new InvalidArgumentException('id ist erforderlich.');
|
||||
}
|
||||
$market = market_management_update_market($pdo, $marketId, [
|
||||
'name' => $fields['name'] ?? null,
|
||||
'category_id' => $fields['category_id'] ?? null,
|
||||
'organizer_id' => $fields['organizer_id'] ?? null,
|
||||
'website_url' => $fields['website_url'] ?? null,
|
||||
]);
|
||||
} else {
|
||||
$market = market_management_create_market($pdo, [
|
||||
'name' => $fields['name'] ?? null,
|
||||
'category_id' => $fields['category_id'] ?? null,
|
||||
'organizer_id' => $fields['organizer_id'] ?? null,
|
||||
'website_url' => $fields['website_url'] ?? null,
|
||||
]);
|
||||
}
|
||||
echo json_encode(['ok' => true, 'market' => $market], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $error) {
|
||||
http_response_code(422);
|
||||
echo json_encode(['ok' => false, 'error' => $error->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../modules/market_management/mcp.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
|
||||
|
||||
try {
|
||||
$userSubject = market_management_mcp_authorize($env);
|
||||
$pdo = connect_database($env);
|
||||
$body = file_get_contents('php://input');
|
||||
$request = json_decode($body === false ? '' : $body, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($request) || ($request['jsonrpc'] ?? '') !== '2.0') {
|
||||
throw new InvalidArgumentException('Ungültige JSON-RPC-Anfrage.');
|
||||
}
|
||||
|
||||
$id = $request['id'] ?? null;
|
||||
$method = (string) ($request['method'] ?? '');
|
||||
$params = is_array($request['params'] ?? null) ? $request['params'] : [];
|
||||
|
||||
if ($method === 'initialize') {
|
||||
$result = [
|
||||
'protocolVersion' => (string) ($params['protocolVersion'] ?? '2025-03-26'),
|
||||
'capabilities' => ['tools' => ['listChanged' => false]],
|
||||
'serverInfo' => ['name' => 'naurua-market-management', 'version' => '1.0.0'],
|
||||
];
|
||||
} elseif ($method === 'notifications/initialized') {
|
||||
http_response_code(202);
|
||||
exit;
|
||||
} elseif ($method === 'tools/list') {
|
||||
$result = ['tools' => market_management_mcp_tools()];
|
||||
} elseif ($method === 'tools/call') {
|
||||
$tool = (string) ($params['name'] ?? '');
|
||||
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
|
||||
$result = [
|
||||
'content' => [[
|
||||
'type' => 'text',
|
||||
'text' => json_encode(market_management_mcp_call_idempotent($pdo, $userSubject, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
||||
]],
|
||||
'isError' => false,
|
||||
];
|
||||
} else {
|
||||
throw new InvalidArgumentException('Unbekannte JSON-RPC-Methode: ' . $method);
|
||||
}
|
||||
|
||||
echo json_encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code($e instanceof RuntimeException && str_contains($e->getMessage(), 'Token') ? 401 : 400);
|
||||
echo json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => $request['id'] ?? null,
|
||||
'error' => ['code' => -32600, 'message' => $e->getMessage()],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?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/kontakte/service.php';
|
||||
require_once __DIR__ . '/../../modules/market_management/service.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
|
||||
if (auth_current_user($pdo) === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
if ($requestMethod === 'POST') {
|
||||
$csrf = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
|
||||
if (!auth_validate_csrf_token($csrf)) {
|
||||
http_response_code(419);
|
||||
echo json_encode(['ok' => false, 'error' => 'Ungültiges Sicherheits-Token']);
|
||||
exit;
|
||||
}
|
||||
$payload = json_decode((string) file_get_contents('php://input'), true);
|
||||
if (!is_array($payload)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Ungültige Anfrage']);
|
||||
exit;
|
||||
}
|
||||
try {
|
||||
if (($payload['action'] ?? '') === 'create') {
|
||||
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
|
||||
$occurrence = market_management_create_occurrence($pdo, [
|
||||
'market_definition_id' => $fields['market_definition_id'] ?? null,
|
||||
'days' => [['date' => $fields['date'] ?? null]],
|
||||
]);
|
||||
echo json_encode(['ok' => true, 'occurrence_id' => (int) $occurrence['id']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
$scope = (string) ($payload['scope'] ?? '');
|
||||
$id = market_management_require_positive_id($payload['id'] ?? null, 'id');
|
||||
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
|
||||
if (($payload['action'] ?? '') === 'delete') {
|
||||
if (($payload['confirmation'] ?? '') !== 'DELETE') {
|
||||
throw new InvalidArgumentException('Die Löschbestätigung muss exakt DELETE lauten.');
|
||||
}
|
||||
market_management_set_occurrence_archive($pdo, $id, true);
|
||||
} elseif (($payload['action'] ?? '') === 'create_day') {
|
||||
$fields['occurrence_id'] = $id;
|
||||
$day = market_management_create_day($pdo, $fields);
|
||||
echo json_encode(['ok' => true, 'day' => $day]);
|
||||
exit;
|
||||
} elseif (($payload['action'] ?? '') === 'delete_day') {
|
||||
if (($payload['confirmation'] ?? '') !== 'DELETE') {
|
||||
throw new InvalidArgumentException('Bestätigung muss exakt DELETE lauten.');
|
||||
}
|
||||
$day = market_management_delete_day($pdo, $id);
|
||||
echo json_encode(['ok' => true, 'day' => $day]);
|
||||
exit;
|
||||
} elseif ($scope === 'day') {
|
||||
market_management_update_day($pdo, $id, $fields);
|
||||
} elseif (in_array($scope, ['occurrence', 'status', 'overview', 'participation', 'infrastructure', 'travel', 'notes'], true)) {
|
||||
$contactFields = [];
|
||||
foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $contactField) {
|
||||
if (array_key_exists($contactField, $fields)) {
|
||||
$contactFields[$contactField] = $fields[$contactField];
|
||||
unset($fields[$contactField]);
|
||||
}
|
||||
}
|
||||
$existingOccurrence = null;
|
||||
if ($contactFields !== []) {
|
||||
$existingOccurrence = market_management_get_occurrence($pdo, $id, true);
|
||||
}
|
||||
if ($contactFields !== []) {
|
||||
$contactFields['occurrence_id'] = $id;
|
||||
market_management_set_occurrence_contact($pdo, $contactFields);
|
||||
}
|
||||
if ($fields !== []) {
|
||||
$simpleCostFields = [];
|
||||
foreach (['gross_cost', 'cost_items_text', 'tax_status', 'tax_rate'] as $costField) {
|
||||
if (array_key_exists($costField, $fields)) {
|
||||
$simpleCostFields[$costField] = $fields[$costField];
|
||||
unset($fields[$costField]);
|
||||
}
|
||||
}
|
||||
if ($simpleCostFields !== []) {
|
||||
market_management_update_occurrence_simple_costs($pdo, $id, $simpleCostFields);
|
||||
}
|
||||
if ($fields !== []) {
|
||||
market_management_update_occurrence($pdo, $id, $fields);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new InvalidArgumentException('Ungültiger Bearbeitungsbereich.');
|
||||
}
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
} catch (Throwable $error) {
|
||||
http_response_code(422);
|
||||
echo json_encode(['ok' => false, 'error' => $error->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (($requestMethod === 'GET') && ($_GET['action'] ?? '') === 'create_options') {
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'market_definitions' => market_management_list_active_market_definitions($pdo),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
$occurrenceId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
|
||||
if (!is_int($occurrenceId) || $occurrenceId <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid occurrence id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$occurrence = market_management_get_occurrence($pdo, $occurrenceId);
|
||||
if ($occurrence === null) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'Occurrence not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id']);
|
||||
if ($market === null) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'Market not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'market' => $market, 'occurrence' => $occurrence], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -1,6 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
http_response_code(410);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Bestellungen-Realtime ist deaktiviert.';
|
||||
exit;
|
||||
|
||||
require_once __DIR__ . '/../../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
|
||||
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
--surface-menu-panel-portal: var(--color-light-grey);
|
||||
--surface-help-icon: var(--color-medium-grey);
|
||||
--surface-help-panel: var(--color-light-grey);
|
||||
--surface-tooltip: var(--color-light-grey);
|
||||
--surface-pulldown-panel: var(--color-light-grey);
|
||||
--surface-activatable-remove: var(--surface-control-default);
|
||||
--surface-slider-track: var(--color-medium-grey);
|
||||
@@ -89,6 +90,8 @@
|
||||
--layout-mode-toggle-local-width-factor: 3;
|
||||
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
|
||||
--layout-help-panel-width: calc(var(--interaction-height) * 8);
|
||||
--layout-tooltip-width: calc(var(--interaction-height) * 8);
|
||||
--radius-tooltip: var(--radius-interaction);
|
||||
--text-control-default: var(--color-font-dark);
|
||||
--text-control-disabled: var(--color-dark-grey);
|
||||
--text-button-process: var(--color-font-light);
|
||||
@@ -98,6 +101,7 @@
|
||||
--text-menu-link-portal: var(--color-font-dark);
|
||||
--text-help-icon: var(--color-font-dark);
|
||||
--text-help-panel: var(--color-font-dark);
|
||||
--text-tooltip: var(--color-font-dark);
|
||||
--text-activatable-remove: var(--text-control-default);
|
||||
--text-hyperlink: var(--color-font-hyperlink);
|
||||
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
|
||||
@@ -175,6 +179,8 @@
|
||||
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
|
||||
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
|
||||
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
|
||||
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
|
||||
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
|
||||
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
|
||||
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
|
||||
--layout-notification-card-width: var(--dimension-notification-card-width);
|
||||
@@ -287,6 +293,8 @@
|
||||
--dimension-object-card-mobile-width: 100%;
|
||||
--dimension-object-card-mobile-height: auto;
|
||||
--dimension-object-card-desktop-breakpoint: 768px;
|
||||
--dimension-scrollable-card-width: 600px;
|
||||
--dimension-scrollable-card-height: 600px;
|
||||
--dimension-content-card-margin-top-desktop: 100px;
|
||||
--dimension-content-card-margin-top-mobile: 1rem;
|
||||
--dimension-object-group-card-min-width: 450px;
|
||||
@@ -656,6 +664,13 @@ section + section {
|
||||
color: var(--text-control-default);
|
||||
}
|
||||
|
||||
.sg-button--warning {
|
||||
--surface-button-warning: var(--color-signal-red);
|
||||
--text-button-warning: var(--color-font-light);
|
||||
background: var(--surface-button-warning);
|
||||
color: var(--text-button-warning);
|
||||
}
|
||||
|
||||
.sg-button--process {
|
||||
background: var(--surface-button-process);
|
||||
color: var(--text-button-process);
|
||||
@@ -1749,6 +1764,95 @@ section + section {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sg-tooltip-panel {
|
||||
position: absolute;
|
||||
z-index: var(--layer-pulldown-panel);
|
||||
display: block;
|
||||
width: var(--layout-tooltip-width);
|
||||
max-width: calc(100vw - (2 * var(--spacing-large)));
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--spacing-large);
|
||||
border-radius: var(--radius-tooltip);
|
||||
color: var(--text-tooltip);
|
||||
background: var(--surface-tooltip);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.sg-tooltip-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
|
||||
top: calc(100% + var(--spacing-small));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
|
||||
bottom: calc(100% + var(--spacing-small));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
|
||||
top: 50%;
|
||||
right: calc(100% + var(--spacing-small));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
|
||||
top: 50%;
|
||||
left: calc(100% + var(--spacing-small));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-panel::before {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
|
||||
top: calc(var(--spacing-small) * -1);
|
||||
left: 50%;
|
||||
border-width: 0 var(--spacing-small) var(--spacing-small);
|
||||
border-color: transparent transparent var(--surface-tooltip);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
|
||||
bottom: calc(var(--spacing-small) * -1);
|
||||
left: 50%;
|
||||
border-width: var(--spacing-small) var(--spacing-small) 0;
|
||||
border-color: var(--surface-tooltip) transparent transparent;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
|
||||
top: 50%;
|
||||
right: calc(var(--spacing-small) * -1);
|
||||
border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small);
|
||||
border-color: transparent transparent transparent var(--surface-tooltip);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
|
||||
top: 50%;
|
||||
left: calc(var(--spacing-small) * -1);
|
||||
border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0;
|
||||
border-color: transparent var(--surface-tooltip) transparent transparent;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-pulldown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-small));
|
||||
@@ -2096,6 +2200,147 @@ section + section {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Component: File Input */
|
||||
.sg-file-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-file-input__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-file-input__control {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sg-file-input:focus-within .sg-file-input__trigger {
|
||||
outline: 2px solid var(--text-hyperlink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sg-file-input__trigger {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sg-file-input__row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.sg-file-input--disabled {
|
||||
opacity: var(--disabled-opacity);
|
||||
}
|
||||
|
||||
.sg-file-input--disabled .sg-file-input__trigger {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* Patterns: Datei-Upload */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-file-upload {
|
||||
--surface-file-upload-dropzone: var(--surface-form-preview);
|
||||
--surface-file-upload-file-item: var(--surface-control-default);
|
||||
--text-file-upload-meta: var(--text-control-disabled);
|
||||
--text-file-upload-error: var(--color-signal-red);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
max-width: var(--layout-input-field-max-width);
|
||||
}
|
||||
|
||||
.sg-file-upload__status,
|
||||
.sg-file-upload__file-meta {
|
||||
margin: var(--spacing-small) 0 0;
|
||||
}
|
||||
|
||||
.sg-file-upload__dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
padding: var(--spacing-large);
|
||||
background: var(--surface-file-upload-dropzone);
|
||||
border: 1px dashed var(--surface-button-active);
|
||||
border-radius: var(--radius-interaction);
|
||||
}
|
||||
|
||||
.sg-file-upload__dropzone[data-drag-over="true"] {
|
||||
outline: 2px solid var(--text-hyperlink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sg-file-upload__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-small);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sg-file-upload__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
padding: var(--spacing-small) var(--spacing-large);
|
||||
color: var(--text-control-default);
|
||||
background: var(--surface-file-upload-file-item);
|
||||
border-radius: var(--radius-interaction);
|
||||
}
|
||||
|
||||
.sg-file-upload__file-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sg-file-upload__file-meta {
|
||||
color: var(--text-file-upload-meta);
|
||||
}
|
||||
|
||||
.sg-file-upload__remove {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-file-upload__submit {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.sg-file-upload__error {
|
||||
color: var(--text-file-upload-error);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sg-file-upload__dropzone {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sg-file-upload__file {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-small);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* Patterns: Portal Header */
|
||||
/* ========================================================= */
|
||||
@@ -5031,3 +5276,38 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* Patterns: Scrollable Card */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-scrollable-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--layout-scrollable-card-width);
|
||||
max-width: 100%;
|
||||
height: var(--layout-scrollable-card-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__header,
|
||||
.sg-scrollable-card__actions-segment {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-small);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-scrollable-card__actions > .sg-button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"styleguideVersion": "2026.05.18.1",
|
||||
"styleguideCommit": "3f4ec38",
|
||||
"syncedAtUtc": "2026-09-03T15:57:49Z",
|
||||
"styleguideCommit": "944d791",
|
||||
"syncedAtUtc": "2026-09-21T12:42:55Z",
|
||||
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
|
||||
"mirroredDocsPath": "docs/styleguide"
|
||||
}
|
||||
@@ -62,6 +62,7 @@
|
||||
--surface-menu-panel-portal: var(--color-light-grey);
|
||||
--surface-help-icon: var(--color-medium-grey);
|
||||
--surface-help-panel: var(--color-light-grey);
|
||||
--surface-tooltip: var(--color-light-grey);
|
||||
--surface-pulldown-panel: var(--color-light-grey);
|
||||
--surface-activatable-remove: var(--surface-control-default);
|
||||
--surface-slider-track: var(--color-medium-grey);
|
||||
@@ -89,6 +90,8 @@
|
||||
--layout-mode-toggle-local-width-factor: 3;
|
||||
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
|
||||
--layout-help-panel-width: calc(var(--interaction-height) * 8);
|
||||
--layout-tooltip-width: calc(var(--interaction-height) * 8);
|
||||
--radius-tooltip: var(--radius-interaction);
|
||||
--text-control-default: var(--color-font-dark);
|
||||
--text-control-disabled: var(--color-dark-grey);
|
||||
--text-button-process: var(--color-font-light);
|
||||
@@ -98,6 +101,7 @@
|
||||
--text-menu-link-portal: var(--color-font-dark);
|
||||
--text-help-icon: var(--color-font-dark);
|
||||
--text-help-panel: var(--color-font-dark);
|
||||
--text-tooltip: var(--color-font-dark);
|
||||
--text-activatable-remove: var(--text-control-default);
|
||||
--text-hyperlink: var(--color-font-hyperlink);
|
||||
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
|
||||
@@ -175,6 +179,8 @@
|
||||
--layout-object-card-mobile-width: var(--dimension-object-card-mobile-width);
|
||||
--layout-object-card-mobile-height: var(--dimension-object-card-mobile-height);
|
||||
--layout-object-card-desktop-breakpoint: var(--dimension-object-card-desktop-breakpoint);
|
||||
--layout-scrollable-card-width: var(--dimension-scrollable-card-width);
|
||||
--layout-scrollable-card-height: var(--dimension-scrollable-card-height);
|
||||
--layout-content-card-margin-top-desktop: var(--dimension-content-card-margin-top-desktop);
|
||||
--layout-content-card-margin-top-mobile: var(--dimension-content-card-margin-top-mobile);
|
||||
--layout-notification-card-width: var(--dimension-notification-card-width);
|
||||
@@ -287,6 +293,8 @@
|
||||
--dimension-object-card-mobile-width: 100%;
|
||||
--dimension-object-card-mobile-height: auto;
|
||||
--dimension-object-card-desktop-breakpoint: 768px;
|
||||
--dimension-scrollable-card-width: 600px;
|
||||
--dimension-scrollable-card-height: 600px;
|
||||
--dimension-content-card-margin-top-desktop: 100px;
|
||||
--dimension-content-card-margin-top-mobile: 1rem;
|
||||
--dimension-object-group-card-min-width: 450px;
|
||||
@@ -656,6 +664,13 @@ section + section {
|
||||
color: var(--text-control-default);
|
||||
}
|
||||
|
||||
.sg-button--warning {
|
||||
--surface-button-warning: var(--color-signal-red);
|
||||
--text-button-warning: var(--color-font-light);
|
||||
background: var(--surface-button-warning);
|
||||
color: var(--text-button-warning);
|
||||
}
|
||||
|
||||
.sg-button--process {
|
||||
background: var(--surface-button-process);
|
||||
color: var(--text-button-process);
|
||||
@@ -1749,6 +1764,95 @@ section + section {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sg-tooltip-panel {
|
||||
position: absolute;
|
||||
z-index: var(--layer-pulldown-panel);
|
||||
display: block;
|
||||
width: var(--layout-tooltip-width);
|
||||
max-width: calc(100vw - (2 * var(--spacing-large)));
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--spacing-large);
|
||||
border-radius: var(--radius-tooltip);
|
||||
color: var(--text-tooltip);
|
||||
background: var(--surface-tooltip);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.sg-tooltip-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
|
||||
top: calc(100% + var(--spacing-small));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
|
||||
bottom: calc(100% + var(--spacing-small));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
|
||||
top: 50%;
|
||||
right: calc(100% + var(--spacing-small));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
|
||||
top: 50%;
|
||||
left: calc(100% + var(--spacing-small));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-panel::before {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
|
||||
top: calc(var(--spacing-small) * -1);
|
||||
left: 50%;
|
||||
border-width: 0 var(--spacing-small) var(--spacing-small);
|
||||
border-color: transparent transparent var(--surface-tooltip);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
|
||||
bottom: calc(var(--spacing-small) * -1);
|
||||
left: 50%;
|
||||
border-width: var(--spacing-small) var(--spacing-small) 0;
|
||||
border-color: var(--surface-tooltip) transparent transparent;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
|
||||
top: 50%;
|
||||
right: calc(var(--spacing-small) * -1);
|
||||
border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small);
|
||||
border-color: transparent transparent transparent var(--surface-tooltip);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
|
||||
top: 50%;
|
||||
left: calc(var(--spacing-small) * -1);
|
||||
border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0;
|
||||
border-color: transparent var(--surface-tooltip) transparent transparent;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sg-pulldown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-small));
|
||||
@@ -2096,6 +2200,147 @@ section + section {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Component: File Input */
|
||||
.sg-file-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-file-input__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-file-input__control {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sg-file-input:focus-within .sg-file-input__trigger {
|
||||
outline: 2px solid var(--text-hyperlink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sg-file-input__trigger {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sg-file-input__row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.sg-file-input--disabled {
|
||||
opacity: var(--disabled-opacity);
|
||||
}
|
||||
|
||||
.sg-file-input--disabled .sg-file-input__trigger {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* Patterns: Datei-Upload */
|
||||
/* ========================================================= */
|
||||
|
||||
.sg-file-upload {
|
||||
--surface-file-upload-dropzone: var(--surface-form-preview);
|
||||
--surface-file-upload-file-item: var(--surface-control-default);
|
||||
--text-file-upload-meta: var(--text-control-disabled);
|
||||
--text-file-upload-error: var(--color-signal-red);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
max-width: var(--layout-input-field-max-width);
|
||||
}
|
||||
|
||||
.sg-file-upload__status,
|
||||
.sg-file-upload__file-meta {
|
||||
margin: var(--spacing-small) 0 0;
|
||||
}
|
||||
|
||||
.sg-file-upload__dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
width: 100%;
|
||||
padding: var(--spacing-large);
|
||||
background: var(--surface-file-upload-dropzone);
|
||||
border: 1px dashed var(--surface-button-active);
|
||||
border-radius: var(--radius-interaction);
|
||||
}
|
||||
|
||||
.sg-file-upload__dropzone[data-drag-over="true"] {
|
||||
outline: 2px solid var(--text-hyperlink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sg-file-upload__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-small);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sg-file-upload__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-large);
|
||||
padding: var(--spacing-small) var(--spacing-large);
|
||||
color: var(--text-control-default);
|
||||
background: var(--surface-file-upload-file-item);
|
||||
border-radius: var(--radius-interaction);
|
||||
}
|
||||
|
||||
.sg-file-upload__file-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sg-file-upload__file-meta {
|
||||
color: var(--text-file-upload-meta);
|
||||
}
|
||||
|
||||
.sg-file-upload__remove {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sg-file-upload__submit {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.sg-file-upload__error {
|
||||
color: var(--text-file-upload-error);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sg-file-upload__dropzone {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sg-file-upload__file {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-small);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================= */
|
||||
/* Patterns: Portal Header */
|
||||
/* ========================================================= */
|
||||
@@ -5031,3 +5276,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;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,75 @@ body.sg-card-list-page-drawer-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sg-market-mobile-detail-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-large);
|
||||
padding: var(--spacing-small);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
background: var(--color-background-purple);
|
||||
}
|
||||
|
||||
.sg-market-mobile-detail-host .sg-card-list-page-drawer {
|
||||
position: static;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body.sg-market-mobile-detail-open > :not([data-market-mobile-detail-host]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.market-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 0.8fr) minmax(0, 1.2fr);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.market-detail-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.market-detail-row dt,
|
||||
.market-detail-row dd {
|
||||
margin: 0;
|
||||
padding: var(--spacing-small) 0;
|
||||
border-bottom: 1px solid var(--color-light-grey);
|
||||
}
|
||||
|
||||
.market-detail-row dt {
|
||||
color: var(--color-black);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.market-detail-row dd {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.market-edit-form .sg-component-row {
|
||||
margin-top: var(--spacing-medium);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.market-edit-form .sg-labeled-input-row .sg-input-multi-line {
|
||||
width: 100%;
|
||||
max-width: var(--layout-form-input-field-max-width);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.market-detail-grid {
|
||||
grid-template-columns: minmax(8rem, 0.9fr) minmax(0, 1.1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -58,6 +127,10 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sg-bestellungen-mobile-detail-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -75,6 +148,48 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-lager-mobile-detail-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-large);
|
||||
padding: var(--spacing-small);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
background: var(--color-background-purple);
|
||||
}
|
||||
|
||||
.sg-lager-mobile-detail-host .sg-vsf-drawer-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sg-lager-mobile-detail-host .sg-card-list-page-drawer {
|
||||
position: static;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sg-bestellungen-mobile-detail-host .sg-card-list-page-drawer {
|
||||
position: static;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sg-mobile-detail-back-card,
|
||||
.sg-mobile-detail-back-card * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -91,9 +206,8 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay__panel {
|
||||
width: min(960px, 100%);
|
||||
max-height: calc(100vh - (2 * var(--spacing-large)));
|
||||
overflow: auto;
|
||||
--layout-scrollable-card-width: min(960px, 100%);
|
||||
--layout-scrollable-card-height: min(600px, calc(100vh - (2 * var(--spacing-large))));
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay__title {
|
||||
@@ -112,6 +226,19 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
color: var(--color-signal-red);
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay [data-otc-order-product] {
|
||||
flex: 0 0 6rem;
|
||||
width: 6rem;
|
||||
max-width: 6rem;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay .sg-otc-order-product-row .sg-label {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sg-otc-order-overlay .sg-form-sections-card__field-group {
|
||||
gap: var(--spacing-small);
|
||||
}
|
||||
@@ -128,6 +255,14 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sg-otc-order-overlay [data-otc-order-product] {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sg-large-table--bestellungen .sg-search-field-row {
|
||||
width: min(100%, calc(var(--layout-search-field-width) * 0.75));
|
||||
}
|
||||
@@ -180,8 +315,12 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sg-large-table--markets .sg-large-table__row {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: 30% repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {
|
||||
@@ -204,6 +343,87 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail-
|
||||
color: var(--color-signal-yellow);
|
||||
}
|
||||
|
||||
.sg-large-table--markets .market-status--red {
|
||||
color: var(--color-signal-red);
|
||||
}
|
||||
|
||||
.sg-large-table--markets .market-status--yellow {
|
||||
color: var(--color-signal-yellow);
|
||||
}
|
||||
|
||||
.sg-large-table--markets .market-status--green {
|
||||
color: var(--color-signal-green);
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-lager-edit-form][hidden],
|
||||
.sg-card-list-page-drawer [data-lager-read-view][hidden],
|
||||
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-form"][hidden],
|
||||
.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],
|
||||
.sg-card-list-page-drawer [data-market-definition-read-card][hidden],
|
||||
.sg-card-list-page-drawer [data-market-definition-create-card][hidden],
|
||||
.sg-card-list-page-drawer [data-market-definition-new-organizer][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-market-new-municipality][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-shared-editable-card-read][hidden],
|
||||
[data-shared-editable-card-form][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-market-definition-card] .sg-object-card__header > .sg-component-row {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-market-delete-occurrence-id],
|
||||
.sg-card-list-page-drawer [data-market-definition-delete-id] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-large);
|
||||
--layout-delete-confirmation-overlay-offset-block-start: 100px;
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-market-delete-occurrence-id] > .sg-component-row,
|
||||
.sg-card-list-page-drawer [data-market-definition-delete-id] > .sg-component-row,
|
||||
.sg-card-list-page-drawer .market-delete-confirmation-card .sg-component-row {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.market-delete-confirmation-card__title {
|
||||
margin: 0;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.market-days-group__title {
|
||||
margin: var(--spacing-small) 0 0;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.market-required-field-indicator {
|
||||
position: relative;
|
||||
top: -0.25em;
|
||||
margin-left: 0.15em;
|
||||
color: var(--color-signal-red);
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-component="content-group-box"] {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-action"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-small);
|
||||
}
|
||||
|
||||
.sg-card-list-page-drawer__title {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../modules/shared/auth/service.php';
|
||||
|
||||
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
if ($requestMethod !== 'GET') {
|
||||
http_response_code(405);
|
||||
header('Allow: GET');
|
||||
echo 'Methode nicht erlaubt.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
|
||||
$pdo = connect_database($env);
|
||||
auth_bootstrap_session();
|
||||
auth_require_user($pdo);
|
||||
|
||||
$documentRoot = rtrim(env_value('HERMES_DOCUMENT_ROOT', $env), DIRECTORY_SEPARATOR);
|
||||
if ($documentRoot === '' || !str_starts_with($documentRoot, DIRECTORY_SEPARATOR)) {
|
||||
http_response_code(500);
|
||||
echo 'Dokumentablage ist nicht konfiguriert.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$resolvedRoot = realpath($documentRoot);
|
||||
$relativePath = (string) ($_GET['path'] ?? '');
|
||||
|
||||
if ($resolvedRoot === false || !is_dir($resolvedRoot) || $relativePath === '' || str_contains($relativePath, "\0") || str_contains($relativePath, '\\') || str_starts_with($relativePath, '/')) {
|
||||
http_response_code(404);
|
||||
echo 'Dokument nicht gefunden.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$segments = explode('/', $relativePath);
|
||||
foreach ($segments as $segment) {
|
||||
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||
http_response_code(404);
|
||||
echo 'Dokument nicht gefunden.';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedFile = realpath($resolvedRoot . DIRECTORY_SEPARATOR . $relativePath);
|
||||
$rootPrefix = $resolvedRoot . DIRECTORY_SEPARATOR;
|
||||
if ($resolvedFile === false || !is_file($resolvedFile) || !is_readable($resolvedFile) || !str_starts_with($resolvedFile, $rootPrefix)) {
|
||||
http_response_code(404);
|
||||
echo 'Dokument nicht gefunden.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$fileSize = filesize($resolvedFile);
|
||||
$mimeType = mime_content_type($resolvedFile);
|
||||
if ($fileSize === false || $mimeType === false) {
|
||||
http_response_code(500);
|
||||
echo 'Dokument konnte nicht bereitgestellt werden.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$downloadName = basename($resolvedFile);
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . (string) $fileSize);
|
||||
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($downloadName));
|
||||
header('Cache-Control: private, no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($resolvedFile);
|
||||
+39
-1
@@ -6,7 +6,9 @@ require_once __DIR__ . '/../modules/shared/auth/ui/login.php';
|
||||
require_once __DIR__ . '/../modules/shared/auth/ui/home.php';
|
||||
require_once __DIR__ . '/../modules/erp/bestellungen/service.php';
|
||||
require_once __DIR__ . '/../modules/erp/lager/service.php';
|
||||
require_once __DIR__ . '/../modules/erp/kontakte/service.php';
|
||||
require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php';
|
||||
require_once __DIR__ . '/../modules/market_management/service.php';
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
|
||||
$pdo = connect_database($env);
|
||||
@@ -47,7 +49,9 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$currentUser = auth_current_user($pdo);
|
||||
if ($currentUser !== null) {
|
||||
$otcProducts = get_otc_order_form_products($pdo);
|
||||
$otcMarketOptions = market_management_list_active_occurrence_options($pdo);
|
||||
$lagerProducts = get_lager_product_overview($pdo);
|
||||
$lagerManufacturers = get_manufacturer_options($pdo);
|
||||
$lagerArtikel = get_lager_artikel_overview($pdo);
|
||||
$bestellungenTable = get_sales_order_overview($pdo, [
|
||||
'search' => (string) ($_GET['bestellungen_search'] ?? ''),
|
||||
@@ -56,11 +60,45 @@ if ($currentUser !== null) {
|
||||
'limit' => (int) ($_GET['bestellungen_limit'] ?? 20),
|
||||
'page_size' => 20,
|
||||
]);
|
||||
$marketSearch = (string) ($_GET['market_search'] ?? '');
|
||||
$marketSort = (string) ($_GET['market_sort'] ?? 'earliest_date');
|
||||
$marketDirection = strtoupper((string) ($_GET['market_dir'] ?? 'ASC'));
|
||||
$marketLimit = min(100, max(1, (int) ($_GET['market_limit'] ?? 20)));
|
||||
$marketOccurrencesTable = [
|
||||
'rows' => market_management_search_occurrences($pdo, [
|
||||
'search' => $marketSearch,
|
||||
'sort' => $marketSort,
|
||||
'sort_direction' => $marketDirection,
|
||||
'limit' => $marketLimit,
|
||||
'offset' => 0,
|
||||
]),
|
||||
'search' => $marketSearch,
|
||||
'sort_column' => $marketSort,
|
||||
'sort_direction' => $marketDirection,
|
||||
'limit' => $marketLimit,
|
||||
'page_size' => 20,
|
||||
];
|
||||
$marketOccurrencesTable['has_more'] = count($marketOccurrencesTable['rows']) >= $marketLimit;
|
||||
$marketDefinitionsTable = [
|
||||
'rows' => market_management_search_markets($pdo, [
|
||||
'include_archived' => false,
|
||||
'sort' => 'name',
|
||||
'sort_direction' => 'ASC',
|
||||
'limit' => 100,
|
||||
'offset' => 0,
|
||||
]),
|
||||
'categories' => market_management_list_categories($pdo),
|
||||
'organizers' => market_management_list_organizers($pdo),
|
||||
];
|
||||
if ((string) ($_GET['bestellungen_fragment'] ?? '') === '1') {
|
||||
echo auth_render_bestellungen_content($bestellungenTable);
|
||||
exit;
|
||||
}
|
||||
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel);
|
||||
if ((string) ($_GET['market_fragment'] ?? '') === '1') {
|
||||
echo auth_render_markets_content($marketOccurrencesTable);
|
||||
exit;
|
||||
}
|
||||
render_auth_home_page($currentUser, $otcProducts, $otcMarketOptions, $bestellungenTable, $marketOccurrencesTable, $lagerProducts, $lagerArtikel, $lagerManufacturers, $marketDefinitionsTable);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const cardSelector = '[data-shared-editable-action-card]';
|
||||
|
||||
const getCard = (element) => element.closest(cardSelector);
|
||||
|
||||
const getDirectDeleteElement = (card, selector) => Array.from(card?.children || [])
|
||||
.find((child) => child.matches(selector)) || null;
|
||||
|
||||
const setEditMode = (card, enabled) => {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readView = card.querySelector('[data-shared-editable-card-read]');
|
||||
const editView = card.querySelector('[data-shared-editable-card-form]');
|
||||
const editButtons = card.querySelectorAll('[data-shared-editable-card-edit]');
|
||||
|
||||
if (readView) {
|
||||
readView.hidden = enabled;
|
||||
}
|
||||
if (editView) {
|
||||
editView.hidden = !enabled;
|
||||
}
|
||||
editButtons.forEach((editButton) => {
|
||||
editButton.disabled = enabled;
|
||||
editButton.setAttribute('aria-disabled', String(enabled));
|
||||
editButton.classList.toggle('sg-button--inactive', enabled);
|
||||
});
|
||||
};
|
||||
|
||||
const resetDeleteConfirmation = (card) => {
|
||||
const stage = card?.closest('.sg-delete-confirmation-pattern__stage');
|
||||
const confirmation = getDirectDeleteElement(card, '[data-shared-editable-card-delete-confirmation]');
|
||||
const input = confirmation?.querySelector('[data-shared-editable-card-delete-input]');
|
||||
const submit = confirmation?.querySelector('[data-shared-editable-card-delete-submit]');
|
||||
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
if (submit) {
|
||||
submit.disabled = true;
|
||||
submit.setAttribute('aria-disabled', 'true');
|
||||
submit.classList.add('sg-button--process-inactive');
|
||||
}
|
||||
if (confirmation) {
|
||||
confirmation.hidden = true;
|
||||
}
|
||||
if (stage) {
|
||||
stage.dataset.dialogOpen = 'false';
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteConfirmation = (card) => {
|
||||
const stage = card?.closest('.sg-delete-confirmation-pattern__stage');
|
||||
const confirmation = getDirectDeleteElement(card, '[data-shared-editable-card-delete-confirmation]');
|
||||
const input = confirmation?.querySelector('[data-shared-editable-card-delete-input]');
|
||||
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
if (confirmation) {
|
||||
confirmation.hidden = false;
|
||||
}
|
||||
if (stage) {
|
||||
stage.dataset.dialogOpen = 'true';
|
||||
}
|
||||
};
|
||||
|
||||
const updateDeleteButton = (input) => {
|
||||
const card = getCard(input);
|
||||
const confirmation = input?.closest('[data-shared-editable-card-delete-confirmation]');
|
||||
const submit = confirmation?.querySelector('[data-shared-editable-card-delete-submit]');
|
||||
const token = card?.dataset.sharedEditableDeleteToken || 'DELETE';
|
||||
const valid = input.value === token;
|
||||
|
||||
if (submit) {
|
||||
submit.disabled = !valid;
|
||||
submit.setAttribute('aria-disabled', String(!valid));
|
||||
submit.classList.toggle('sg-button--process-inactive', !valid);
|
||||
}
|
||||
};
|
||||
|
||||
const init = (root) => {
|
||||
if (!root || root.dataset.sharedEditableActionCardsInitialized === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.sharedEditableActionCardsInitialized = 'true';
|
||||
root.querySelectorAll(cardSelector).forEach((card) => setEditMode(card, false));
|
||||
root.addEventListener('click', (event) => {
|
||||
const edit = event.target.closest('[data-shared-editable-card-edit]');
|
||||
if (edit && root.contains(edit)) {
|
||||
setEditMode(getCard(edit), true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cancel = event.target.closest('[data-shared-editable-card-cancel]');
|
||||
if (cancel && root.contains(cancel)) {
|
||||
const card = getCard(cancel);
|
||||
resetDeleteConfirmation(card);
|
||||
setEditMode(card, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteButton = event.target.closest('[data-shared-editable-card-delete]');
|
||||
if (deleteButton && root.contains(deleteButton)) {
|
||||
openDeleteConfirmation(getCard(deleteButton));
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteCancel = event.target.closest('[data-shared-editable-card-delete-cancel]');
|
||||
if (deleteCancel && root.contains(deleteCancel)) {
|
||||
resetDeleteConfirmation(getCard(deleteCancel));
|
||||
}
|
||||
});
|
||||
|
||||
root.addEventListener('input', (event) => {
|
||||
const input = event.target.closest('[data-shared-editable-card-delete-input]');
|
||||
if (input && root.contains(input)) {
|
||||
updateDeleteButton(input);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.SharedEditableActionCard = { init, resetDeleteConfirmation, setEditMode };
|
||||
}());
|
||||
Reference in new issue
Block a user