# Baza surowców — Projekt architektoniczny v1

> Dokument przygotowany na podstawie analizy kodu źródłowego projektów `api` i `supplier` oraz mockupu Recipe Calculator.
> Wersja: 1.1 | Status: DRAFT do weryfikacji | Data: 2026-05-05

---

## Changelog

| Wersja | Data | Zmiana |
|---|---|---|
| 1.0 | 2026-05-05 | Pierwsza wersja — analiza kodu + projekt domeny |
| 1.1 | 2026-05-05 | Mockup Recipe Calculator, decyzje: usuwanie surowca (twarde z potwierdzeniem), waluta per dostawca (`material_supplier.currency`), kategorie per dostawca, widok szczegółu jako modal |
| 1.2 | 2026-05-05 | Analiza rzeczywistego schematu bazy — korekty: `allergen` nie ma pola `name` (tylko `code`), `material_supplier.id` i `unit.id` są `int(11)` nie `INT UNSIGNED`; 5 plików migracji |

---

## 0. Analiza mockupu Recipe Calculator

> Plik: `.github/examples/05052026/recipe_calculator_mockup.html`

### 0.1 Struktura Recipe Calculator

Mockup prezentuje pełny kalkulator receptur dla dostawcy jako jedną stronę podzieloną na **8 kafelków (tiles)**:

| Tile | Tytuł | Opis |
|---|---|---|
| **1** | Product | Wybór produktu z katalogu dostawcy |
| **2** | Raw materials | **← Bezpośredni kontekst dla Bazy surowców** |
| 3 | Labor | Czas produkcji i koszt robocizny |
| 4 | Sales unit | Hierarchia: porcja → opakowanie → karton |
| 5 | Overhead | Koszty ogólne per porcja |
| 6 | Vidanges | Kaucja za opakowania zwrotne |
| 7 | P&L summary | Zsumowane koszty + slider marży → cena rekomendowana |
| 8 | Max recommended network price | Maksymalna cena dla sieci |

### 0.2 Tile 2 — Raw materials (kluczowy dla Bazy surowców)

Mockup tile 2 to tabela składników receptury:

```
Ingredient         | Qty (g) | € / kg | %    | Cost (€)
───────────────────┼─────────┼────────┼──────┼─────────
Flour T55          |     200 |   0.40 | 58.0 |     0.08
Butter (tourage)   |     100 |   8.00 | 29.0 |     0.80
Sugar              |      20 |   1.20 |  5.8 |     0.02
Yeast              |       5 |  15.00 |  1.5 |     0.08
Chocolate sticks   |      20 |  12.00 |  5.8 |     0.24
───────────────────┴─────────┴────────┴──────┴─────────
5 ingredients · 345 g total              Total: €1.22
```

**Alergeny** — widoczne pod tabelą składników:
> "Allergens · auto-detected from ingredient master data" → `Gluten` · `Milk`

**Sekcja techniczna** — notatka poniżej alergenów:
> "Comments / Supplier technical sheet" → textarea z notatkami procesowymi

### 0.3 Wnioski z mockupu dla projektu Bazy surowców

✅ Mockup potwierdza decyzje projektowe:

| Obserwacja z mockupu | Implikacja dla modelu surowca |
|---|---|
| Kolumna `€ / kg` — cena podana per kg | `current_price_net` to cena per **jednostka bazowa** surowca (nie per opakowanie) |
| `Qty (g)` — ilość w gramach, cena w EUR/kg | Kalkulator receptury przelicza: `qty_g / 1000 * price_net_per_kg` → wymaga spójności jednostek |
| Alergeny auto-detected | Alergeny **przechowywane na surowcu** (`supplier_raw_material_allergen`), nie wpisywane ręcznie na recepturze |
| `% of total` — procent udziału w recepturze | Pole obliczeniowe, nie przechowywane |
| Textarea "Supplier technical sheet" | Pole `description` / `technical_notes` na surowcu **i** na recepturze |
| Waluta EUR widoczna w całym kalkulatorze | Waluta pochodzi z kontekstu dostawcy — słuszna decyzja: `material_supplier.currency` |

### 0.4 Tone produktu (dla implementacji UI)

Mockup definiuje styl wizualny portalu dostawcy:
- Jasne tło `#FAF8F5`, białe karty z `border-radius: 12px`
- Typografia: `Plus Jakarta Sans` (sans) + `Fraunces` (display/liczby)
- Kolor brandowy: `#8D1D2C` (ciemna czerwień)
- Kolory tekstów: `#1A1A1A` (primary), `#6B6B6B` (secondary), `#9C9A93` (muted)
- Tabele bez prążkowania — separator tylko `border-bottom` per wiersz
- Liczby: `font-variant-numeric: tabular-nums`, wyrównane do prawej
- Ciepłe morelowe akcenty dla wersji/tagów: `background: #FBEFE0; color: #6B3F18`

> ⚠️ **Uwaga dla implementacji UI w `supplier`:** Portal aktualnie używa Bootstrap 5. Mockup definiuje **ton i hierarchię informacji** — nie oczekujemy 1:1 odwzorowania CSS (Bootstrap to zmieni), ale struktura kafelków, typografia liczb i hierarchia sekcji powinny być zachowane.

---

## 1. Analiza istniejących rozwiązań

### 1.1 Struktura projektu `api`

✅ POTWIERDZONE W KODZIE

**Lokalizacja kluczowych warstw:**
- Kontrolery: `api/app/Controllers/` (np. `Material/MaterialController.php`, `Material/Supplier/Catalog/ProductController.php`)
- Serwisy: `api/app/Services/` (np. `Material/MaterialService.php`, `Material/Supplier/Price/PriceListService.php`)
- Repozytoria: `api/app/Repositories/` (np. `Material/MaterialRepository.php`, `Material/Supplier/Catalog/ProductRepository.php`)
- Trasy: `api/v1/Routes/` (np. `MaterialSupplier/SupplierAppRoutes.php`)
- Rdzeń: `api/core/` — `BaseController.php`, `BaseRepository.php`, `ResponseHandler.php`, `RequestContext.php`, `TransactionManager.php`
- Migracje: `api/database/` — pliki `.sql` z DDL

**Routing (potwierdzony w `v1/Routes/MaterialSupplier/SupplierAppRoutes.php`):**
```php
$router->get('/material-suppliers/(\d+)/catalog/products', function ($supplierId) use ($container) {
    AuthMiddleware::checkAuth();
    $controller = $container->get(ProductController::class);
    $controller->read($supplierId);
});
```
Format: `$router->{method}('/path/{param}', closure)` z `AuthMiddleware::checkAuth()` wewnątrz.

**Autoryzacja (potwierdzone w `core/AuthMiddleware.php`):**
```php
AuthMiddleware::checkAuth();        // standard JWT
AuthMiddleware::checkSupplierAuth(); // supplier-scoped JWT
```

**Odpowiedzi (potwierdzone w `core/ResponseHandler.php`):**
- `ResponseHandler::send(array $data)` → 200 + JSON
- `ResponseHandler::sendCreated(int|array $id)` → 201 + `{status, message, inserted_id}`
- `ResponseHandler::sendUpdated()` → 204
- `ResponseHandler::sendSuccess(string $msg)` → 200 + `{status, message}`
- `ResponseHandler::sendError(string $desc, int $code, array $errors)` → `{status, description, errors}`

**Bazowe repozytorium (`core/BaseRepository.php`):**
- `insert(array $data)` — filtruje przez `$allowedColumns`, zwraca `lastInsertId()`
- `update(mixed $id, array $data, string $id_column = 'id')` — filtruje przez `$allowedColumns`
- `delete(mixed $id, string $id_column = 'id')`
- `query(string $sql, array $params)` — auto-detects SELECT/INSERT/UPDATE/DELETE
- `buildFilterClause(array $filters, array $allowedColumns, string $alias)` — generuje WHERE, LIKE dla `name`
- `first(array $data)` — pobiera jeden rekord

**BaseController (`core/BaseController.php`):**
- `getValueFromPostRequest()` — obsługuje JSON i form-data
- `createContext(): RequestContext` — tworzy kontekst z `$_GET`

**RequestContext (`core/RequestContext.php`):**
- Obsługuje `?include=allergens,specification` (comma-separated)
- `$ctx->includes('allergens')` — sprawdzenie czy relacja ma być załadowana

**TransactionManager:**
- `$this->transactionManager->execute(function() use(...) { ... })` — wiele operacji DB w jednej transakcji

### 1.2 Struktura projektu `supplier`

✅ POTWIERDZONE W KODZIE

**Lokalizacja:**
- Kontrolery: `supplier/src/app/Http/Controllers/` (np. `Catalog/CatalogController.php`, `Catalog/CatalogProductController.php`)
- Serwisy: `supplier/src/app/Services/` (np. `Catalog/ProductService.php`)
- Repozytoria: `supplier/src/app/Repositories/` (np. `Catalog/ProductRepository.php`)
- Modele: `supplier/src/app/Models/` (np. `Catalog/ProductModel.php`) — `JsonSerializable` DTOs
- Widoki: `supplier/src/app/Views/` — szablony Twig
- Trasy: `supplier/src/core/Bootstrap/Routes/` (np. `CatalogRoutes.php`) — FastRoute
- Rdzeń: `supplier/src/core/` — `ApiClient`, `GlobalRegistry`, atrybut `Route`, `Container`

**Routing (potwierdzony w `CatalogRoutes.php` i `CatalogController.php`):**
```php
// FILE-BASED (FastRoute)
$r->addRoute('GET', '/catalog', ['controller' => CatalogController::class, 'method' => 'index']);
$r->addRoute('GET', '/ajax/catalog/products', ['controller' => CatalogProductController::class, 'method' => 'ajaxGetAll']);

// ATTRIBUTE-BASED (w kontrolerze)
#[Route('GET', '/catalog')]
public function index() { ... }
```

**Supplier identity (potwierdzony w `ProductService.php`):**
```php
$supplierId = GlobalRegistry::get('user')['supplier_id'];
```
`supplier_id` jest automatycznie wstrzykiwany z sesji — każda akcja serwisu domyślnie działa w zakresie zalogowanego dostawcy.

**ApiClient (`core/Http/ApiClient.php`):**
- JWT przesyłany jako `Authorization: Bearer {token}` z ciasteczka sesji
- Metody: `get()`, `post()`, `patch()`, `put()`, `delete()`, `postMultipart()`
- Odpowiedź: `['success', 'data', 'message', 'code', 'inserted_id']`

**Controller bazowy (`Http/Controllers/Controller.php`):**
- `$this->view('module/template', $data)` → Twig render z automatycznym ładowaniem tłumaczeń
- `$this->json($data, $status)` → `JsonResponse` (Symfony HTTP Foundation)
- `$this->getJson(Request $request)` → dekoduje JSON body

**Stack frontendu (potwierdzony w `layouts/base.twig` i `catalog/catalog.twig`):**
- Bootstrap 5 + Bootstrap Icons
- SweetAlert2 (alerty, potwierdzenia)
- Choices.js (select)
- Twig (szablony)
- Vanilla JS z `api()` helper do AJAX calls
- Cropper.js (upload zdjęć)

### 1.3 Wzorce architektoniczne — potwierdzone w kodzie

✅ POTWIERDZONE W KODZIE

**Przepływ `api` (Request → Response):**
```
HTTP Request
  → v1/index.php (router)
  → AuthMiddleware::checkAuth()
  → Controller::method($params)
    → $data = $this->getValueFromPostRequest()
    → Service::method($data)
      → Validation (errors throw CustomException)
      → TransactionManager::execute(fn)
        → Repository::insert/update/query()
    → ResponseHandler::sendCreated($id)
  → JSON Response
```

**Przepływ `supplier` (Request → API → Response):**
```
Browser AJAX call
  → supplier/public/index.php (FastRoute)
  → Controller::ajaxMethod()
    → Service::method($data)          // wstrzykuje supplier_id z GlobalRegistry
      → Repository::apiCall($endpoint)   // tworzy URL z supplier_id
        → ApiClient::post/get/patch()     // cURL + JWT Bearer
          → api ResponseHandler::send*()
      → return ['success' => ..., 'data' => ...]
    → Controller::json($resp, $resp['code'])
  → JsonResponse → Browser
```

**Izolacja dostawcy:**
- W `api`: `supplier_id` jako explicit parametr URL (`/material-suppliers/{supplierId}/...`)
- W `supplier`: service automatycznie pobiera `GlobalRegistry::get('user')['supplier_id']` i wstrzykuje do URL

### 1.4 Analogiczne moduły — wzory do naśladowania

✅ POTWIERDZONE W KODZIE

| Moduł | Opis | Analogia dla Bazy Surowców |
|---|---|---|
| `supplier_catalog_product` | Katalog produktów dostawcy w `api` | **Bezpośredni wzorzec** dla `supplier_raw_material` |
| `supplier_product_price_list` | Historia cen z `valid_from` | **Bezpośredni wzorzec** dla `supplier_raw_material_price_history` |
| `catalog/catalog.twig` | Grid produktów z filtrami i modale AJAX | **Szablon UI** dla listy surowców |
| `CatalogProductController.php` (supplier) | Thin AJAX controller | **Wzorzec** dla `RawMaterialController` |
| `Catalog/ProductService.php` (supplier) | Service z `supplier_id` z GlobalRegistry | **Wzorzec** dla `RawMaterialService` |
| `Catalog/ProductRepository.php` (supplier) | Repository delegujące do ApiClient | **Wzorzec** dla `RawMaterialRepository` |
| `ProductModel.php` (supplier) | JsonSerializable DTO | **Wzorzec** dla `RawMaterialModel` |
| `material` (api) | Centralny katalog surowców | Analogia, ale **surowce dostawcy są niezależne** |

> **Kluczowa obserwacja:** istniejąca tabela `material` w `api` to centralny, systemowy katalog (field `source_type = 'CENTRAL'|'OPEN'` widoczny w `MaterialService.php` i `MaterialRepository.php`). Moduł „Baza surowców" w portalu dostawcy to **własny, izolowany katalog per dostawca** — analogiczny do `supplier_catalog_product`, nie do `material`.

### 1.5 Przepływ danych api ↔ supplier

✅ POTWIERDZONE W KODZIE

```
supplier portal                     api
──────────────────────────────────────────────────────────────
GET /ajax/raw-materials
  └─ RawMaterialService::getAll()
       └─ RawMaterialRepository::getAll($supplierId)
            └─ ApiClient::get("/material-suppliers/{supplierId}/raw-materials")
                 └─ AuthMiddleware::checkAuth()
                      └─ RawMaterialController::read($supplierId)
                           └─ RawMaterialApiService::getAll($supplierId)
                                └─ RawMaterialApiRepository::getAll($supplierId)
                                     └─ SELECT * FROM supplier_raw_material WHERE supplier_id = ?
                                          └─ ResponseHandler::send([...])
                 ← JSON response ←────────────────────────────
       └─ new RawMaterialModel($item) per rekord
  └─ return JsonResponse(['success', 'data'])
```

---

## 2. Projekt domeny

### 2.1 Rola modułu

Moduł **Baza surowców** dostarcza dostawcy własny, izolowany katalog surowców (składników), z których produkowane są jego produkty. Moduł jest fundamentem przyszłego kalkulatora receptur — każda receptura referencjonuje surowce z tej bazy z podaną ilością, a koszty receptury obliczane są na podstawie aktualnej lub historycznej ceny surowca.

**Scope v1:**
- Dostawca zarządza własnymi surowcami (CRUD)
- Surowce mają podstawowe dane identyfikacyjne, jednostkę i kategorię
- Surowce mają aktualną cenę zakupu (opcjonalną)
- Historia zmian cen jest rejestrowana
- Surowce mogą mieć alergeny (z istniejącego systemu `allergen`)
- Archiwizacja (soft delete) — surowce używane w recepturach nie mogą być usunięte na twardo

### 2.2 Główne encje biznesowe

| Encja | Tabela | Opis |
|---|---|---|
| Surowiec | `supplier_raw_material` | Podstawowy rekord: nazwa, SKU, jednostka, kategoria, opis |
| Historia cen | `supplier_raw_material_price_history` | Kolejne wpisy cen z datą obowiązywania |
| Alergen surowca | `supplier_raw_material_allergen` | Powiązanie N:M z istniejącą tabelą `allergen` |
| Kategoria surowca | `supplier_raw_material_category` | Słownik kategorii per dostawca |

### 2.3 Ownership danych

```
material_supplier (id) ──1:N──► supplier_raw_material (supplier_id)
                                    └──1:N──► supplier_raw_material_price_history
                                    └──N:M──► allergen (przez supplier_raw_material_allergen)
                                    └──N:1──► supplier_raw_material_category
```

- Wszystkie surowce są ściśle przypisane do konkretnego `supplier_id`
- Jeden dostawca **NIE widzi** surowców innego dostawcy
- Izolacja egzekwowana na poziomie API: `WHERE supplier_id = :supplier_id` w każdym query

### 2.4 Relacje z przyszłymi recepturami

💡 REKOMENDACJA

```
supplier_raw_material (id)
  └──1:N──► recipe_ingredient (raw_material_id)
                └── recipe_ingredient.quantity_base_unit
                └── recipe_ingredient.price_snapshot       (DECIMAL — cena w momencie tworzenia)
                └── recipe_ingredient.price_snapshot_date  (DATE — kiedy snapshot)
```

Receptury będą przechowywać **snapshot ceny** w momencie kalkulacji, nie live referencję. Pozwala to na historyczną analizę kosztów niezależnie od zmian cen.

---

## 3. Model danych

### 3.1 Tabele i kolumny (DDL — szkic, nie finalna migracja)

#### Zmiana w istniejącej tabeli: `material_supplier`

💡 REKOMENDACJA — rozszerzenie istniejącej tabeli

```sql
-- Migracja: dodanie kolumny currency do material_supplier
ALTER TABLE `material_supplier`
    ADD COLUMN `currency` CHAR(3) NOT NULL DEFAULT 'EUR'
        COMMENT 'Waluta operacyjna dostawcy — ISO 4217'
        AFTER `...`;  -- ustalić rzeczywistą pozycję po weryfikacji schematu
```

**Uzasadnienie:**
- Waluta jest właściwością **dostawcy**, nie pojedynczego surowca ani wpisu cenowego
- Wszystkie ceny w portalu dostawcy (surowce, receptury, cenniki produktów) operują w tej samej walucie
- `price_history.currency` może być wówczas usunięte lub zastąpione linkiem do waluty dostawcy
- Mockup potwierdza: cały kalkulator receptur wyświetla jedną walutę (EUR w przykładzie)

> ✅ DECYZJA ZATWIERDZONA przez właściciela produktu: waluta per dostawca, `material_supplier.currency`.


#### Tabela: `supplier_raw_material`

```sql
CREATE TABLE `supplier_raw_material` (
    `id`                    INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    `supplier_id`           INT UNSIGNED    NOT NULL,
    `sku`                   VARCHAR(100)    NULL     COMMENT 'Wewnętrzny kod/SKU dostawcy',
    `name`                  VARCHAR(255)    NOT NULL COMMENT 'Nazwa surowca',
    `description`           TEXT            NULL     COMMENT 'Opis / notatka technologiczna',
    `id_unit`               INT UNSIGNED    NOT NULL COMMENT 'FK → unit(id) — jednostka bazowa (kg, l, szt, ...)',
    `id_category`           INT UNSIGNED    NULL     COMMENT 'FK → supplier_raw_material_category(id)',
    `waste_perc`            DECIMAL(5,2)    NOT NULL DEFAULT 0.00 COMMENT 'Procent odpadów (0-100)',
    `current_price_net`     DECIMAL(10,4)   NULL     COMMENT 'Aktualna cena netto za jednostkę bazową (denormalizacja)',
    `vat_rate`              DECIMAL(5,2)    NULL     COMMENT 'Stawka VAT (%)',
    `origin_country`        CHAR(2)         NULL     COMMENT 'Kraj pochodzenia ISO 3166-1 alpha-2 — v2',
    `shelf_life_days`       INT UNSIGNED    NULL     COMMENT 'Termin przydatności (dni)',
    `storage_temp_min`      DECIMAL(5,1)    NULL     COMMENT 'Min temperatura przechowywania (°C) — v2',
    `storage_temp_max`      DECIMAL(5,1)    NULL     COMMENT 'Max temperatura przechowywania (°C) — v2',
    `is_archived`           TINYINT(1)      NOT NULL DEFAULT 0 COMMENT 'Soft delete — archiwizacja',
    `created_at`            DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at`            DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_supplier_id`   (`supplier_id`),
    KEY `idx_supplier_name` (`supplier_id`, `name`),
    KEY `idx_supplier_sku`  (`supplier_id`, `sku`),
    KEY `idx_category`      (`id_category`),
    KEY `idx_archived`      (`supplier_id`, `is_archived`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

#### Tabela: `supplier_raw_material_price_history`

```sql
CREATE TABLE `supplier_raw_material_price_history` (
    `id`                INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    `raw_material_id`   INT UNSIGNED    NOT NULL COMMENT 'FK → supplier_raw_material(id)',
    `supplier_id`       INT UNSIGNED    NOT NULL COMMENT 'Denormalizacja dla bezpieczeństwa',
    `price_net`         DECIMAL(10,4)   NOT NULL COMMENT 'Cena netto za jednostkę bazową',
    -- Waluta pochodzi z material_supplier.currency — nie duplikujemy per wpis
    `valid_from`        DATE            NOT NULL COMMENT 'Data obowiązywania ceny',
    `note`              VARCHAR(500)    NULL     COMMENT 'Notatka o zmianie ceny',
    `created_by_user`   INT UNSIGNED    NULL     COMMENT 'ID użytkownika — audit',
    `created_at`        DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_raw_material_id`  (`raw_material_id`),
    KEY `idx_supplier_date`    (`supplier_id`, `raw_material_id`, `valid_from`),
    CONSTRAINT `fk_srm_price_raw_material`
        FOREIGN KEY (`raw_material_id`) REFERENCES `supplier_raw_material` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

> **Uwaga:** Pole `currency` zostało usunięte z `price_history`, ponieważ waluta jest przechowywana na poziomie `material_supplier.currency`. Wszystkie ceny dostawcy są w jednej walucie. Przy wyświetlaniu historii cen walutę pobiera się z danych dostawcy.

#### Tabela: `supplier_raw_material_allergen`

```sql
CREATE TABLE `supplier_raw_material_allergen` (
    `id`                INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    `raw_material_id`   INT UNSIGNED    NOT NULL COMMENT 'FK → supplier_raw_material(id)',
    `allergen_id`       INT UNSIGNED    NOT NULL COMMENT 'FK → allergen(id)',
    `created_at`        DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_raw_material_allergen` (`raw_material_id`, `allergen_id`),
    CONSTRAINT `fk_srma_raw_material`
        FOREIGN KEY (`raw_material_id`) REFERENCES `supplier_raw_material` (`id`) ON DELETE CASCADE,
    CONSTRAINT `fk_srma_allergen`
        FOREIGN KEY (`allergen_id`) REFERENCES `allergen` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

#### Tabela: `supplier_raw_material_category`

```sql
CREATE TABLE `supplier_raw_material_category` (
    `id`            INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    `supplier_id`   INT UNSIGNED    NOT NULL,
    `name`          VARCHAR(255)    NOT NULL,
    `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_supplier_id` (`supplier_id`),
    UNIQUE KEY `uq_supplier_category_name` (`supplier_id`, `name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

### 3.2 Indeksy i constrainty

| Indeks | Tabela | Kolumny | Uzasadnienie |
|---|---|---|---|
| `idx_supplier_id` | `supplier_raw_material` | `supplier_id` | Izolacja per dostawca |
| `idx_supplier_name` | `supplier_raw_material` | `(supplier_id, name)` | Wyszukiwanie po nazwie w obrębie dostawcy |
| `idx_supplier_sku` | `supplier_raw_material` | `(supplier_id, sku)` | Unikalność SKU per dostawca + wyszukiwanie |
| `idx_supplier_date` | `supplier_raw_material_price_history` | `(supplier_id, raw_material_id, valid_from)` | Query „aktualna cena" i historia |
| `uq_raw_material_allergen` | `supplier_raw_material_allergen` | `(raw_material_id, allergen_id)` | Brak duplikatów alergenów per surowiec |
| `uq_supplier_category_name` | `supplier_raw_material_category` | `(supplier_id, name)` | Unikalność nazwy kategorii per dostawca |

> SKU jest unikalny per dostawca na poziomie DB (UNIQUE INDEX) — SKU to identyfikator biznesowy.

### 3.3 Enumy i wartości słownikowe

| Pole | Tabela | Wartości | Uwagi |
|---|---|---|---|
| `currency` | `material_supplier` | `EUR`, `PLN`, `GBP`, `USD`, `CZK`, `SEK`, `DKK` | ISO 4217, default `EUR` — **jedna waluta per dostawca** ✅ ZATWIERDZONE |
| `origin_country` | `supplier_raw_material` | `PL`, `FR`, `NL`, `IT`, `DE`, `ES`, `BE`, `CZ`, ... | ISO 3166-1 alpha-2 |
| `is_archived` | `supplier_raw_material` | `0` (aktywny), `1` (zarchiwizowany) | Soft delete |
| `id_unit` | `supplier_raw_material` | FK → istniejąca tabela `unit` w `api` | `kg`, `g`, `l`, `ml`, `szt`, `op` |

---

## 4. Projekt rekordu surowca

Pełna lista pól rekordu `supplier_raw_material` — pola v1 i v2 (pola v2 w tabeli od razu jako NULL, bez UI):

| Pole | Typ | v1 | Receptury | Opis |
|---|---|---|---|---|
| `id` | INT UNSIGNED PK | ✅ | ✅ | Auto-increment |
| `supplier_id` | INT UNSIGNED FK | ✅ | ✅ | Izolacja per dostawca |
| `sku` | VARCHAR(100) NULL | ✅ | ✅ | Wewnętrzny kod surowca |
| `name` | VARCHAR(255) NOT NULL | ✅ | ✅ | Nazwa surowca |
| `description` | TEXT NULL | ✅ | — | Opis / notatka technologiczna |
| `id_unit` | INT FK NOT NULL | ✅ | ✅ | Jednostka bazowa (kg, l, szt) |
| `id_category` | INT FK NULL | ✅ | ✅ | Kategoria surowca |
| `waste_perc` | DECIMAL(5,2) DEFAULT 0 | ✅ | ✅ | % odpadów do kalkulacji receptur |
| `current_price_net` | DECIMAL(10,4) NULL | ✅ | ✅ | Denormalizacja aktualnej ceny netto |
| `vat_rate` | DECIMAL(5,2) NULL | ✅ | ✅ | VAT do kalkulacji cen brutto |
| `origin_country` | CHAR(2) NULL | v2 UI | ✅ | Kraj pochodzenia (wymagania UE) |
| `shelf_life_days` | INT UNSIGNED NULL | ✅ | ✅ | Termin przydatności |
| `storage_temp_min` | DECIMAL(5,1) NULL | v2 UI | ✅ | Min temp. przechowywania |
| `storage_temp_max` | DECIMAL(5,1) NULL | v2 UI | ✅ | Max temp. przechowywania |
| `is_archived` | TINYINT(1) DEFAULT 0 | ✅ | ✅ | Soft delete |
| `created_at` | DATETIME | ✅ | ✅ | Data utworzenia |
| `updated_at` | DATETIME | ✅ | ✅ | Data aktualizacji |

> Kolumny z oznaczeniem `v2 UI` są w tabeli od razu (jako NULL, opcjonalne), ale bez formularzy w v1. Unika to migracji ALTER TABLE później.

**Historia cen (`supplier_raw_material_price_history`):**

| Pole | Typ | v1 | Receptury | Opis |
|---|---|---|---|---|
| `id` | INT UNSIGNED PK | ✅ | ✅ | |
| `raw_material_id` | INT FK NOT NULL | ✅ | ✅ | FK → surowiec |
| `supplier_id` | INT NOT NULL | ✅ | ✅ | Denormalizacja — security |
| `price_net` | DECIMAL(10,4) NOT NULL | ✅ | ✅ | Cena netto za j. bazową |
| ~~`currency`~~ | ~~CHAR(3)~~ | — | — | **Usunięte** — waluta pobierana z `material_supplier.currency` |
| `valid_from` | DATE NOT NULL | ✅ | ✅ | Data obowiązywania |
| `note` | VARCHAR(500) NULL | ✅ | — | Notatka |
| `created_by_user` | INT NULL | ✅ | ✅ | Audit — kto dodał |
| `created_at` | DATETIME | ✅ | ✅ | |

---

## 5. Decyzje projektowe i kompromisy

### 5.1 Baza per dostawca — uzasadnienie

✅ POTWIERDZONE W KODZIE — analogia z `supplier_catalog_product`

Dostawca posiada własny, niezależny katalog surowców odizolowany według `supplier_id`. Jest to:
- **Spójne z istniejącym wzorcem**: tabela `supplier_catalog_product` ma `supplier_id`, `ProductService.php` w portalu dostawcy automatycznie wstrzykuje `GlobalRegistry::get('user')['supplier_id']`
- **Zgodne z modelem biznesowym**: każdy dostawca ma inne surowce, inne nazwy, inne ceny zakupu
- **Bezpieczne**: brak możliwości przypadkowego dostępu do danych innego dostawcy

### 5.2 Zalety i ryzyka

| Aspekt | Zaleta | Ryzyko / Mitygacja |
|---|---|---|
| Izolacja per dostawca | Prostota, bezpieczeństwo, szybkość | Brak globalnego masterdata → mitygacja: pole `central_material_id NULL` dla v2 |
| Soft delete (`is_archived`) | Surowce z receptur nie giną | Lista może „rosnąć" → mitygacja: domyślny filtr `is_archived = 0` |
| Denormalizacja `current_price_net` | Szybkie wyświetlanie w UI bez JOIN | Ryzyko desync — patrz sekcja 5.5 |
| Historia cen `valid_from` | Modelowanie zmian cen w czasie | Złożone SQL → mitygacja: analogia `supplier_product_price_list` już rozwiązana |
| Snapshot ceny w recepturze | Historyczna analiza kosztów | Redundancja danych → celowa, akceptowalna |

### 5.3 Polityka usuwania surowców

✅ DECYZJA ZATWIERDZONA: Twarde usunięcie jest możliwe, ale wymaga **dwuetapowego potwierdzenia** od użytkownika.

**Etap 1 — Informacja o skutkach:**
Zanim system wyświetli dialog potwierdzenia, użytkownik musi zobaczyć pełną informację o konsekwencjach:

```
⚠️ Uwaga — usunięcie surowca jest nieodwracalne

Usunięcie surowca "[nazwa]" spowoduje:
• Trwałe usunięcie rekordu surowca i całej historii cen
• Utratę informacji o alergenach przypisanych do surowca

Jeśli surowiec jest używany w recepturach:
• Receptury stracą odwołanie do surowca
• Kalkulacje kosztów mogą stać się niepoprawne

Zalecamy zamiast usunięcia: archiwizację surowca.
```

**Etap 2 — Potwierdzenie:**
Po przeczytaniu informacji użytkownik musi jawnie potwierdzić usunięcie (SweetAlert2 z wyraźnym przyciskiem `Usuń trwale`).

**Implementacja (wzorzec SweetAlert2):**
```javascript
// Etap 1: modal informacyjny
Swal.fire({
    title: 'Usuń surowiec?',
    html: `<div class="text-start">...[pełna informacja]...</div>`,
    icon: 'warning',
    showCancelButton: true,
    confirmButtonText: 'Rozumiem, kontynuuj',
    cancelButtonText: 'Anuluj'
}).then(result => {
    if (!result.isConfirmed) return;

    // Etap 2: ostateczne potwierdzenie
    Swal.fire({
        title: 'Ostateczne potwierdzenie',
        text: 'Tej operacji nie można cofnąć.',
        icon: 'error',
        showCancelButton: true,
        confirmButtonColor: '#d33',
        confirmButtonText: 'Usuń trwale',
        cancelButtonText: 'Anuluj'
    }).then(result2 => {
        if (result2.isConfirmed) {
            api('DELETE', `/ajax/raw-materials/${id}`)
                .then(() => { /* odśwież listę */ });
        }
    });
});
```

**Ograniczenia po stronie `api`:**
- Przed usunięciem `api` sprawdza czy surowiec jest używany w recepturach (`SELECT COUNT(*) FROM supplier_recipe_ingredient WHERE raw_material_id = ?`)
- Jeśli tak → zwraca 409 Conflict z informacją o liczbie receptur
- Frontend obsługuje 409 wyświetlając dodatkowy komunikat

### 5.4 Ścieżka migracji do globalnego masterdata (future)

💡 REKOMENDACJA

W przyszłości możliwa migracja do modelu globalnego:
1. Dodanie `central_material_id INT NULL FK → material(id)` do `supplier_raw_material`
2. Opcjonalne „mapowanie" surowca dostawcy na surowiec centralny (jak `supplier_catalog_product_mapping`)
3. Importowanie globalnych nazw, alergenów, jednostek przy zachowaniu własnej ceny
4. Nie narusza v1 — pole nullable, backfill opcjonalny

### 5.5 Snapshot vs live data przy recepturach

💡 REKOMENDACJA

**Model: snapshot przy tworzeniu/przeliczaniu receptury**

```
recipe_ingredient.price_net_snapshot = DECIMAL(10,4)   -- cena w momencie kalkulacji
recipe_ingredient.price_snapshot_date = DATE            -- data snapshotowania
```

| Zastosowanie | Źródło | Zmienia się po edycji surowca? |
|---|---|---|
| Lista surowców w UI | `current_price_net` (denorm.) | Tak (live, może być chwilowo nieaktualne — patrz 5.6) |
| Historia zmian cen | `price_history` | Nie (append-only) |
| Kalkulacja kosztu receptury | `price_history WHERE valid_from <= CURDATE()` | Dynamicznie pobierana |
| Archiwum receptury (PDF, raport) | `recipe_ingredient.price_net_snapshot` | **Nigdy** — celowo zamrożona |

### 5.6 Desynchronizacja `current_price_net` — szczegółowe wyjaśnienie

`current_price_net` to **pole zduplikowane (denormalizacja)** przechowujące tę samą wartość, którą można wyliczyć zapytaniem:

```sql
SELECT price_net FROM supplier_raw_material_price_history
WHERE raw_material_id = ? AND valid_from <= CURDATE()
ORDER BY valid_from DESC LIMIT 1;
```

**Pole istnieje wyłącznie dla wydajności UI** — żeby lista surowców ładowała się bez N+1 zapytań.

**Dwa scenariusze desynchronizacji:**

**Scenariusz A — błąd aplikacyjny:**
Dostawca dodaje cenę `0.85 EUR` od dziś. Kod zapisuje wpis do `price_history`, ale nie aktualizuje `current_price_net` (bug lub pominięty UPDATE). Lista wyświetla starą cenę `0.78 EUR`.
- *Mitygacja:* sync w jednej transakcji (`TransactionManager::execute()`)

**Scenariusz B — cena zaplanowana na przyszłość:**
Dostawca dodaje cenę `1.10 EUR` z `valid_from = 2026-07-01`. Dziś jest maj — `current_price_net` słusznie nie jest aktualizowany. Ale **1 lipca nic automatycznie nie zaktualizuje pola**.
- *Mitygacja v1:* akceptowalne ograniczenie — pole służy tylko UI listy, nie kalkulacjom
- *Mitygacja v2:* scheduled job lub DB event uruchamiany o północy

**Reguła użycia:**

```
✅ Używaj current_price_net do:              ❌ NIE używaj current_price_net do:
   - wyświetlania ceny w liście surowców        - kalkulacji kosztów receptury
   - podpowiedzi w formularzu receptury         - snapshotowania ceny do receptury
                                                - raportowania finansowego
✅ Do kalkulacji zawsze używaj zapytania na price_history.
```

**Implementacja sync w serwisie:**

```php
// RawMaterialPriceService::addPrice()
$this->transactionManager->execute(function() use ($data, $supplierId) {
    $id = $this->priceRepo->insert([
        'raw_material_id' => $data['raw_material_id'],
        'supplier_id'     => $supplierId,
        'price_net'       => $data['price_net'],
        'valid_from'      => $data['valid_from'],
        'note'            => $data['note'] ?? null,
    ]);

    // Sync TYLKO jeśli cena jest już aktywna (nie przyszła)
    if ($data['valid_from'] <= date('Y-m-d')) {
        $this->rawMaterialRepo->update($data['raw_material_id'], [
            'current_price_net' => $data['price_net']
        ]);
    }

    return $id;
});
```



---

## 6. Projekt API

### 6.1 Endpointy REST

Nowy plik: `api/v1/Routes/MaterialSupplier/rawMaterialRoutes.php`

```
# Lista i szczegół
GET    /material-suppliers/{supplierId}/raw-materials
GET    /material-suppliers/{supplierId}/raw-materials/{id}

# CRUD
POST   /material-suppliers/{supplierId}/raw-materials
PATCH  /material-suppliers/{supplierId}/raw-materials/{id}
DELETE /material-suppliers/{supplierId}/raw-materials/{id}

# Archiwizacja
PATCH  /material-suppliers/{supplierId}/raw-materials/{id}/archive
PATCH  /material-suppliers/{supplierId}/raw-materials/{id}/restore

# Alergeny
GET    /material-suppliers/{supplierId}/raw-materials/{id}/allergens
POST   /material-suppliers/{supplierId}/raw-materials/{id}/allergens
DELETE /material-suppliers/{supplierId}/raw-materials/{id}/allergens/{allergenId}

# Historia cen
GET    /material-suppliers/{supplierId}/raw-materials/{id}/price-history
POST   /material-suppliers/{supplierId}/raw-materials/{id}/price-history

# Kategorie
GET    /material-suppliers/{supplierId}/raw-material-categories
POST   /material-suppliers/{supplierId}/raw-material-categories
PATCH  /material-suppliers/{supplierId}/raw-material-categories/{id}
DELETE /material-suppliers/{supplierId}/raw-material-categories/{id}
```

**Kontrolery w `api/app/Controllers/Material/Supplier/RawMaterial/`:**
- `RawMaterialController.php`
- `RawMaterialPriceController.php`
- `RawMaterialAllergenController.php`
- `RawMaterialCategoryController.php`

### 6.2 Przykładowe requesty i responses

**GET `/material-suppliers/5/raw-materials?include=allergens`**
```json
[
  {
    "id": 42,
    "supplier_id": 5,
    "sku": "FLOUR-001",
    "name": "Mąka pszenna typ 500",
    "description": null,
    "id_unit": 1,
    "unit_name": "kg",
    "id_category": 3,
    "category_name": "Mąki i skrobie",
    "waste_perc": "2.00",
    "current_price_net": "0.8500",
    "vat_rate": "8.00",
    "shelf_life_days": 365,
    "is_archived": 0,
    "allergens": [
      { "id": 1, "name": "Gluten", "code": "GL" }
    ],
    "created_at": "2025-01-15T10:30:00",
    "updated_at": "2025-06-01T08:00:00"
  }
]
```

**POST `/material-suppliers/5/raw-materials`**
```json
// Request
{
  "sku": "FLOUR-001",
  "name": "Mąka pszenna typ 500",
  "id_unit": 1,
  "id_category": 3,
  "waste_perc": 2.0,
  "vat_rate": 8.0,
  "shelf_life_days": 365,
  "description": "Mąka do wypieku pieczywa"
}
// Response 201
{
  "status": "success",
  "message": "The item was created successfully",
  "inserted_id": 42
}
```

**PATCH `/material-suppliers/5/raw-materials/42`**
```json
// Request (partial update)
{ "waste_perc": 3.5, "current_price_net": 0.95 }
// Response 204 (no content)
```

**PATCH `/material-suppliers/5/raw-materials/42/archive`**
```json
// Response 200
{ "status": "success", "message": "Raw material archived successfully." }
```

**POST `/material-suppliers/5/raw-materials/42/price-history`**
```json
// Request
{
  "price_net": 0.85,
  "currency": "EUR",
  "valid_from": "2025-07-01",
  "note": "Wzrost cen od dostawcy — Q3 2025"
}
// Response 201
{ "status": "success", "message": "The item was created successfully", "inserted_id": 101 }
```

**GET `/material-suppliers/5/raw-materials/42/price-history`**
```json
// Waluta pobierana z kontekstu dostawcy (material_supplier.currency), nie per wpis
[
  { "id": 101, "price_net": "0.8500", "valid_from": "2025-07-01", "note": "Wzrost cen Q3", "created_at": "2025-06-15T09:00:00" },
  { "id": 88,  "price_net": "0.7800", "valid_from": "2025-01-01", "note": null, "created_at": "2024-12-20T14:00:00" }
]
```

### 6.3 Walidacje i błędy

```json
// 400 Bad Request
{
  "status": "error",
  "description": "Validation errors in request",
  "errors": {
    "name": "Missing name.",
    "id_unit": "Missing unit."
  }
}
// 403 Access Denied
{ "status": "error", "description": "Access denied." }

// 404 Not Found
{ "status": "error", "description": "Raw material not found." }

// 409 — surowiec używany w recepturach (przy próbie DELETE)
{
  "status": "error",
  "description": "Raw material is used in recipes and cannot be deleted.",
  "errors": {
    "raw_material_id": "Used in 3 recipe(s). Archive instead of deleting, or remove from all recipes first."
  }
}
```

### 6.4 Paginacja, filtrowanie, sortowanie

Query params dla GET listy (wzorzec `buildFilterClause()`):
```
?name=mąka         → LIKE '%mąka%'
?id_category=3     → exact match
?sku=FLOUR         → LIKE '%FLOUR%'
?is_archived=0     → aktywne (default)
?is_archived=1     → tylko zarchiwizowane
?include=allergens → JOIN alergeny
```

💡 REKOMENDACJA dla v1: brak paginacji server-side (jak `catalog/products`) — client-side filtering. Paginacja w v2 gdy lista przekroczy ~500 rekordów.

---

## 7. Model uprawnień

### 7.1 Reguły autoryzacji w `api`

✅ POTWIERDZONE W KODZIE — wzorzec `SupplierAppRoutes.php`

```php
$router->get('/material-suppliers/(\d+)/raw-materials', function ($supplierId) use ($container) {
    AuthMiddleware::checkAuth();
    $container->get(RawMaterialController::class)->read($supplierId);
});
```

**Reguły:**
1. JWT musi być ważny (`AuthMiddleware::checkAuth()`)
2. `supplier_id` z URL musi odpowiadać `supplier_id` z JWT — weryfikacja w serwisie
3. Każdy query: `WHERE supplier_id = :supplier_id`
4. Serwis weryfikuje ownership przed update/delete/archive

```php
// Przykład weryfikacji ownership
$material = $this->repo->findById($id);
if (!$material || (int)$material['supplier_id'] !== (int)$supplierId) {
    throw new CustomException('ACCESS_DENIED', 'raw_material');
}
```

Admin może odczytywać dane wielu dostawców (support, audyt) — wymaga weryfikacji istniejących ról przed implementacją.

### 7.2 Konsekwencje UX w `supplier`

- `supplier_id` wstrzykiwany automatycznie z `GlobalRegistry` — dostawca widzi tylko swoje surowce
- Brak filtra po dostawcy w UI — izolacja transparentna
- Błąd 403 → redirect do strony błędu (wzorzec istniejący w portalu)
- Błąd 404 → toast + stan pusty w komponencie

---

## 8. Gotowość pod receptury

### 8.1 Referencjonowanie surowców z linii receptury

💡 REKOMENDACJA (schemat dla v2 — nie implementować w v1)

```sql
CREATE TABLE `supplier_recipe_ingredient` (
    `id`                    INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    `recipe_id`             INT UNSIGNED    NOT NULL,
    `raw_material_id`       INT UNSIGNED    NOT NULL,
    `quantity`              DECIMAL(10,4)   NOT NULL  COMMENT 'Ilość w j. bazowej surowca',
    `price_net_snapshot`    DECIMAL(10,4)   NULL      COMMENT 'Snapshot ceny netto',
    `snapshot_date`         DATE            NULL,
    `note`                  VARCHAR(255)    NULL,
    PRIMARY KEY (`id`),
    CONSTRAINT `fk_sri_raw_material`
        FOREIGN KEY (`raw_material_id`) REFERENCES `supplier_raw_material` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 8.2 Snapshot przy kalkulacji receptury

💡 REKOMENDACJA

```sql
-- Pobierz aktualną cenę surowca
SELECT price_net, currency
FROM supplier_raw_material_price_history
WHERE raw_material_id = ?
  AND supplier_id = ?
  AND valid_from <= CURDATE()
ORDER BY valid_from DESC
LIMIT 1;
```

Analogia: wzorzec `PriceListRepository::getCurrentPrices()` z `MAX(valid_from) <= CURDATE()`.

Kalkulacja kosztu linii receptury:
```
koszt_linii = quantity * price_net_snapshot * (1 + waste_perc / 100)
```

### 8.3 Pola historyczne vs live

| Zastosowanie | Źródło | Zmienia się po edycji surowca? |
|---|---|---|
| Wyświetlanie ceny w UI | `current_price_net` (denorm.) | Tak — live |
| Historia zmian cen | `price_history` | Nie — append-only |
| Kalkulacja kosztu receptury | `price_history` (aktualny wpis) | Dynamicznie pobierana |
| Archiwum receptury / raport | `recipe_ingredient.price_net_snapshot` | **Nigdy** — celowo zamrożona |
| Alergeny na recepturze | Z surowca live lub snapshot | Do decyzji biznesowej — rekomendacja snapshot dla compliance |

---

## 9. Reguły walidacji

### Surowiec (`supplier_raw_material`)

| Pole | Reguła | Błąd |
|---|---|---|
| `name` | required, string, 1–255 | `"Missing name."` |
| `id_unit` | required, exists in `unit` | `"Missing or invalid unit."` |
| `id_category` | nullable; jeśli podane: exists WHERE `supplier_id = ?` | `"Invalid category."` |
| `sku` | nullable, unique per supplier, max 100 | `"SKU already in use."` |
| `waste_perc` | required, decimal, 0.00–100.00 | `"waste_perc must be between 0 and 100."` |
| `vat_rate` | nullable, decimal, 0.00–100.00 | `"Invalid vat_rate."` |
| `shelf_life_days` | nullable, int > 0 | `"shelf_life_days must be a positive integer."` |
| `current_price_net` | nullable, decimal >= 0 | `"Price must be >= 0."` |
| `description` | nullable, max 5000 | — |

### Historia cen

| Pole | Reguła | Błąd |
|---|---|---|
| `price_net` | required, decimal >= 0.0001 | `"Missing or invalid price_net."` |
| `currency` | required, enum whitelist ISO 4217 | `"Invalid currency."` |
| `valid_from` | required, format YYYY-MM-DD | `"Missing or invalid valid_from."` |
| `note` | nullable, max 500 | — |

### Kategoria

| Pole | Reguła | Błąd |
|---|---|---|
| `name` | required, 1–255, unique per supplier | `"Missing name."` / `"Category name already exists."` |

### Reguły biznesowe

| Reguła | Opis |
|---|---|
| Unikalność SKU per dostawca | `sku` musi być unikalne w obrębie danego `supplier_id` |
| Cena >= 0 | Zakaz cen ujemnych |
| Usunięcie z dwuetapowym potwierdzeniem | Twarde usunięcie dopuszczalne po jawnym poinformowaniu o skutkach i dwukrotnym potwierdzeniu przez użytkownika |
| DELETE blokowany przez receptury | Jeśli surowiec jest używany w recepturach → 409 Conflict; usunięcie możliwe dopiero po usunięciu z receptur lub po archiwizacji |
| Brak nieaktywnych w nowej recepturze | Zarchiwizowany surowiec (`is_archived = 1`) nie może być dodany do **nowej** receptury |
| Niezmienność historii cen | Wpisów do `price_history` nie edytujemy — tylko dodajemy nowe |
| Sync `current_price_net` | Przy INSERT do `price_history` z `valid_from <= CURDATE()` → UPDATE `supplier_raw_material.current_price_net`; ceny przyszłe nie aktualizują pola |

---

## 10. Propozycja UI w `supplier`

### 10.1 Lista surowców

**Routing:** `supplier/src/core/Bootstrap/Routes/RawMaterialRoutes.php`

**Wejście z sidebara** w `supplier/src/app/Views/page_components/sidebar.twig`:
```twig
<li class="nav-item">
    <a class="nav-link {{ app.request.pathInfo starts with '/raw-materials' ? 'active' : '' }}"
       href="/raw-materials">
        <i class="bi bi-archive me-2"></i>
        {{ translations.raw_materials.menu_label }}
    </a>
</li>
```

**Widok:** `src/app/Views/raw-materials/raw_materials.twig`

Struktura (wzorzec `catalog/catalog.twig`):
- **Page heading:** „Baza surowców" + subtitle
- **Info alert:** opis modułu
- **Toolbar:** `+ Dodaj surowiec` · filtr tekstowy · filtr kategorii (Choices.js) · filtr statusu · Wyczyść
- **Tabela:**

| Kolumna | Źródło | Styl |
|---|---|---|
| SKU | `sku` | `<code class="text-muted">` |
| Nazwa | `name` | Klikalny → detail modal |
| Kategoria | `category_name` | Small badge secondary |
| Jednostka | `unit_name` | Badge outline |
| Alergeny | `allergens[]` | Max 3 badge + `+N more` |
| Cena netto | `current_price_net` + `currency` | right-aligned |
| % Odpadów | `waste_perc` | `2.00 %` |
| Status | `is_archived` | Pill: Aktywny / Archiwum |
| Akcje | — | ✏️ · 💰 Historia cen · 🔖 Alergeny · 📦 Archiwizuj |

- Stan pusty: ilustracja + „Nie masz jeszcze żadnych surowców. Dodaj pierwszy."

### 10.2 Formularz dodawania/edycji

Modal `#rawMaterialModal` (wzorzec `catalog/modals/product_manage.twig`):

- **Sekcja 1 — Identyfikacja:** SKU (optional), Nazwa (required)
- **Sekcja 2 — Klasyfikacja:** Kategoria (Choices.js + inline create), Jednostka bazowa
- **Sekcja 3 — Produkcja:** % Odpadów (default 0), Termin przydatności (dni)
- **Sekcja 4 — Cena zakupu (opcjonalna):** Cena netto, Waluta, VAT %
  - Jeśli podana → automatyczny wpis do `price_history` z `valid_from = today`
- **Sekcja 5 — Notatki:** Opis / notatka technologiczna (textarea)

### 10.3 Widok szczegółu

Modal `#rawMaterialDetailModal`:
- Pełne dane surowca
- Lista alergenów + przycisk „Zarządzaj alergenami"
- Aktualna cena + ostatnie 3 wpisy historii + link „Pełna historia"

💡 Rekomendacja: w v2 zamienić modal na dedykowaną stronę `/raw-materials/{id}` — więcej miejsca na sekcję receptur.

### 10.4 Historia cen

Modal `#priceHistoryModal`:
- Tabela historii (DESC): `valid_from`, `price_net` + waluta z dostawcy, `note`, `created_at`
- Przyszłe daty: badge „Zaplanowana"
- Formularz: `price_net`, `valid_from` (datepicker), `note` — waluta automatycznie z kontekstu dostawcy

### 10.5 Archiwizacja i usunięcie

**Archiwizacja (zalecana ścieżka):**
- Przycisk "Archiwizuj" → SweetAlert2 confirm
- `PATCH /ajax/raw-materials/{id}/archive`
- Toast + odśwież listę; wiersz: szary kolor + badge „ARCHIWUM"
- Przycisk „Przywróć" dla `is_archived = 1`

**Usunięcie trwałe (dwuetapowe potwierdzenie):**

Krok 1 — SweetAlert2 `icon: 'warning'` z pełną informacją o skutkach:
```
⚠️ Usunięcie surowca jest nieodwracalne

Usunięcie "[nazwa]" spowoduje trwałe usunięcie:
• rekordu surowca i całej historii cen
• przypisanych alergenów

Surowiec używany w recepturach nie może być usunięty.
Zalecamy archiwizację zamiast usunięcia.
```
Przyciski: `Rozumiem, kontynuuj` / `Anuluj`

Krok 2 — SweetAlert2 `icon: 'error'`, `confirmButtonColor: '#d33'`:
```
Czy na pewno chcesz trwale usunąć ten surowiec?
Tej operacji nie można cofnąć.
```
Przyciski: `Usuń trwale` / `Anuluj`

Po potwierdzeniu: `DELETE /ajax/raw-materials/{id}`
- Sukces → toast + usuń wiersz z listy
- **409 Conflict** → `"Surowiec jest używany w X recepturach. Usuń go z receptur lub zarchiwizuj."`

---

## 11. Plan implementacji

### Etap 1: Analiza i setup (1–2 dni)

- [ ] Potwierdzenie zakresu pól v1 z biznesem
- [ ] Weryfikacja zawartości tabeli `unit` w `api`
- [ ] Weryfikacja nazwy i struktury tabeli `allergen` w istniejącej bazie
- [ ] Decyzja: własna `supplier_raw_material_category` vs reużycie globalnej (rekomendacja: własna)
- [ ] Weryfikacja jak `api/v1/index.php` includuje pliki tras

**Struktura katalogów `api`:**
```
api/app/Controllers/Material/Supplier/RawMaterial/
api/app/Services/Material/Supplier/RawMaterial/
api/app/Repositories/Material/Supplier/RawMaterial/
api/v1/Routes/MaterialSupplier/rawMaterialRoutes.php
```

**Struktura katalogów `supplier`:**
```
supplier/src/app/Http/Controllers/RawMaterial/
supplier/src/app/Services/RawMaterial/
supplier/src/app/Repositories/RawMaterial/
supplier/src/app/Models/RawMaterial/
supplier/src/app/Views/raw-materials/
supplier/src/core/Bootstrap/Routes/RawMaterialRoutes.php
```

### Etap 2: Migracje w `api` (1 dzień)

1. `YYYY_MM_DD_create_supplier_raw_material_category.sql`
2. `YYYY_MM_DD_create_supplier_raw_material.sql`
3. `YYYY_MM_DD_create_supplier_raw_material_price_history.sql`
4. `YYYY_MM_DD_create_supplier_raw_material_allergen.sql`

Wzorzec: istniejące pliki `.sql` w `api/database/`.

### Etap 3: CRUD API w `api` (3–4 dni)

Kolejność implementacji:
1. `RawMaterialCategoryRepository` → serwis → kontroler → trasy
2. `RawMaterialRepository` (extends `BaseRepository`) → serwis (walidacja + `TransactionManager`) → kontroler → trasy
3. `RawMaterialAllergenRepository` → kontroler → trasy
4. `RawMaterialPriceRepository` (z sync `current_price_net`) → serwis → kontroler → trasy
5. Testy manualne: CRUD + archiwizacja + history + alergeny

### Etap 4: UI w `supplier` (3–4 dni)

Kolejność implementacji:
1. `RawMaterialModel` — JsonSerializable DTO
2. `RawMaterialRepository` — wraps `ApiClient`
3. `RawMaterialService` — wstrzykuje `supplier_id` z `GlobalRegistry`
4. `RawMaterialController` + AJAX controller
5. `RawMaterialRoutes.php` — FastRoute
6. `raw_materials.twig` — lista + toolbar + tabela (wzorzec `catalog.twig`)
7. Modale: `raw_material_manage.twig`, `allergen.twig`, `price_history.twig`
8. Wpis w `sidebar.twig`
9. Tłumaczenia w `src/core/I18n/translations/page/{lang}/raw_materials.json`

### Etap 5: Historia cen i audyt (1–2 dni)

1. Weryfikacja sync `current_price_net` przy INSERT do `price_history`
2. UI historii cen: tabela + formularz + badge „Zaplanowana"
3. Weryfikacja że `created_by_user` jest zapisywany z JWT `user_id`

### Etap 6: Integracja z recepturami (oddzielny etap / moduł)

1. Projekt modułu receptur (oddzielny dokument projektowy)
2. Endpoint z `include=currentPrice` dla selektora surowców w formularzu receptury
3. Snapshot logika: przy zapisie linii receptury → `getCurrentPrice()` → `price_net_snapshot`
4. Blokada: zarchiwizowany surowiec nie może być wybrany w nowej recepturze

---

## 12. Ryzyka i pytania

### 3 najważniejsze ryzyka implementacyjne

**🔴 Ryzyko 1: Desynchronizacja denormalizacji `current_price_net`**
- Problem: Pole `current_price_net` musi być aktualizowane przy każdym INSERT do `price_history` z `valid_from <= CURDATE()`. Ceny z przyszłą datą nigdy automatycznie nie zaktualizują pola po upływie daty.
- Mitygacja: synchronizacja w `TransactionManager::execute()` z warunkiem `IF valid_from <= CURDATE()`. Pole służy **wyłącznie UI listy** — kalkulacje zawsze używają zapytania na `price_history`. Szczegóły: sekcja 5.6.

**🔴 Ryzyko 2: Utrata danych receptury po twardym usunięciu surowca**
- Problem: Jeśli tabela receptur zostanie zaimplementowana przed modułem Bazy surowców i FK nie jest jeszcze w bazie, twarde DELETE jest możliwe bez blokady.
- Mitygacja: API sprawdza count receptur przed DELETE (zwraca 409), UI wymaga dwuetapowego potwierdzenia z jawną informacją o skutkach.

**🟡 Ryzyko 3: Brak waluty w responsach price_history dla starych rekordów**
- Problem: Po migracji `currency` na `material_supplier` — historyczne wpisy w `price_history` nie mają pola `currency` (zakładamy że nie istniało). Jeśli dostawca zmieni walutę, historia cen będzie dezorientująca.
- Mitygacja v1: waluta dostawcy traktowana jako niezmienna w v1. Zmiana waluty wymaga migracji danych. Dokumentacja tego ograniczenia w API.

### Decyzje podjęte — zamknięte pytania

| Pytanie | Decyzja |
|---|---|
| Kategorie surowców | ✅ Własne per dostawca (`supplier_raw_material_category`) |
| Waluta | ✅ Per dostawca — `material_supplier.currency` (ALTER TABLE) |
| Widok szczegółu surowca | ✅ Modal (`#rawMaterialDetailModal`) |
| Usunięcie surowca | ✅ Twarde usunięcie dozwolone, wymagane dwuetapowe potwierdzenie + 409 guard przy recepturach |
| Paginacja | ✅ Nie dotyczy v1 — client-side filtering |

### Rekomendowany zakres v1

**✅ W scope v1:**
- CRUD surowców — name, sku, unit, category, waste_perc, vat_rate, shelf_life_days, description
- Usunięcie trwałe z dwuetapowym potwierdzeniem + guard 409 przy recepturach
- Kategorie per dostawca (CRUD)
- Archiwizacja (soft delete) + przywracanie
- Alergeny — assign/unassign z istniejącej tabeli `allergen`
- Historia cen — dodawanie wpisów, lista historii, sync `current_price_net`
- Lista z filtrowaniem — nazwa, SKU, kategoria, status archiwizacji
- Widok szczegółu — modal
- Wejście z sidebara i routing
- Tłumaczenia (en/pl/fr/it/nl)
- Migracja `material_supplier.currency` — waluta per dostawca

**❌ Poza scope v1 (backlog):**
- Import/export CSV/JSON
- Mapowanie na centralny `material`
- Receptury i kalkulator kosztów (oddzielny moduł)
- UI dla `origin_country`, `storage_temp_min`, `storage_temp_max`
- Widok receptur powiązanych z danym surowcem
- Powiadomienia o zmianie ceny surowca używanego w recepturach
- Scheduled sync `current_price_net` dla cen z przyszłą datą

