diff --git a/db/README.md b/db/README.md index a3a8f84..d220baf 100644 --- a/db/README.md +++ b/db/README.md @@ -13,6 +13,7 @@ 9. `db/migrations/0013_virtual_shortage_lot_allocations.sql` 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` ## Beispielausfuehrung diff --git a/db/migrations/0016_stock_lot_forecast_history.sql b/db/migrations/0016_stock_lot_forecast_history.sql new file mode 100644 index 0000000..2cd7609 --- /dev/null +++ b/db/migrations/0016_stock_lot_forecast_history.sql @@ -0,0 +1,50 @@ +BEGIN; + +ALTER TABLE stock_lot + ADD COLUMN IF NOT EXISTS sellout_forecast_status TEXT NOT NULL DEFAULT 'no_demand', + ADD COLUMN IF NOT EXISTS sellout_forecast_confidence TEXT NOT NULL DEFAULT 'none', + ADD COLUMN IF NOT EXISTS sellout_forecast_daily_demand NUMERIC(14, 6) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS sellout_forecast_calculated_at TIMESTAMP; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_stock_lot_forecast_status') THEN + ALTER TABLE stock_lot ADD CONSTRAINT chk_stock_lot_forecast_status + CHECK (sellout_forecast_status IN ('forecast', 'depleted', 'no_demand')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_stock_lot_forecast_confidence') THEN + ALTER TABLE stock_lot ADD CONSTRAINT chk_stock_lot_forecast_confidence + CHECK (sellout_forecast_confidence IN ('high', 'medium', 'low', 'none')); + END IF; +END; +$$; + +CREATE TABLE IF NOT EXISTS stock_lot_forecast_history ( + id BIGSERIAL PRIMARY KEY, + stock_lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE RESTRICT, + product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE RESTRICT, + calculated_at TIMESTAMP NOT NULL DEFAULT NOW(), + as_of_date DATE NOT NULL, + physical_qty NUMERIC(14, 4) NOT NULL, + weighted_daily_demand NUMERIC(14, 6) NOT NULL DEFAULT 0, + sales_units NUMERIC(14, 4) NOT NULL DEFAULT 0, + sales_days INTEGER NOT NULL DEFAULT 0, + observed_span_days INTEGER NOT NULL DEFAULT 0, + lookback_days INTEGER NOT NULL, + half_life_days INTEGER NOT NULL, + forecast_date DATE, + forecast_status TEXT NOT NULL, + confidence TEXT NOT NULL, + error_message TEXT, + CONSTRAINT chk_stock_lot_forecast_history_status + CHECK (forecast_status IN ('forecast', 'depleted', 'no_demand')), + CONSTRAINT chk_stock_lot_forecast_history_confidence + CHECK (confidence IN ('high', 'medium', 'low', 'none')) +); + +CREATE INDEX IF NOT EXISTS idx_stock_lot_forecast_history_lot_date + ON stock_lot_forecast_history(stock_lot_id, calculated_at DESC); +CREATE INDEX IF NOT EXISTS idx_stock_lot_forecast_history_product_date + ON stock_lot_forecast_history(product_id, calculated_at DESC); + +COMMIT; diff --git a/docs/modules/erp/lager/processes/main/erp.lager.stock_lot_forecast_refresh.md b/docs/modules/erp/lager/processes/main/erp.lager.stock_lot_forecast_refresh.md new file mode 100644 index 0000000..ba8848a --- /dev/null +++ b/docs/modules/erp/lager/processes/main/erp.lager.stock_lot_forecast_refresh.md @@ -0,0 +1,94 @@ +# erp.lager.stock_lot_forecast_refresh +Stand: 2026-09-06 +Status: Verbindliche Prozess-Spezifikation + +## 1. Zweck + +Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder aktuellen Charge aktiver Produkte. + +## 2. Prozess-Einbettung + +Direkt ausgeführter, täglich um 06:00 Uhr gestarteter Lager-Prozess ü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 + +## 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. + +## 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. + +## 6. Output + +Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produkten, Fehleranzahl und fehlerhaften Produkt-IDs. + +## 7. Erfolgskriterien + +- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot. +- Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage. +- Die vollständige Berechnungshistorie bleibt erhalten. +- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen. +- Eine leere Charge erhält das aktuelle Datum als Leer-Datum. + +## 8. Fehlerschranke + +Einzelne fachliche oder technische Fehler je Produkt führen zu `partial_success` und werden mit Produkt-ID erfasst. Nicht verfügbare Kernquellen, Datenbankfehler oder fehlende Prozessinfrastruktur führen zu `failed`. + +## 9. Fachliche Betriebsregel + +Die GUI zeigt das aktuelle Leer-Datum in der Produkttabelle. Die Darstellung wird rot und fett, wenn die Charge leer ist oder das Leer-Datum innerhalb von drei Kalendermonaten vor dem MHD liegt. + +## 10. Sub-Prozess-Referenzen + +Keine. + +## 11. End-to-End Sub-Prozess-Kette + +ERP-Cron um 06:00 Uhr → Forecast-Prozess → Nachfrageaggregation → Chargenprognose → Historie und aktueller Snapshot → Lager/Produkte-Tabelle. + +## 12. Sub-Prozess-Wiederverwendung + +Die bestehende Lagerbestands-View `v_stock_lot_balance` und die bestehende Prozesslauf-Tabelle `process_runs` werden wiederverwendet. Ein zusätzlicher Sub-Prozess ist für den kleinen, deterministischen Batch nicht erforderlich. + +## 13. Step-Liste + +1. `load_current_lots` +2. `load_daily_sales` +3. `calculate_weighted_demand` +4. `persist_forecast` +5. `aggregate_result` + +## 14. Parallelisierung + +Nicht erforderlich. Die Produktmenge ist klein; die Verarbeitung erfolgt deterministisch und seriell mit toleranter Einzelfehlerbehandlung. + +## 15. Output-Payload + +```json +{ + "status": "done|partial_success|failed", + "total_products": 0, + "processed_products": 0, + "failed_products": 0, + "failed_product_ids": [] +} +``` + +## 16. Done-Kriterien + +- Prozesslauf in `process_runs` abgeschlossen. +- Erfolgreiche Prognosen in `stock_lot_forecast_history` gespeichert. +- Aktuelle Chargen-Snapshots aktualisiert. +- Produktfehler vollständig und kompakt im Prozessresultat erfasst. diff --git a/docs/modules/system/README.md b/docs/modules/system/README.md index 658fd41..27d7caa 100644 --- a/docs/modules/system/README.md +++ b/docs/modules/system/README.md @@ -15,6 +15,7 @@ Technische Laufzeit-, Start-, Trigger- und Supervisor-Logik. ## Technisch umgesetzt - `modules/system/deploy.php` +- `modules/system/naurua-erp-cron.php` ## Wrapper diff --git a/modules/erp/lager/forecast.php b/modules/erp/lager/forecast.php new file mode 100644 index 0000000..f7daa04 --- /dev/null +++ b/modules/erp/lager/forecast.php @@ -0,0 +1,223 @@ +prepare( + "INSERT INTO process_runs (process_name, status, scope_json, requested_by) + VALUES ('erp.lager.stock_lot_forecast_refresh', 'running', :scope_json::jsonb, 'cron') + RETURNING id" + ); + $stmt->execute([ + ':scope_json' => json_encode(['lookback_days' => 365, 'half_life_days' => 60], JSON_THROW_ON_ERROR), + ]); + return (int) $stmt->fetchColumn(); +} + +function lager_forecast_finish_run(PDO $pdo, int $runId, string $status, array $result, ?Throwable $error = null): void +{ + $stmt = $pdo->prepare( + 'UPDATE process_runs + SET status = :status, result_json = :result_json::jsonb, finished_at = NOW(), + error_code = :error_code, error_message = :error_message + WHERE id = :id' + ); + $stmt->execute([ + ':status' => $status, + ':result_json' => json_encode($result, JSON_THROW_ON_ERROR), + ':error_code' => $error === null ? null : 'stock_lot_forecast_failed', + ':error_message' => $error?->getMessage(), + ':id' => $runId, + ]); +} + +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 + 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 + WHERE p.status = 'active' + ORDER BY p.id" + ); + return $stmt->fetchAll(); +} + +function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate, int $lookbackDays): array +{ + $startDate = $asOfDate->modify('-' . ($lookbackDays - 1) . ' days')->format('Y-m-d'); + $endDate = $asOfDate->modify('+1 day')->format('Y-m-d'); + $stmt = $pdo->prepare( + "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 + INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id + INNER JOIN sales_order so ON so.id = sol.sales_order_id + INNER JOIN product p ON p.id = a.product_id + WHERE p.status = 'active' + AND a.allocation_status IN ('allocated', 'reserved') + 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]); + + $sales = []; + foreach ($stmt->fetchAll() as $row) { + $sales[(int) $row['product_id']][(string) $row['sale_date']] = (float) $row['units']; + } + return $sales; +} + +function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQty, array $dailySales): array +{ + $lookbackDays = 365; + $halfLifeDays = 60; + $weightedUnits = 0.0; + $weightTotal = 0.0; + $salesUnits = 0.0; + $salesDays = 0; + $firstSaleDate = null; + $lastSaleDate = null; + + for ($age = 0; $age < $lookbackDays; $age++) { + $date = $asOfDate->modify('-' . $age . ' days'); + $dateKey = $date->format('Y-m-d'); + $units = max(0.0, (float) ($dailySales[$dateKey] ?? 0.0)); + $weight = 2 ** (-$age / $halfLifeDays); + $weightedUnits += $units * $weight; + $weightTotal += $weight; + if ($units > 0) { + $salesUnits += $units; + $salesDays++; + $firstSaleDate = $dateKey; + $lastSaleDate ??= $dateKey; + } + } + + $dailyDemand = $weightTotal > 0 ? $weightedUnits / $weightTotal : 0.0; + $observedSpanDays = $firstSaleDate === null + ? 0 + : (new DateTimeImmutable($firstSaleDate))->diff(new DateTimeImmutable($lastSaleDate))->days + 1; + + if ($physicalQty <= 0.0000001) { + return [ + 'forecast_date' => $asOfDate->format('Y-m-d'), 'forecast_status' => 'depleted', + 'confidence' => $dailyDemand > 0 ? 'low' : 'none', 'daily_demand' => $dailyDemand, + 'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays, + 'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays, + ]; + } + if ($dailyDemand <= 0.0000001) { + return [ + 'forecast_date' => null, 'forecast_status' => 'no_demand', 'confidence' => 'none', + 'daily_demand' => 0.0, 'sales_units' => $salesUnits, 'sales_days' => $salesDays, + 'observed_span_days' => $observedSpanDays, 'lookback_days' => $lookbackDays, + 'half_life_days' => $halfLifeDays, + ]; + } + + $confidence = $salesDays >= 20 && $observedSpanDays >= 180 + ? 'high' + : ($salesDays >= 10 ? 'medium' : 'low'); + $daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand); + return [ + 'forecast_date' => $asOfDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'), + 'forecast_status' => 'forecast', 'confidence' => $confidence, 'daily_demand' => $dailyDemand, + 'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays, + 'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays, + ]; +} + +function lager_forecast_persist(PDO $pdo, array $lot, array $forecast, DateTimeImmutable $asOfDate): void +{ + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare( + "INSERT INTO stock_lot_forecast_history ( + stock_lot_id, product_id, as_of_date, physical_qty, weighted_daily_demand, + sales_units, sales_days, observed_span_days, lookback_days, half_life_days, + forecast_date, forecast_status, confidence + ) VALUES ( + :stock_lot_id, :product_id, :as_of_date, :physical_qty, :weighted_daily_demand, + :sales_units, :sales_days, :observed_span_days, :lookback_days, :half_life_days, + :forecast_date, :forecast_status, :confidence + )" + ); + $stmt->execute([ + ':stock_lot_id' => (int) $lot['lot_id'], ':product_id' => (int) $lot['product_id'], + ':as_of_date' => $asOfDate->format('Y-m-d'), ':physical_qty' => (float) $lot['physical_qty'], + ':weighted_daily_demand' => $forecast['daily_demand'], ':sales_units' => $forecast['sales_units'], + ':sales_days' => $forecast['sales_days'], ':observed_span_days' => $forecast['observed_span_days'], + ':lookback_days' => $forecast['lookback_days'], ':half_life_days' => $forecast['half_life_days'], + ':forecast_date' => $forecast['forecast_date'], ':forecast_status' => $forecast['forecast_status'], + ':confidence' => $forecast['confidence'], + ]); + + $stmt = $pdo->prepare( + "UPDATE stock_lot SET sellout_date = :sellout_date, + sellout_forecast_status = :forecast_status, + sellout_forecast_confidence = :confidence, + sellout_forecast_daily_demand = :daily_demand, + sellout_forecast_calculated_at = NOW(), updated_at = NOW() + WHERE id = :lot_id" + ); + $stmt->execute([ + ':sellout_date' => $forecast['forecast_date'], ':forecast_status' => $forecast['forecast_status'], + ':confidence' => $forecast['confidence'], ':daily_demand' => $forecast['daily_demand'], + ':lot_id' => (int) $lot['lot_id'], + ]); + $pdo->commit(); + } catch (Throwable $error) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $error; + } +} + +function run_lager_stock_lot_forecast(PDO $pdo): array +{ + $runId = lager_forecast_start_run($pdo); + $asOfDate = new DateTimeImmutable('today', new DateTimeZone('Europe/Zurich')); + $result = [ + 'status' => 'done', 'total_products' => 0, 'processed_products' => 0, + 'failed_products' => 0, 'failed_product_ids' => [], + ]; + + try { + $lots = lager_forecast_load_current_lots($pdo); + $result['total_products'] = count($lots); + $dailySales = lager_forecast_load_daily_sales($pdo, $asOfDate, 365); + foreach ($lots as $lot) { + $productId = (int) $lot['product_id']; + if ($lot['lot_id'] === null) { + $result['failed_products']++; + $result['failed_product_ids'][] = $productId; + continue; + } + try { + $forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []); + lager_forecast_persist($pdo, $lot, $forecast, $asOfDate); + $result['processed_products']++; + } catch (Throwable $error) { + $result['failed_products']++; + $result['failed_product_ids'][] = $productId; + } + } + if ($result['failed_products'] > 0) { + $result['status'] = 'partial_success'; + } + lager_forecast_finish_run($pdo, $runId, $result['status'], $result); + return $result; + } catch (Throwable $error) { + $result['status'] = 'failed'; + lager_forecast_finish_run($pdo, $runId, 'failed', $result, $error); + throw $error; + } +} diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 6e67a14..9eb2f00 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -72,7 +72,10 @@ function get_lager_product_overview(PDO $pdo): array p.name, p.sku, p.uom, - COALESCE(SUM(v.qty_net), 0) AS available_qty + COALESCE(SUM(v.qty_net), 0) AS available_qty, + sl.expiry_date, + sl.sellout_date, + sl.sellout_forecast_status FROM product p LEFT JOIN stock_lot sl ON sl.product_id = p.id @@ -80,7 +83,7 @@ function get_lager_product_overview(PDO $pdo): array LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE p.status = 'active' - GROUP BY p.id, p.name, p.sku, p.uom + GROUP BY p.id, p.name, p.sku, p.uom, sl.expiry_date, sl.sellout_date, sl.sellout_forecast_status ORDER BY p.name ASC, p.id ASC" ); @@ -91,6 +94,9 @@ function get_lager_product_overview(PDO $pdo): array 'sku' => (string) $row['sku'], 'uom' => (string) $row['uom'], 'available_qty' => (float) $row['available_qty'], + 'expiry_date' => $row['expiry_date'], + 'sellout_date' => $row['sellout_date'], + 'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'), ], $stmt->fetchAll() ); diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index de08341..9597a40 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -223,6 +223,23 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str $value = $tableKey === 'products' ? (string) max(0, (int) round((float) ($row['available_qty'] ?? 0))) : (string) ($row['item_code'] ?? ''); + $forecastText = ''; + $forecastWarning = false; + if ($tableKey === 'products') { + $forecastStatus = (string) ($row['sellout_forecast_status'] ?? 'no_demand'); + $forecastDate = $row['sellout_date'] ?? null; + if ($forecastDate !== null && $forecastDate !== '') { + $forecastDateObject = new DateTimeImmutable((string) $forecastDate); + $forecastText = $forecastDateObject->format('d.m.Y'); + $forecastWarning = $forecastStatus === 'depleted'; + if (!$forecastWarning && !empty($row['expiry_date'])) { + $expiryDate = new DateTimeImmutable((string) $row['expiry_date']); + $forecastWarning = $forecastDateObject >= $expiryDate->modify('-3 months'); + } + } else { + $forecastText = 'Keine Prognose'; + } + } $nameData = ' data-lager-drawer-name="' . auth_escape_html($name) . '"'; $valueData = ' data-lager-drawer-value="' . auth_escape_html($value) . '"'; $typeData = ' data-lager-drawer-type="' . auth_escape_html($tableKey === 'products' ? 'Produkt' : 'Artikel') . '"'; @@ -230,6 +247,10 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str $html[] = '
'; $html[] = '
'; $html[] = '
'; + if ($tableKey === 'products') { + $forecastClass = $forecastWarning ? ' sg-strong lager-forecast-warning' : ''; + $html[] = '
' . auth_escape_html($forecastText) . '
'; + } $html[] = '
'; } @@ -241,7 +262,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): s { return '
' . '
' - . auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager'], 'products') + . auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products') . auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles') . '
' . '' diff --git a/modules/system/naurua-erp-cron.php b/modules/system/naurua-erp-cron.php new file mode 100644 index 0000000..317f8d2 --- /dev/null +++ b/modules/system/naurua-erp-cron.php @@ -0,0 +1,33 @@ + 'skipped', 'reason' => 'already_running'], JSON_THROW_ON_ERROR) . PHP_EOL; + exit; +} + +$env = expand_env_values(parse_env_file(dirname(__DIR__, 2) . '/.env')); +$pdo = connect_database($env); + +try { + $jobs = [ + 'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo), + ]; + $results = []; + foreach ($jobs as $name => $job) { + $results[$name] = $job(); + } + echo json_encode(['status' => 'done', 'jobs' => $results], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . PHP_EOL); + exit(1); +} diff --git a/public/assets/styles.portal.css b/public/assets/styles.portal.css index b9d0529..59e50bc 100644 --- a/public/assets/styles.portal.css +++ b/public/assets/styles.portal.css @@ -161,10 +161,18 @@ body.sg-bestellungen-mobile-detail-open > :not([data-bestellungen-mobile-detail- grid-template-columns: repeat(2, minmax(0, 1fr)); } +.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child { text-align: left; } +.sg-large-table--lager .lager-forecast-warning { + color: var(--color-signal-red); +} + .sg-card-list-page-drawer__title { color: var(--color-white); }