Compare commits

..
6 Commits
Author SHA1 Message Date
jens fa5894426d M3: Reorder + Inspiration – Schritt 2 (UI): Planungsliste mit SortableJS, Tagesansicht /mahlzeiten/tage, Inspiration /inspiration mit 'Weitere 5' + 1-Klick-Adopt, Feature-Tests 2026-09-19 17:09:15 +02:00
jens 52a34225fa M3: Reorder + Inspiration – Schritt 1 (Logik): Reorder-UseCase, Inspiration-Query (ADR-006), Adopt-UseCase + Unit-Tests 2026-09-19 15:57:13 +02:00
jens ce77785003 M2: Status & Wochentage – Schritt 2 (UI): Tagesansicht, htmx-CheckOff/Uncheck-Partials, Archiv-Tab + Feature-Tests 2026-09-19 11:46:15 +02:00
jens 3519ff08f2 M2: Status & Wochentage – Schritt 1 (Logik): CheckOff/Uncheck UseCases + Status-Event + Tests 2026-09-19 10:54:52 +02:00
jens 160b89f5a1 M1: CRUD-Slice 'Mahlzeit' Schritt 2 – UI (Controller, Templates, Routen, Feature-Tests) 2026-09-19 10:17:55 +02:00
jens 1589088379 M1: CRUD-Slice 'Mahlzeit' Schritt 1 – Logik + Daten + Tests
Rich-Model Mahlzeit (MahlzeitStatus, Wochentag) mit Invarianten und
Zustandsübergängen; DTOs + MahlzeitFactory (reine Mapping-Funktionen);
Manager (create hängt an Ende an: maxSortOrder+1, find → 404) und
Create/Update/Delete-UseCases mit Transaktion + Events nach Commit.

Data-Layer: MahlzeitEntity (Tabelle essens_mahlzeiten, wochentage als
CSV), Mapper (CSV/Enum/null), Provider (find/list/maxSortOrder),
Processor (create/save/delete). DI-Bindings in services.yaml.
ClockInterface (Logic) + SystemClock (Data).

Tests: Model/Factory/UseCase-Unit + Provider/Processor/Mapper-Integration
(In-Mem-SQLite). composer test grün (53 Tests, 161 Assertions).
2026-09-19 08:55:54 +02:00
70 changed files with 4866 additions and 154 deletions
+151 -133
View File
@@ -3,6 +3,30 @@
> Konkretisierung der ADR-007 für diese App. Referenz: > Konkretisierung der ADR-007 für diese App. Referenz:
> [jens/symfony-architektur](https://git.ljh31.de/jens/symfony-architektur). > [jens/symfony-architektur](https://git.ljh31.de/jens/symfony-architektur).
## 0.1 Namenskonzept (2026-09-13 final)
**Regel: Domäne deutsch, technische Begriffe / Suffixe englisch.**
| Rolle | Name | Beispiel |
|---|---|---|
| Rich-Modell | Substantiv | `Mahlzeit` |
| Domain-Enum (Status) | `<Model>Status` | `MahlzeitStatus` (`Geplant`, `Gegessen`) |
| Domain-Enum (Wert) | Substantiv, Werte kurz | `Wochentag` (`Mo`…`So`) |
| DTOs | `<Model><Aktion>{Request,Response}` | `MahlzeitCreateRequest`, `MahlzeitListResponse` |
| Fabrik (DTO→Model) | `<Model>Factory` | `MahlzeitFactory` |
| UseCase | `<Model><Aktion>UseCase` | `MahlzeitCreateUseCase` |
| Read-Query | `<Model><Aktion>Query` | `MahlzeitListQuery` |
| Manager | `<Model>Manager` | `MahlzeitManager` |
| Provider (Read) | `<Model>Provider(Interface)` | `MahlzeitProvider` |
| Processor (Write) | `<Model>Processor(Interface)` | `MahlzeitProcessor` |
| Mapper | `<Model>Mapper` | `MahlzeitMapper` |
| Entity | `<Model>Entity` | `MahlzeitEntity` |
| Exception | `<Model>…Exception` | `MahlzeitNotFoundException` |
| Events | `<Model><Aktion>Event` | `MahlzeitCreatedEvent` |
Methode-Suffixe in Interfaces bleiben kurz/englisch (`create`, `save`, `delete`, `find`, `list`),
da sie technische Konvention sind — nur die **Klassen-/Modellnamen** sind domänendeutsch.
## 0. Die drei Layer (Kurzreferenz) ## 0. Die drei Layer (Kurzreferenz)
``` ```
@@ -26,43 +50,36 @@ UI → Logic; Logic → Interface (Definition im Logic); Data → Interface
(Definition im Data). Die Data-Layer implementiert Interfaces aus der (Definition im Data). Die Data-Layer implementiert Interfaces aus der
Logic-Layer. **Keine** Umkehr. Logic-Layer. **Keine** Umkehr.
**Namenskonvention (Kurz):** **Namenskonvention (Kurz)** — siehe §0.1 Namenskonzept:
- Interface: Suffixe `Interface` (z. B. `DishProviderInterface`). `MahlzeitProviderInterface`, `MahlzeitCreateUseCase`, `MahlzeitListQuery`,
- UseCase: Suffix `UseCase` (z. B. `CreateDishUseCase`). `Manager`, `Provider`/`Processor`, `Mapper`, `MahlzeitFactory` (statt `*ModelFactory`),
- Query: Suffix `Query` (z. B. `ListDishesQuery`). `Create/Update/Delete/…Request`, `…Response`, Exception, `Entity`.
- Manager: Suffix `Manager` (z. B. `DishManager`).
- Provider/Processor: Suffix `Provider`/`Processor` (z. B. `DishProvider`).
- Mapper: Suffix `Mapper` (z. B. `DishMapper`).
- Model-Factory: Suffix `Factory` (z. B. `DishModelFactory`).
- DTO: Suffix `Request` bzw. `Response`.
- Exception: Suffix `Exception`.
- Entity (Data): Suffix `Entity` (z. B. `DishEntity`).
## 1. Domain-Modell (Logik) ## 1. Domain-Modell (Logik)
**Eine** Modul-Domäne: `Essen`. Darin ein Business-Model: `Dish` **Eine** Modul-Domäne: `Essen`. Darin ein Business-Model: `Mahlzeit`
(Gericht/Eintrag). Wichtiger Hinweis: Die "Archivsicht" ist **kein** (Gericht/Eintrag). Wichtiger Hinweis: Die "Archivsicht" ist **kein**
abgesonderter Business-Model, sondern eine **Volltext-Sicht** (Read-Query) mit abgesonderter Business-Model, sondern eine **Volltext-Sicht** (Read-Query) mit
dem Filter `status = gegessen`. Das reduziert die Komplexität. dem Filter `status = gegessen`. Das reduziert die Komplexität.
### 1.1. Das Business-Model `Dish` ### 1.1. Das Business-Model `Mahlzeit`
`src/Logic/Essen/Model/Dish.php` – **Rich Domain Model** (POPO, readonly, `src/Logic/Essen/Model/Mahlzeit.php` – **Rich Domain Model** (POPO, readonly,
kein Framework): kein Framework):
```php ```php
namespace App\Logic\Essen\Model; namespace App\Logic\Essen\Model;
class Dish class Mahlzeit
{ {
public function __construct( public function __construct(
public readonly ?int $id, public readonly ?int $id,
public string $name, public string $name,
public ?string $notiz, public ?string $notiz,
/** @var DishDay[] */ /** @var Wochentag[] */
public readonly array $wochentage, public readonly array $wochentage,
public readonly int $sort_order, public readonly int $sort_order,
public DishStatus $status, public MahlzeitStatus $status,
public readonly ?\DateTimeImmutable $gegessen_am, public readonly ?\DateTimeImmutable $gegessen_am,
public readonly \DateTimeImmutable $erstellt_am, public readonly \DateTimeImmutable $erstellt_am,
public readonly \DateTimeImmutable $aktualisiert_am, public readonly \DateTimeImmutable $aktualisiert_am,
@@ -70,11 +87,11 @@ class Dish
// State transitions: kapseln eigene Statusänderungen // State transitions: kapseln eigene Statusänderungen
public function markAsGegessen(\DateTimeImmutable $now): self { public function markAsGegessen(\DateTimeImmutable $now): self {
if ($this->status === DishStatus::GEGESSEN) { if ($this->status === MahlzeitStatus::GEGESSEN) {
throw new BusinessRuleViolationException('Bereits gegessen.'); throw new BusinessRuleViolationException('Bereits gegessen.');
} }
return new self($this->id, $this->name, $this->notiz, $this->wochentage, return new self($this->id, $this->name, $this->notiz, $this->wochentage,
$this->sort_order, DishStatus::GEGESSEN, $now, $this->erstellt_am, $now); $this->sort_order, MahlzeitStatus::GEGESSEN, $now, $this->erstellt_am, $now);
} }
public function markAsGeplant(\DateTimeImmutable $now): self { /* symmetrisch */ } public function markAsGeplant(\DateTimeImmutable $now): self { /* symmetrisch */ }
@@ -82,23 +99,24 @@ class Dish
``` ```
**Enums** in `src/Logic/Essen/Model/`: **Enums** in `src/Logic/Essen/Model/`:
- `DishStatus` (`GEPLANT`, `GEGESSEN`) - `MahlzeitStatus` (`GEPLANT`, `GEGESSEN`)
- `DishDay` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`) - `Wochentag` (`MO`, `DI`, `MI`, `DO`, `FR`, `SA`, `SO`)
### 1.2. DTOs ### 1.2. DTOs
``` ```
src/Logic/Essen/Dto/ src/Logic/Essen/Dto/
├── CreateDishRequest.php // name, notiz?, wochentag[]? ├── MahlzeitCreateRequest.php // name, notiz?, wochentage?: Wochentag[]
├── UpdateDishRequest.php // id, name, notiz?, wochentag[]?, sort_order? ├── MahlzeitUpdateRequest.php // name, notiz?, wochentage? (sort_order via M3-Reorder)
├── DeleteDishRequest.php // id ├── MahlzeitMoveRequest.php // id, position (int)
├── MoveDishRequest.php // id, position (int) ├── MahlzeitToggleStatusRequest.php // id
├── ToggleDishStatusRequest.php // id ├── MahlzeitListRequest.php // status? (optional), sort='sort_order'|'gegessen_am'
├── ListDishesRequest.php // status? (optional), sort='sort_order'|'gegessen_am' ├── MahlzeitListResponse.php // items: Mahlzeit[]
├── ListDishesResponse.php // items: Dish[] ├── MahlzeitCreateResponse.php // id, mahlzeit
├── MahlzeitUpdateResponse.php // id, mahlzeit
├── InspirationRequest.php // limit=5, offset=0, excludeIds: int[] ├── InspirationRequest.php // limit=5, offset=0, excludeIds: int[]
├── InspirationResponse.php // suggestions: Dish[] ├── InspirationResponse.php // suggestions: Mahlzeit[]
└── TodayResponse.php // dishes: Dish[] // optional HA-Consumer └── TodayResponse.php // mahlzeiten: Mahlzeit[] // optional HA-Consumer
``` ```
Alle `readonly`, native Typisierung, **keine** Attribute/Constraints. Alle `readonly`, native Typisierung, **keine** Attribute/Constraints.
@@ -107,43 +125,43 @@ Alle `readonly`, native Typisierung, **keine** Attribute/Constraints.
``` ```
src/Logic/Essen/UseCase/ src/Logic/Essen/UseCase/
├── CreateDishUseCase.php ├── MahlzeitCreateUseCase.php // execute(MahlzeitCreateRequest): MahlzeitCreateResponse
├── UpdateDishUseCase.php ├── MahlzeitUpdateUseCase.php // execute(int $id, MahlzeitUpdateRequest): MahlzeitUpdateResponse
├── DeleteDishUseCase.php ├── MahlzeitDeleteUseCase.php // execute(int $id): void
├── MoveDishUseCase.php // ändert sort_order (bzw. sort_order-Reihenfolge) ├── MahlzeitMoveUseCase.php // ändert sort_order
└── ToggleDishStatusUseCase.php // markAsGegessen / markAsGeplant └── MahlzeitToggleStatusUseCase.php // markAsGegessen / markAsGeplant
``` ```
**Verfahren (schlau aus Referenz Sektion 1 & 3):** **Verfahren (schlau aus Referenz Sektion 1 & 3):**
1. Syntaktische Validierung im UI-Layer (Controller/Command) als **rein 1. Syntaktische Validierung im UI-Layer (Controller/Command) als **rein
PHP** (keine Symfony-Validator-Constraints auf DTOs). PHP** (keine Symfony-Validator-Constraints auf DTOs).
2. Business-Validierung im UseCase + im Model-Konstruktor (Invarianzen). 2. Business-Validierung im UseCase + im Model-Konstruktor (Invarianzen).
3. Persistence im UseCase via `DishManager` → `DishProcessor` 3. Persistence im UseCase via `MahlzeitManager` → `MahlzeitProcessor`
(Write); Cache-Sidecar-Invalidierung im Manager. (Write); Cache-Sidecar-Invalidierung im Manager.
4. Event-Dispatch nach erfolgreichem Commit (Bridge in Data-Layer). 4. Event-Dispatch nach erfolgreichem Commit (Bridge in Data-Layer).
5. Response-DTO (z. B. `UpdateDishResponse`) zurück an Controller. 5. Response-DTO (z. B. `MahlzeitUpdateResponse`) zurück an Controller.
**Beispiel** (UseCase `CreateDishUseCase`): **Beispiel** (UseCase `MahlzeitCreateUseCase`):
```php ```php
namespace App\Logic\Essen\UseCase; namespace App\Logic\Essen\UseCase;
readonly class CreateDishUseCase readonly class MahlzeitCreateUseCase
{ {
public function __construct( public function __construct(
private TransactionManagerInterface $tx, private TransactionManagerInterface $tx,
private DishManagerInterface $dishes, private MahlzeitManagerInterface $mahlzeiten,
private DishModelFactory $factory, private MahlzeitFactory $factory,
private EventDispatcherInterface $events, private EventDispatcherInterface $events,
) {} ) {}
public function execute(CreateDishRequest $req): CreateDishResponse public function execute(MahlzeitCreateRequest $req): MahlzeitCreateResponse
{ {
return $this->tx->execute(function() use ($req): CreateDishResponse { return $this->tx->execute(function() use ($req): MahlzeitCreateResponse {
$dish = $this->factory->createFromRequest($req); // DTO -> Model $mahlzeit = $this->factory->createFromCreateRequest($req); // DTO -> Model
$saved = $this->dishes->create($dish); // Model -> Processor $gespeichert = $this->mahlzeiten->create($mahlzeit); // Model -> Processor
$this->events->dispatch(new DishCreatedEvent(id: $saved->id)); $this->events->dispatch(new MahlzeitCreatedEvent(id: $gespeichert->id));
return new CreateDishResponse(id: $saved->id, dish: $saved); return new MahlzeitCreateResponse(id: $gespeichert->id, mahlzeit: $gespeichert);
}); });
} }
} }
@@ -153,8 +171,8 @@ readonly class CreateDishUseCase
``` ```
src/Logic/Essen/Query/ src/Logic/Essen/Query/
├── ListDishesQuery.php // -> ListDishesResponse ├── MahlzeitListQuery.php // -> MahlzeitListResponse
├── GetDishQuery.php // -> Dish (optional) ├── MahlzeitGetQuery.php // -> Mahlzeit (optional)
├── TodayQuery.php // -> TodayResponse (HA-Consumer / optional) ├── TodayQuery.php // -> TodayResponse (HA-Consumer / optional)
└── InspirationQuery.php // -> InspirationResponse (5er-Batch + Offset) └── InspirationQuery.php // -> InspirationResponse (5er-Batch + Offset)
``` ```
@@ -166,28 +184,28 @@ readonly class InspirationQuery
{ {
public function __construct( public function __construct(
private InspirationWeightSettings $settings, // w_slow/w_rare/w_hot private InspirationWeightSettings $settings, // w_slow/w_rare/w_hot
private DishManagerInterface $dishes, private MahlzeitManagerInterface $mahlzeiten,
private DishHistoryProviderInterface $history, // aus Data-Layer private MahlzeitHistoryProviderInterface $history, // aus Data-Layer
) {} ) {}
public function execute(InspirationRequest $req): InspirationResponse public function execute(InspirationRequest $req): InspirationResponse
{ {
$all = $this->dishes->listGegessen(); $all = $this->mahlzeiten->listGegessen();
$history = $this->history->historyByDishName(); $history = $this->history->historyByMahlzeitName();
$scored = $all $scored = $all
->filter(fn(Dish $d) => !in_array($d->id, $req->excludeIds, true)) ->filter(fn(Mahlzeit $m) => !in_array($m->id, $req->excludeIds, true))
->map(fn(Dish $d) => new Scored( ->map(fn(Mahlzeit $m) => new Scored(
dish: $d, mahlzeit: $m,
score: $this->score($d, $history), score: $this->score($m, $history),
)) ))
->sortDescByScore() ->sortDescByScore()
->slice($req->offset, $req->limit); ->slice($req->offset, $req->limit);
return new InspirationResponse(suggestions: $scored->map(fn($s)=>$s->dish)); return new InspirationResponse(suggestions: $scored->map(fn($s)=>$s->mahlzeit));
} }
private function score(Dish $d, array $h): float { private function score(Mahlzeit $m, array $h): float {
// w_slow * staleness + w_rare * rarity + w_hot * popularity // w_slow * staleness + w_rare * rarity + w_hot * popularity
// (Details: ADR-006) // (Details: ADR-006)
} }
@@ -198,24 +216,24 @@ readonly class InspirationQuery
``` ```
src/Logic/Essen/Manager/ src/Logic/Essen/Manager/
├── DishManagerInterface.php ├── MahlzeitManagerInterface.php
└── DishManager.php // Read + Write-Koordination, Cache-Aside (vorerst └── MahlzeitManager.php // Read + Write-Koordination, Cache-Aside (vorerst
// ohne Cache, Platzhalter für Phase 2) // ohne Cache, Platzhalter für Phase 2)
``` ```
Interface (in Logic): Interface (in Logic):
```php ```php
interface DishManagerInterface interface MahlzeitManagerInterface
{ {
public function create(Dish $dish): Dish; public function create(Mahlzeit $mahlzeit): Mahlzeit;
public function update(Dish $dish): Dish; public function update(Mahlzeit $mahlzeit): Mahlzeit;
public function delete(int $id): void; public function delete(int $id): void;
public function move(Dish $dish, int $newSortOrder): void; public function move(Mahlzeit $mahlzeit, int $newSortOrder): void;
public function find(int $id): Dish; public function find(int $id): Mahlzeit; // -> MahlzeitNotFoundException (404)
/** @return Dish[] */ /** @return list<Mahlzeit> */
public function listGegessen(): array; public function listGegessen(): array;
/** @return Dish[] */ /** @return list<Mahlzeit> */
public function listGeplant(): array; public function listGeplant(): array;
public function invalidateCache(): void; public function invalidateCache(): void;
} }
@@ -225,8 +243,8 @@ interface DishManagerInterface
``` ```
src/Logic/Essen/ src/Logic/Essen/
├── DishProviderInterface.php // Read: find, listGegessen, listGeplant, countByName... ├── MahlzeitProviderInterface.php // Read: find, listGegessen, listGeplant, countByName...
└── DishProcessorInterface.php // Write: create, save, delete; move (sort_order) └── MahlzeitProcessorInterface.php // Write: create, save, delete; move (sort_order)
``` ```
Implementierungen liegen in `src/Data/Essen/Provider/` bzw. `Processor/`. Implementierungen liegen in `src/Data/Essen/Provider/` bzw. `Processor/`.
@@ -234,10 +252,10 @@ Implementierungen liegen in `src/Data/Essen/Provider/` bzw. `Processor/`.
### 1.7. Model-Factory (DTO -> Model) ### 1.7. Model-Factory (DTO -> Model)
``` ```
src/Logic/Essen/Mapping/DishModelFactory.php src/Logic/Essen/Mapping/MahlzeitFactory.php
├── createFromRequest(CreateDishRequest): Dish ├── createFromCreateRequest(MahlzeitCreateRequest): Mahlzeit
├── updateFromRequest(UpdateDishRequest): Dish ├── updateFromUpdateRequest(Mahlzeit, MahlzeitUpdateRequest): Mahlzeit
└── rebuildFromPartialUpdate(Dish $current, UpdateDishRequest $req): Dish └── ...
``` ```
### 1.8. Domänen-Exceptions ### 1.8. Domänen-Exceptions
@@ -252,7 +270,7 @@ src/Logic/Common/Exception/
└── InfrastructureException.php // -> 503 └── InfrastructureException.php // -> 503
src/Logic/Essen/Exception/ src/Logic/Essen/Exception/
└── DishNotFoundException.php // erbt ResourceNotFoundException └── MahlzeitNotFoundException.php // erbt ResourceNotFoundException
``` ```
**HTTP-Mapping** erfolgt zentral in `src/UI/Http/ExceptionSubscriber.php` **HTTP-Mapping** erfolgt zentral in `src/UI/Http/ExceptionSubscriber.php`
@@ -275,59 +293,59 @@ src/Logic/Common/
``` ```
src/Data/Essen/ src/Data/Essen/
├── Entity/DishEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt! ├── Entity/MahlzeitEntity.php // #[ORM\Entity] – Doctrine, Attribute erlaubt!
├── Provider/DishProvider.php // implementiert DishProviderInterface ├── Provider/MahlzeitProvider.php // implementiert MahlzeitProviderInterface
├── Processor/DishProcessor.php // implementiert DishProcessorInterface ├── Processor/MahlzeitProcessor.php // implementiert MahlzeitProcessorInterface
├── Mapper/DishMapper.php // toModel / createEntity / updateEntity ├── Mapper/MahlzeitMapper.php // toModel / createEntity / updateEntity
└── HistoryProvider/DishHistoryProvider.php // (optional, für Inspiration) └── HistoryProvider/MahlzeitHistoryProvider.php // (optional, für Inspiration)
src/Data/Infrastructure/ src/Data/Infrastructure/
├── Doctrine/EntityManagerTransactionManager.php ├── Doctrine/EntityManagerTransactionManager.php
└── SymfonyEventDispatcherBridge.php └── SymfonyEventDispatcherBridge.php
``` ```
**Doctrine Entity** (`DishEntity`): **Doctrine Entity** (`MahlzeitEntity`):
```php ```php
#[ORM\Entity, ORM\Table(name: 'essens_dishes')] #[ORM\Entity, ORM\Table(name: 'essens_mahlzeiten')]
class DishEntity class MahlzeitEntity
{ {
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)] #[ORM\Column(type: 'string', length: 255)]
private string $name = ''; private string $name = '';
// (notiz, wochentage (set), sort_order, status, gegessen_am, timestamps) // (notiz, wochentage (CSV-string „mo,di,fr", nullable), sort_order, status (enum), gegessen_am, timestamps)
// nur Getter/Setter, keine Logik // nur Getter/Setter, keine Logik
} }
``` ```
**Mapper** (stateless): **Mapper** (stateless):
- `toModel(DishEntity): Dish` - `toModel(MahlzeitEntity): Mahlzeit`
- `createEntity(Dish): DishEntity` - `createEntity(Mahlzeit): MahlzeitEntity`
- `updateEntity(Dish, DishEntity): void` - `updateEntity(Mahlzeit, MahlzeitEntity): void`
**Processor** (Write): **Processor** (Write):
```php ```php
readonly class DishProcessor implements DishProcessorInterface readonly class MahlzeitProcessor implements MahlzeitProcessorInterface
{ {
public function __construct( public function __construct(
private EntityManagerInterface $em, private EntityManagerInterface $em,
private DishMapper $mapper, private MahlzeitMapper $mapper,
) {} ) {}
public function create(Dish $model): Dish { public function create(Mahlzeit $model): Mahlzeit {
$entity = $this->mapper->createEntity($model); $entity = $this->mapper->createEntity($model);
$this->em->persist($entity); $this->em->persist($entity);
$this->em->flush(); $this->em->flush();
return $this->mapper->toModel($entity); // mit id aus DB return $this->mapper->toModel($entity); // mit id aus DB
} }
public function save(Dish $model): Dish { public function save(Mahlzeit $model): Mahlzeit {
$entity = $this->em->getRepository(DishEntity::class)->find($model->id); $entity = $this->em->getRepository(MahlzeitEntity::class)->find($model->id);
if ($entity === null) throw new DishNotFoundException($model->id); if ($entity === null) throw new MahlzeitNotFoundException($model->id);
$this->mapper->updateEntity($model, $entity); $this->mapper->updateEntity($model, $entity);
$this->em->flush(); $this->em->flush();
return $model; return $model;
@@ -340,13 +358,13 @@ readonly class DishProcessor implements DishProcessorInterface
``` ```
src/UI/Http/Essen/ src/UI/Http/Essen/
├── DishController.php // CRUD, toggle, move (sort_order) ├── MahlzeitController.php // CRUD, toggle, move (sort_order)
├── InspirationController.php // 5er-Batch ├── InspirationController.php // 5er-Batch
├── TodayController.php // optional: HA-Consumer (GET /api/today) ├── TodayController.php // optional: HA-Consumer (GET /api/today)
└── ExceptionSubscriber.php // zentrales Domain-Exception → HTTP-Mapping └── ExceptionSubscriber.php // zentrales Domain-Exception → HTTP-Mapping
src/UI/Twig/ // Twig-Templates (htmx-Fragmente) templates/ // Twig-Templates (htmx-Fragmente)
├── dish/ ├── mahlzeiten/
│ ├── index.html.twig // Planungsliste + Archiv-Tab │ ├── index.html.twig // Planungsliste + Archiv-Tab
│ ├── form.html.twig // Erstellen/Bearbeiten │ ├── form.html.twig // Erstellen/Bearbeiten
│ └── item.html.twig // Eintrag (li) – SortableJS │ └── item.html.twig // Eintrag (li) – SortableJS
@@ -369,19 +387,19 @@ eingeblendet. Kein Build-Step.
``` ```
tests/ tests/
├── Unit/Logic/ // Pure PHP, ohne Kernel (schnell) ├── Unit/Logic/ // Pure PHP, ohne Kernel (schnell)
│ ├── Essen/Model/DishTest.php │ ├── Essen/Model/MahlzeitTest.php
│ ├── Essen/UseCase/CreateDishUseCaseTest.php │ ├── Essen/UseCase/MahlzeitCreateUseCaseTest.php
│ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider │ ├── Essen/Query/InspirationQueryTest.php // mit gemocktem Provider
│ └── Essen/Mapping/DishModelFactoryTest.php │ └── Essen/Mapping/MahlzeitFactoryTest.php
├── Integration/Data/ // Test-Env + SQLite (oder in-memory) ├── Integration/Data/ // Test-Env + file-basierte SQLite (var/test.db)
│ ├── Essen/DishProviderTest.php │ ├── Essen/MahlzeitProviderTest.php
│ ├── Essen/DishProcessorTest.php │ ├── Essen/MahlzeitProcessorTest.php
│ └── Essen/DishMapperTest.php │ └── Essen/MahlzeitMapperTest.php
├── Functional/UI/ // Symfony WebTestCase, isolierte Endpunkte ├── Functional/UI/ // Symfony WebTestCase, isolierte Endpunkte
│ ├── Essen/DishControllerTest.php │ ├── Essen/MahlzeitControllerTest.php
│ └── Essen/ExceptionSubscriberTest.php │ └── Essen/ExceptionSubscriberTest.php
└── Functional/Scenarios/ // User-Flows └── Functional/Scenarios/ // User-Flows
└── EssenDishLifecycleTest.php // create → toggle → archive → restore └── MahlzeitLifecycleTest.php // create → toggle → archive → restore
``` ```
**Mocking-Richtlinien** (aus Referenz Sektion 13): **Mocking-Richtlinien** (aus Referenz Sektion 13):
@@ -420,25 +438,25 @@ svc-essensplanung/
│ │ │ ├── TransactionManagerInterface.php │ │ │ ├── TransactionManagerInterface.php
│ │ │ └── EventDispatcherInterface.php │ │ │ └── EventDispatcherInterface.php
│ │ └── Essen/ │ │ └── Essen/
│ │ ├── Model/ (Dish, DishDay, DishStatus) │ │ ├── Model/ (Mahlzeit, Wochentag, MahlzeitStatus)
│ │ ├── Dto/ (10 Dateien) │ │ ├── Dto/ (10 Dateien)
│ │ ├── UseCase/ (5) │ │ ├── UseCase/ (5)
│ │ ├── Query/ (4) │ │ ├── Query/ (4)
│ │ ├── Manager/ (interface + impl) │ │ ├── Manager/ (interface + impl)
│ │ ├── DishProviderInterface.php │ │ ├── MahlzeitProviderInterface.php
│ │ ├── DishProcessorInterface.php │ │ ├── MahlzeitProcessorInterface.php
│ │ ├── Mapping/DishModelFactory.php │ │ ├── Mapping/MahlzeitFactory.php
│ │ └── Event/DishCreatedEvent.php, DishArchivedEvent.php, ... │ │ └── Event/MahlzeitCreatedEvent.php, MahlzeitArchivedEvent.php, ...
│ ├── Data/ │ ├── Data/
│ │ ├── Infrastructure/ │ │ ├── Infrastructure/
│ │ │ ├── Doctrine/EntityManagerTransactionManager.php │ │ │ ├── Doctrine/EntityManagerTransactionManager.php
│ │ │ └── SymfonyEventDispatcherBridge.php │ │ │ └── SymfonyEventDispatcherBridge.php
│ │ └── Essen/ │ │ └── Essen/
│ │ ├── Entity/DishEntity.php │ │ ├── Entity/MahlzeitEntity.php
│ │ ├── Provider/DishProvider.php │ │ ├── Provider/MahlzeitProvider.php
│ │ ├── Processor/DishProcessor.php │ │ ├── Processor/MahlzeitProcessor.php
│ │ ├── Mapper/DishMapper.php │ │ ├── Mapper/MahlzeitMapper.php
│ │ └── HistoryProvider/DishHistoryProvider.php │ │ └── HistoryProvider/MahlzeitHistoryProvider.php
│ └── UI/ │ └── UI/
│ ├── Http/Essen/ (3 Controllers + ExceptionSubscriber) │ ├── Http/Essen/ (3 Controllers + ExceptionSubscriber)
│ └── Twig/ (Layout + Fragmente) │ └── Twig/ (Layout + Fragmente)
@@ -459,28 +477,28 @@ svc-essensplanung/
## 3. Dependency Graph (konkret) ## 3. Dependency Graph (konkret)
``` ```
UI: DishController UI: MahlzeitController
-> Logic: CreateDishUseCase -> Logic: MahlzeitCreateUseCase
-> Logic: MoveDishUseCase -> Logic: MahlzeitMoveUseCase
-> Logic: ToggleDishStatusUseCase -> Logic: MahlzeitToggleStatusUseCase
-> Logic: ListDishesQuery -> Logic: MahlzeitListQuery
-> Logic: InspirationQuery -> Logic: InspirationQuery
-> Logic DTOs (CreateDishRequest, MoveDishRequest, ...) -> Logic DTOs (MahlzeitCreateRequest, MahlzeitMoveRequest, ...)
Logic: CreateDishUseCase Logic: MahlzeitCreateUseCase
-> Logic: Common TransactionManagerInterface -> Logic: Common TransactionManagerInterface
-> Logic: Essen DishManagerInterface -> Logic: Essen MahlzeitManagerInterface
-> Logic: Essen Mapping/DishModelFactory -> Logic: Essen Mapping/MahlzeitFactory
-> Logic: Common EventDispatcherInterface -> Logic: Common EventDispatcherInterface
-> Logic: Essen/Event DishCreatedEvent -> Logic: Essen/Event MahlzeitCreatedEvent
Logic: DishManager Logic: MahlzeitManager
-> Logic: Essen DishProviderInterface (Read) -> Logic: Essen MahlzeitProviderInterface (Read)
-> Logic: Essen DishProcessorInterface (Write) -> Logic: Essen MahlzeitProcessorInterface (Write)
Data: DishProvider implements Logic:DishProviderInterface Data: MahlzeitProvider implements Logic:MahlzeitProviderInterface
Data: DishProcessor implements Logic:DishProcessorInterface Data: MahlzeitProcessor implements Logic:MahlzeitProcessorInterface
Data: DishMapper (Entity <-> Model, stateless) Data: MahlzeitMapper (Entity <-> Model, stateless)
Data: EntityManagerTransactionManager implements Logic:Common TransactionManagerInterface Data: EntityManagerTransactionManager implements Logic:Common TransactionManagerInterface
Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherInterface Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherInterface
``` ```
@@ -519,7 +537,7 @@ Data: SymfonyEventDispatcherBridge implements Logic:Common EventDispatcherIn
## 6. Offene / Phase-2-Punkte ## 6. Offene / Phase-2-Punkte
- [ ] `settings.yaml` für Inspiration-Gewichte (ADR-006). Default 0.5/0.3/0.2. - [ ] `settings.yaml` für Inspiration-Gewichte (ADR-006). Default 0.5/0.3/0.2.
- [ ] `DishHistoryProvider` konkret (für Inspiration, zählt Auftritte pro Name). - [ ] `MahlzeitHistoryProvider` konkret (für Inspiration, zählt Auftritte pro Name).
- [ ] `GET /api/today` für HA-Consumer (Phase 2, ADR-004). - [ ] `GET /api/today` für HA-Consumer (Phase 2, ADR-004).
- [ ] Optional: `Push`-Benachrichtigung per HA-Notify (Phase 2). - [ ] Optional: `Push`-Benachrichtigung per HA-Notify (Phase 2).
- [ ] Optional: Cache-Aside im `DishManager` (vorerst ohne, Phase 2). - [ ] Optional: Cache-Aside im `MahlzeitManager` (vorerst ohne, Phase 2).
+38 -13
View File
@@ -32,23 +32,48 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
- ADR-003 überarbeitet (Keycloak statt „kein Login") - ADR-003 überarbeitet (Keycloak statt „kein Login")
- **DoD:** Container läuft, `GET /` → Keycloak-Login, danach Hello-World-Seite - **DoD:** Container läuft, `GET /` → Keycloak-Login, danach Hello-World-Seite
### M1 – CRUD-Slice ### M1 – CRUD-Slice (Model: `Mahlzeit` – Namenskonszept: Domäne DE, Tech-EN, s. architektur.md §0.1)
- Logic: `Dish`-Model, `CreateDish`/`UpdateDish`/`DeleteDish`, `ListDishes`, `DishDto` - **Schritt 1 (Logik+Daten):** `src/Logic/Essen` – `Mahlzeit`-Model (+`MahlzeitStatus`, `Wochentag`),
- Data: `DishEntity`, Migration 0001, Provider/Processor-Impl, Mapper, Exception-Subscriber `Mahlzeit{Create,Update}Request/Response`-DTOs, `MahlzeitFactory`, `Mahlzeit{Provider,Processor}Interface`,
- UI: `DishController` (htmx-Partials), Formular + Validierung im Controller, Liste `MahlzeitManager`, `Mahlzeit{Create,Update,Delete}UseCase`, `MahlzeitListQuery`,
- Tests: UseCase-Unit (in-mem SQLite) + 1 Controller-Feature-Test `MahlzeitNotFoundException` (→404), Events; `src/Data/Essen` – `MahlzeitEntity`
- **DoD:** Gericht anlegen/bearbeiten/löschen im Browser (als eingeloggter User) (Tabelle `essens_mahlzeiten`, wochentage als CSV), `MahlzeitMapper/Provider/Processor`, Migration.
Tests: UseCase/Model-Unit (Pure PHP, Fakes) + Provider/Processor-Integration (file-basierte SQLite, `var/test.db`).
**DoD 1:** `composer test` grün, CRUD vollständig über Logic-API testbar.
- **Schritt 2 (UI):** `MahlzeitController` + `templates/mahlzeiten/` + Routen `/mahlzeiten[/{id}]`
(POST/Redirect/Flash, Wochentage als Checkboxes) + Controller-Feature-Test (eingeloggte Session).
- **DoD:** Gericht anlegen/bearbeiten/löschen im Browser (als eingeloggte/r Nutzer)
### M2 – Status & Wochentage ### M2 – Status & Wochentage
- `CheckOffDish`/`UncheckDish` (Status `aktiv`/`gecheckt`), `ArchiveDish` - `CheckOffDish`/`UncheckDish` (Status `aktiv`/`gecheckt`), `ArchiveDish`
- Wochentag-Tags (Mo–So) setzen/ändern, Liste nach Tag gruppier-/filterbar - Wochentag-Tags (Mo–So) setzen/ändern, Liste nach Tag gruppier-/filterbar
- **DoD:** Check-Off via htmx-Toggle, Tagesansicht, Archiv-Tab - **DoD:** Check-Off via htmx-Toggle, Tagesansicht, Archiv-Tab
- *Konkrete Umsetzung (M1-Konvention, Domäne DE):* `MahlzeitCheckOffUseCase`
(geplant→gegessen) / `MahlzeitUncheckUseCase` (gegessen→geplant), beide
idempotent, `MahlzeitStatusChangedEvent`. „Archiv“ = Read-View auf
`status = gegessen` (kein eigener Status/Use-Case). Check-Off als htmx-Partial
(`POST /mahlzeiten/{id}/checkoff|uncheck`), Tagesansicht gruppiert nach
Wochentag (Mo…So, „Ohne Tag“), Archiv-Tab `GET /mahlzeiten/archiv`.
### M3 – Reorder + Inspiration ### M3 – Reorder + Inspiration
- `ReorderDishes` (SortableJS-`change` → Position-Ints, persistiert) - `ReorderDishes` (SortableJS-`change` → Position-Ints, persistiert)
- `SuggestDishes`-Query: Slow 0.5 / Rarität 0.3 / Hotness 0.2 (ADR-006), - `SuggestDishes`-Query: Slow 0.5 / Rarität 0.3 / Hotness 0.2 (ADR-006),
5 Vorschläge + „Weitere 5", 1-Klick-Adopt → `CreateDish` mit Referenz 5 Vorschläge + „Weitere 5", 1-Klick-Adopt → `CreateDish` mit Referenz
- **DoD:** Reorder bleibt nach Reload bestehen; Inspiration klickbar adoptierend - **DoD:** Reorder bleibt nach Reload bestehen; Inspiration klickbar adoptierend
- *Konkrete Umsetzung (M1-Konvention, Domäne DE):* Schritt 1 (Logik):
`MahlzeitReorderUseCase` (neue ID-Reihenfolge → `sortOrder` neu nummerieren,
nur geänderte Positionen updaten, `MahlzeitReorderedEvent`),
`MahlzeitInspirationQuery` (Kandidaten = Namen mit ≥ 1 `gegessen`-Eintrag,
Repräsentant = neuester `gegessen`-Eintrag, Score nach ADR-006 mit
Defaults 0.5/0.3/0.2, `ausschluss`-IDs für „Weitere 5", Sortierung
absteigend nach Score, bei Gleichstand alphabetisch), `MahlzeitAdoptUseCase`
(Quelle → neue `geplant`-Kopie mit `gegessenAm = null`, `MahlzeitAdoptedEvent`).
Schritt 2 (UI): `GET /mahlzeiten` = flache, per SortableJS sortierbare
Planungsliste (nur `geplant`, Drag-Handle, `POST /mahlzeiten/reorder`
mit JSON/Form `ids` → 204); Tagesansicht ausgelagert nach
`GET /mahlzeiten/tage`; Inspiration unter `GET /inspiration` (5 Vorschläge,
„Weitere 5" via `?ausschluss=…`, 1-Klick-`POST /mahlzeiten/{id}/adopt`
mit Kontext-Erhaltung).
### M4 – Härtung & API ### M4 – Härtung & API
- `X-Api-Token`-Guard (optional, `.env`) für `GET /api/today` (HA-ready) - `X-Api-Token`-Guard (optional, `.env`) für `GET /api/today` (HA-ready)
@@ -64,18 +89,18 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
## Test-Strategie (quer) ## Test-Strategie (quer)
- PHPUnit je Milestone: UseCase-Unit (in-mem SQLite, Logic-Dependencies als - PHPUnit je Milestone: UseCase/Model-Unit (Pure PHP, Logic-Dependencies als
Fakes) + Feature-Tests (HTTP gegen Test-DB) Fakes) + Integration-/Feature-Tests (HTTP/DB gegen file-basierte Test-DB `var/test.db`)
- Manuell: `docker compose run app composer test` bzw. `vendor/bin/phpunit` - Manuell: `docker compose run app composer test` bzw. `vendor/bin/phpunit`
- Kein CI (bewusst, s. Prinzipien) - Kein CI (bewusst, s. Prinzipien)
## Abnahme-Historie ## Abnahme-Historie
| MS | Datum | Status | | MS | Datum | Status |
|----|-------|--------| |----|----------|------------|
| M0 | | offen | | M0 | 2026-09-13 | abgenommen |
| M1 | | offen | | M1 | 2026-09-19 | abgenommen |
| M2 | | offen | | M2 | 2026-09-19 | abgenommen |
| M3 | | offen | | M3 | 2026-09-19 | abgenommen |
| M4 | | offen | | M4 | | offen |
| M5 | | offen | | M5 | | offen |
+3 -1
View File
@@ -16,4 +16,6 @@ doctrine:
when@test: when@test:
doctrine: doctrine:
dbal: dbal:
url: 'sqlite:///:memory:' # Datei-basiert (statt :memory:), damit das Schema über Request-Grenzen
# und geteilte Kernel-/Connection-Instanzen hinweg stabil bleibt.
url: 'sqlite:///%kernel.project_dir%/var/test.db'
+82
View File
@@ -19,3 +19,85 @@ logout:
path: /logout path: /logout
controller: App\UI\Http\Auth\OidcAuthController::logout controller: App\UI\Http\Auth\OidcAuthController::logout
methods: [GET, POST] methods: [GET, POST]
# M1: CRUD-Slice "Mahlzeit" (Web-UI, POST/Redirect/Flash)
mahlzeiten_index:
path: /mahlzeiten
controller: App\UI\Http\Essen\MahlzeitController::index
methods: [GET]
# M2: Archiv (gegessene Mahlzeiten)
mahlzeiten_archiv:
path: /mahlzeiten/archiv
controller: App\UI\Http\Essen\MahlzeitController::archiv
methods: [GET]
mahlzeiten_neu:
path: /mahlzeiten/neu
controller: App\UI\Http\Essen\MahlzeitController::neu
methods: [GET]
mahlzeiten_speichern:
path: /mahlzeiten
controller: App\UI\Http\Essen\MahlzeitController::speichern
methods: [POST]
mahlzeiten_bearbeiten:
path: /mahlzeiten/{id}/bearbeiten
controller: App\UI\Http\Essen\MahlzeitController::bearbeiten
methods: [GET]
requirements:
id: \d+
mahlzeiten_aktualisieren:
path: /mahlzeiten/{id}
controller: App\UI\Http\Essen\MahlzeitController::aktualisieren
methods: [POST]
requirements:
id: \d+
mahlzeiten_loeschen:
path: /mahlzeiten/{id}/loeschen
controller: App\UI\Http\Essen\MahlzeitController::loeschen
methods: [POST]
requirements:
id: \d+
# M3: Tagesansicht (alle Mahlzeiten nach Wochentag) + Reorder (Drag & Drop)
mahlzeiten_tage:
path: /mahlzeiten/tage
controller: App\UI\Http\Essen\MahlzeitController::tage
methods: [GET]
mahlzeiten_reorder:
path: /mahlzeiten/reorder
controller: App\UI\Http\Essen\MahlzeitController::reorder
methods: [POST]
# M3: Inspiration (ADR-006): Vorschläge aus dem Archiv + 1-Klick-Adopt
mahlzeiten_inspiration:
path: /inspiration
controller: App\UI\Http\Essen\MahlzeitController::inspiration
methods: [GET]
mahlzeiten_adopt:
path: /mahlzeiten/{id}/adopt
controller: App\UI\Http\Essen\MahlzeitController::adopt
methods: [POST]
requirements:
id: \d+
# M2: Status-Wechsel als htmx-Partial-Endpunkte (Fragment-Antwort)
mahlzeiten_checkoff:
path: /mahlzeiten/{id}/checkoff
controller: App\UI\Http\Essen\MahlzeitController::checkoff
methods: [POST]
requirements:
id: \d+
mahlzeiten_uncheck:
path: /mahlzeiten/{id}/uncheck
controller: App\UI\Http\Essen\MahlzeitController::uncheck
methods: [POST]
requirements:
id: \d+
+7
View File
@@ -8,6 +8,13 @@ services:
_defaults: _defaults:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
# Interface -> Implementierung (Dependency Inversion): Logic-Layer definiert,
# Data-Layer implementiert. Explizit gebunden, damit Autowiring nie mehrdeutig wird.
bind:
App\Logic\Common\ClockInterface: '@App\Data\Infrastructure\SystemClock'
App\Logic\Essen\MahlzeitProviderInterface: '@App\Data\Essen\Provider\MahlzeitProvider'
App\Logic\Essen\MahlzeitProcessorInterface: '@App\Data\Essen\Processor\MahlzeitProcessor'
App\Logic\Essen\Manager\MahlzeitManagerInterface: '@App\Logic\Essen\Manager\MahlzeitManager'
App\: App\:
resource: '../src/' resource: '../src/'
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260918213517 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE essens_mahlzeiten (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, notiz CLOB DEFAULT NULL, wochentage VARCHAR(32) DEFAULT NULL, sort_order INTEGER NOT NULL, status VARCHAR(32) NOT NULL, gegessen_am DATETIME DEFAULT NULL, erstellt_am DATETIME NOT NULL, aktualisiert_am DATETIME NOT NULL)');
$this->addSql('CREATE INDEX idx_status ON essens_mahlzeiten (status)');
$this->addSql('CREATE INDEX idx_sort_order ON essens_mahlzeiten (sort_order)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE essens_mahlzeiten');
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Data\Essen\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* Doctrine-Entity für `essens_mahlzeiten`.
*
* Bewusst "dumm": nur Getter/Setter, keine Fachlogik (die lebt im Rich-Model
* `App\Logic\Essen\Model\Mahlzeit`). `wochentage` wird als CSV-String
* ("mo,di,fr") persistiert – das Mapping ins `Wochentag[]`-Array macht der Mapper.
*/
#[ORM\Entity]
#[ORM\Table(name: 'essens_mahlzeiten')]
#[ORM\Index(columns: ['status'], name: 'idx_status')]
#[ORM\Index(columns: ['sort_order'], name: 'idx_sort_order')]
final class MahlzeitEntity
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $name = '';
#[ORM\Column(type: 'text', nullable: true)]
private ?string $notiz = null;
/** CSV der Wochentag-Kurzformen, z. B. "mo,di,fr". */
#[ORM\Column(type: 'string', length: 32, nullable: true)]
private ?string $wochentage = null;
#[ORM\Column(type: 'integer')]
private int $sortOrder = 0;
#[ORM\Column(type: 'string', length: 32)]
private string $status = 'geplant';
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $gegessenAm = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $erstelltAm;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $aktualisiertAm;
public function __construct()
{
$now = new \DateTimeImmutable();
$this->erstelltAm = $now;
$this->aktualisiertAm = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getNotiz(): ?string
{
return $this->notiz;
}
public function setNotiz(?string $notiz): self
{
$this->notiz = $notiz;
return $this;
}
public function getWochentage(): ?string
{
return $this->wochentage;
}
public function setWochentage(?string $wochentage): self
{
$this->wochentage = $wochentage;
return $this;
}
public function getSortOrder(): int
{
return $this->sortOrder;
}
public function setSortOrder(int $sortOrder): self
{
$this->sortOrder = $sortOrder;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getGegessenAm(): ?\DateTimeImmutable
{
return $this->gegessenAm;
}
public function setGegessenAm(?\DateTimeImmutable $gegessenAm): self
{
$this->gegessenAm = $gegessenAm;
return $this;
}
public function getErstelltAm(): \DateTimeImmutable
{
return $this->erstelltAm;
}
public function setErstelltAm(\DateTimeImmutable $erstelltAm): self
{
$this->erstelltAm = $erstelltAm;
return $this;
}
public function getAktualisiertAm(): \DateTimeImmutable
{
return $this->aktualisiertAm;
}
public function setAktualisiertAm(\DateTimeImmutable $aktualisiertAm): self
{
$this->aktualisiertAm = $aktualisiertAm;
return $this;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Data\Essen\Mapper;
use App\Data\Essen\Entity\MahlzeitEntity;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
/**
* Stateless-Mapping zwischen `MahlzeitEntity` und dem Rich-Model `Mahlzeit`.
*
* Hier leben die "unbequemen" Persistenz-Konvertierungen:
* - `wochentage`: CSV-String ("mo,di,fr") ↔ `Wochentag[]`
* - `status`: String ("geplant") ↔ `MahlzeitStatus`-Enum
* - `notiz`: nullable-String ↔ nicht-leerer String
*/
final class MahlzeitMapper
{
public function toModel(MahlzeitEntity $entity): Mahlzeit
{
return new Mahlzeit(
id: (int) $entity->getId(),
name: $entity->getName(),
notiz: (string) $entity->getNotiz(),
wochentage: $this->csvToWochentage((string) $entity->getWochentage()),
sortOrder: $entity->getSortOrder(),
status: MahlzeitStatus::from($entity->getStatus()),
erstelltAm: $entity->getErstelltAm(),
aktualisiertAm: $entity->getAktualisiertAm(),
gegessenAm: $entity->getGegessenAm(),
);
}
public function createEntity(Mahlzeit $model): MahlzeitEntity
{
$entity = new MahlzeitEntity();
$this->apply($model, $entity);
return $entity;
}
public function updateEntity(Mahlzeit $model, MahlzeitEntity $entity): void
{
$this->apply($model, $entity);
}
private function apply(Mahlzeit $model, MahlzeitEntity $entity): void
{
$entity->setName($model->name);
$entity->setNotiz($model->notiz === '' ? null : $model->notiz);
$entity->setWochentage($this->wochentageToCsv($model->wochentage()));
$entity->setSortOrder($model->sortOrder);
$entity->setStatus($model->status->value);
$entity->setGegessenAm($model->gegessenAm);
$entity->setErstelltAm($model->erstelltAm);
$entity->setAktualisiertAm($model->aktualisiertAm);
}
/** @return list<Wochentag> */
private function csvToWochentage(string $csv): array
{
if (\trim($csv) === '') {
return [];
}
$tage = [];
foreach (explode(',', $csv) as $wert) {
$tag = Wochentag::fromValue($wert);
if ($tag !== null) {
$tage[] = $tag;
}
}
return $tage;
}
/** @param list<Wochentag> $tage */
private function wochentageToCsv(array $tage): ?string
{
if ($tage === []) {
return null;
}
return implode(',', Wochentag::zuWerten($tage));
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Data\Essen\Processor;
use App\Data\Essen\Entity\MahlzeitEntity;
use App\Data\Essen\Mapper\MahlzeitMapper;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\MahlzeitProcessorInterface;
use App\Logic\Essen\Model\Mahlzeit;
use Doctrine\ORM\EntityManagerInterface;
/**
* Write-Zugriff auf den Mahlzeiten-Datenbestand (Doctrine-Implementierung).
*
* `create` persistiert ein neues Modell und liefert es mit der neuen DB-ID zurück.
* `save`/`delete` setzen die ID voraus und werfen `MahlzeitNotFoundException` (→ 404).
*/
final class MahlzeitProcessor implements MahlzeitProcessorInterface
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly MahlzeitMapper $mapper,
) {
}
public function create(Mahlzeit $mahlzeit): Mahlzeit
{
$entity = $this->mapper->createEntity($mahlzeit);
$this->em->persist($entity);
$this->em->flush();
return $this->mapper->toModel($entity);
}
public function save(Mahlzeit $mahlzeit): Mahlzeit
{
$entity = $this->findEntity($mahlzeit->id);
$this->mapper->updateEntity($mahlzeit, $entity);
$this->em->flush();
return $mahlzeit;
}
public function delete(Mahlzeit $mahlzeit): void
{
$entity = $this->findEntity($mahlzeit->id);
$this->em->remove($entity);
$this->em->flush();
}
private function findEntity(int $id): MahlzeitEntity
{
$entity = $this->em->getRepository(MahlzeitEntity::class)->find($id);
if ($entity === null) {
throw new MahlzeitNotFoundException($id);
}
return $entity;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Data\Essen\Provider;
use App\Data\Essen\Entity\MahlzeitEntity;
use App\Data\Essen\Mapper\MahlzeitMapper;
use App\Logic\Essen\MahlzeitProviderInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use Doctrine\ORM\EntityManagerInterface;
/**
* Read-Zugriff auf den Mahlzeiten-Datenbestand (Doctrine-Implementierung).
*/
final class MahlzeitProvider implements MahlzeitProviderInterface
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly MahlzeitMapper $mapper,
) {
}
public function find(int $id): ?Mahlzeit
{
$entity = $this->em->getRepository(MahlzeitEntity::class)->find($id);
return $entity instanceof MahlzeitEntity ? $this->mapper->toModel($entity) : null;
}
/** @return list<Mahlzeit> */
public function list(?MahlzeitStatus $status = null): array
{
$qb = $this->em->getRepository(MahlzeitEntity::class)->createQueryBuilder('m');
if ($status !== null) {
$qb->andWhere('m.status = :status')->setParameter('status', $status->value);
}
$qb->orderBy('m.sortOrder', 'ASC')->addOrderBy('m.id', 'ASC');
/** @var MahlzeitEntity[] $entities */
$entities = $qb->getQuery()->getResult();
$mahlzeiten = [];
foreach ($entities as $entity) {
$mahlzeiten[] = $this->mapper->toModel($entity);
}
return $mahlzeiten;
}
public function maxSortOrder(): int
{
$max = $this->em
->getRepository(MahlzeitEntity::class)
->createQueryBuilder('m')
->select('MAX(m.sortOrder)')
->getQuery()
->getSingleScalarResult();
return $max === null ? 0 : (int) $max;
}
}
@@ -22,7 +22,7 @@ final class EntityManagerTransactionManager implements TransactionManagerInterfa
public function execute(\Closure $work): mixed public function execute(\Closure $work): mixed
{ {
try { try {
return $this->em->transactional(static fn () => $work($work)); return $this->em->wrapInTransaction($work);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->em->clear(); $this->em->clear();
throw $e; throw $e;
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Data\Infrastructure;
use App\Logic\Common\ClockInterface;
/**
* Bridge: liefert "jetzt" aus der Systemuhr für die Logik-Layer.
*/
final class SystemClock implements ClockInterface
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Logic\Common;
/**
* Liefer "jetzt" für die Logik-Layer – macht Zeitstempel testbar/bar.
*/
interface ClockInterface
{
public function now(): \DateTimeImmutable;
}
@@ -11,7 +11,7 @@ interface TransactionManagerInterface
* Bei Exception wird zurückgerollt, ansonsten committiert. * Bei Exception wird zurückgerollt, ansonsten committiert.
* *
* @template T * @template T
* @param callable(\Closure|\Throwable): T $work * @param \Closure(): T $work
* @return T * @return T
* @throws \Throwable * @throws \Throwable
*/ */
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\Wochentag;
/**
* Request-DTO: eine Mahlzeit anlegen.
*
* `notiz` und `wochentage` sind optional. Der Zeitpunkt (`erstelltAm`/`aktualisiertAm`)
* wird nicht mitgeliefert, sondern vom UseCase über `ClockInterface` gestempelt.
* Die Reihenfolge (`sortOrder`) wird beim Anlegen automatisch am Ende angehängt.
*
* @param non-empty-string $name
* @param list<Wochentag> $wochentage
*/
final class MahlzeitCreateRequest
{
public function __construct(
public readonly string $name,
public readonly string $notiz = '',
public readonly array $wochentage = [],
) {}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\Mahlzeit;
/**
* Response-DTO: Anlegen abgeschlossen. Trägt die neue ID + das fertige Read-Modell.
*/
final class MahlzeitCreateResponse
{
public function __construct(
public readonly int $id,
public readonly MahlzeitResponse $mahlzeit,
) {}
public static function fromModel(Mahlzeit $m): self
{
return new self(id: $m->id, mahlzeit: MahlzeitResponse::fromModel($m));
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\MahlzeitStatus;
/**
* Request-DTO: Mahlzeiten-Liste anfragen.
*
* `status = null` → alle Einträge (geplant + gegessen), sortiert nach `sortOrder`.
*/
final class MahlzeitListRequest
{
public function __construct(
public readonly ?MahlzeitStatus $status = null,
) {}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\Mahlzeit;
/**
* Response-DTO: eine (gefilterte) Liste von Mahlzeiten, sortiert nach `sortOrder`.
*
* @param list<MahlzeitResponse> $items
*/
final class MahlzeitListResponse
{
public function __construct(
public readonly array $items,
) {}
/** @param list<Mahlzeit> $mahlzeiten */
public static function fromModels(array $mahlzeiten): self
{
$items = [];
foreach ($mahlzeiten as $m) {
$items[] = MahlzeitResponse::fromModel($m);
}
return new self(items: $items);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
/**
* Request-DTO: die Reihenfolge der Planungsliste festlegen.
*
* `reihenfolge` ist die Liste der Mahlzeiten-IDs in der gewünschten
* (neuen) Reihenfolge. Die Use-Case-Implementierung nummeriert daraus
* `sortOrder` neu (Position 1..N).
*
* @param list<int> $reihenfolge
*/
final class MahlzeitReorderRequest
{
/** @param list<int> $reihenfolge */
public function __construct(
public readonly array $reihenfolge,
) {
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
/**
* Read-Modell einer einzelnen Mahlzeit für Controller/Antworten.
*
* @param list<Wochentag> $wochentage
*/
final class MahlzeitResponse
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $notiz,
public readonly array $wochentage,
public readonly int $sortOrder,
public readonly MahlzeitStatus $status,
public readonly ?\DateTimeImmutable $gegessenAm,
public readonly \DateTimeImmutable $erstelltAm,
public readonly \DateTimeImmutable $aktualisiertAm,
) {}
public static function fromModel(Mahlzeit $m): self
{
return new self(
id: $m->id,
name: $m->name,
notiz: $m->notiz,
wochentage: $m->wochentage,
sortOrder: $m->sortOrder,
status: $m->status,
gegessenAm: $m->gegessenAm,
erstelltAm: $m->erstelltAm,
aktualisiertAm: $m->aktualisiertAm,
);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\Wochentag;
/**
* Request-DTO: eine bestehende Mahlzeit bearbeiten (Teil-Update).
*
* `null` = Feld bleibt unverändert. `wochentage: []` = "keine Wochentage" setzen.
*
* @param list<Wochentag>|null $wochentage
*/
final class MahlzeitUpdateRequest
{
public function __construct(
public readonly ?string $name = null,
public readonly ?string $notiz = null,
public readonly ?array $wochentage = null,
) {}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\Mahlzeit;
/**
* Response-DTO: Bearbeitung abgeschlossen. Trägt die ID + das aktualisierte Read-Modell.
*/
final class MahlzeitUpdateResponse
{
public function __construct(
public readonly int $id,
public readonly MahlzeitResponse $mahlzeit,
) {}
public static function fromModel(Mahlzeit $m): self
{
return new self(id: $m->id, mahlzeit: MahlzeitResponse::fromModel($m));
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Dto;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\Wochentag;
/**
* Read-Modell eines Inspiration-Vorschlags (ein Gericht aus dem Archiv).
*
* `score` ist der gewichtete Inspiration-Score (ADR-006), `auftraete` die
* Gesamtzahl aller Einträge (geplant + gegessen) mit diesem Namen.
*
* @param list<Wochentag> $wochentage
*/
final class MahlzeitVorschlagResponse
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $notiz,
public readonly array $wochentage,
public readonly float $score,
public readonly ?\DateTimeImmutable $gegessenAm,
public readonly int $auftraete,
) {
}
public static function fromModel(Mahlzeit $m, float $score, int $auftraete): self
{
return new self(
id: $m->id,
name: $m->name,
notiz: $m->notiz,
wochentage: $m->wochentage,
score: $score,
gegessenAm: $m->gegessenAm,
auftraete: $auftraete,
);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Event;
/**
* Domain-Event: ein Archiv-Gericht wurde per 1-Klick als neuer `geplant`-Eintrag
* übernommen (Adopt, nach Commit). `quellId` verweist auf die übernommenen Quelle.
*/
final readonly class MahlzeitAdoptedEvent
{
public function __construct(
public int $id,
public string $name,
public int $quellId,
) {
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Event;
/**
* Domain-Event: eine Mahlzeit wurde angelegt (nach erfolgreichem Commit).
* Trägt nur, was ein Subscriber braucht (State-Carry, kein ORM-Wissen).
*/
final readonly class MahlzeitCreatedEvent
{
public function __construct(
public int $id,
public string $name,
) {
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Event;
/**
* Domain-Event: eine Mahlzeit wurde gelöscht (nach erfolgreichem Commit).
*/
final readonly class MahlzeitDeletedEvent
{
public function __construct(
public int $id,
public string $name,
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Event;
/**
* Domain-Event: die Reihenfolge der Planungsliste wurde geändert (nach Commit).
* Trägt die neue ID-Reihenfolge (State-Carry, kein ORM-Wissen).
*
* @param list<int> $reihenfolge
*/
final readonly class MahlzeitReorderedEvent
{
/** @param list<int> $reihenfolge */
public function __construct(
public array $reihenfolge,
) {
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Event;
use App\Logic\Essen\Model\MahlzeitStatus;
/**
* Domain-Event: der Status einer Mahlzeit wurde geändert (nach erfolgreichem Commit).
*
* Trägt den neuen Status + den (ggf. leeren) gegessenAm-Zeitpunkt, damit ein
* Subscriber (z. B. Archiv, Statistik) ohne erneuten DB-Zugriff reagieren kann.
*/
final readonly class MahlzeitStatusChangedEvent
{
public function __construct(
public int $id,
public string $name,
public MahlzeitStatus $status,
public ?\DateTimeImmutable $gegessenAm,
) {
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Event;
/**
* Domain-Event: eine Mahlzeit wurde bearbeitet (nach erfolgreichem Commit).
*/
final readonly class MahlzeitUpdatedEvent
{
public function __construct(
public int $id,
public string $name,
) {
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Exception;
use App\Logic\Common\Exception\DomainException;
/**
* Mahlzeit existiert nicht → HTTP 404 (über `ExceptionSubscriber`).
*
* `ResourceNotFoundException` ist final, daher erbt hier direkt von der
* abstrakten `DomainException` (gleicher Status/Vertrag).
*/
final class MahlzeitNotFoundException extends DomainException
{
public function __construct(int $id)
{
parent::__construct(\sprintf('Mahlzeit (id=%d) nicht gefunden.', $id));
}
public function getHttpStatusCode(): int
{
return 404;
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen;
use App\Logic\Essen\Model\Mahlzeit;
/**
* Schreiben von Mahlzeiten (Persistenz).
* Implementiert von `App\Data\Essen\Processor\MahlzeitProcessor` (Doctrine).
*/
interface MahlzeitProcessorInterface
{
/** Persistiert ein neues Modell und gibt es mit der neuen ID zurück. */
public function create(Mahlzeit $mahlzeit): Mahlzeit;
/** Schreibt die Änderungen eines bestehenden Modells zurück. */
public function save(Mahlzeit $mahlzeit): Mahlzeit;
/** Entfernt das Modell dauerhaft. */
public function delete(Mahlzeit $mahlzeit): void;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
/**
* Lesen des Mahlzeiten-Datenbestands (Zugriff auf die persistierten Daten).
* Implementiert von `App\Data\Essen\Provider\MahlzeitProvider` (Doctrine).
*/
interface MahlzeitProviderInterface
{
public function find(int $id): ?Mahlzeit;
/**
* @return list<Mahlzeit> sortiert nach sortOrder aufsteigend
*/
public function list(?MahlzeitStatus $status = null): array;
/** Größte vorhandene sortOrder-Position (0, falls leer). */
public function maxSortOrder(): int;
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Manager;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\MahlzeitProviderInterface;
use App\Logic\Essen\MahlzeitProcessorInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
/**
* Standard-Implementierung: kombiniert Provider (Read) + Processor (Write).
*
* @see MahlzeitManagerInterface
*/
final class MahlzeitManager implements MahlzeitManagerInterface
{
public function __construct(
private readonly MahlzeitProviderInterface $provider,
private readonly MahlzeitProcessorInterface $processor,
) {
}
public function create(Mahlzeit $mahlzeit): Mahlzeit
{
$mahlzeit = $mahlzeit->mitSortOrder($this->provider->maxSortOrder() + 1);
return $this->processor->create($mahlzeit);
}
public function update(Mahlzeit $mahlzeit): Mahlzeit
{
return $this->processor->save($mahlzeit);
}
public function delete(int $id): void
{
$this->find($id);
$mahlzeit = $this->provider->find($id);
if ($mahlzeit !== null) {
$this->processor->delete($mahlzeit);
}
}
public function find(int $id): Mahlzeit
{
return $this->provider->find($id) ?? throw new MahlzeitNotFoundException($id);
}
/** @return list<Mahlzeit> */
public function list(?MahlzeitStatus $status = null): array
{
return $this->provider->list($status);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Manager;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
/**
* Read/Write-Koordination pro Business-Model `Mahlzeit`.
*
* `find` wirft `MahlzeitNotFoundException` (→ 404), wenn der Eintrag nicht existiert.
* Cache-Invalidierung ist bewusst ausgelassen (Phase 2, s. architektur.md §6).
*/
interface MahlzeitManagerInterface
{
/** Hängt das Modell am Ende der Planungsliste an und persistiert es. */
public function create(Mahlzeit $mahlzeit): Mahlzeit;
public function update(Mahlzeit $mahlzeit): Mahlzeit;
public function delete(int $id): void;
public function find(int $id): Mahlzeit;
/** @return list<Mahlzeit> sortiert nach sortOrder aufsteigend */
public function list(?MahlzeitStatus $status = null): array;
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Mapping;
use App\Logic\Common\Exception\BusinessRuleViolationException;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Model\Mahlzeit;
/**
* Reine Funktionen: Request-DTO → Modell. Keine Framework-/DB-Abhängigkeit.
*
* Fachregeln (Pflichtfelder) werden hier als Business-Validierung geprüft.
* Zeitstempel (`erstelltAm`/`aktualisiertAm`) liefert der UseCase über `ClockInterface`.
*/
final class MahlzeitFactory
{
/**
* Erstellt ein neues, geplantes Modell aus einem Anlegen-DTO.
*
* `sortOrder` wird bewusst auf 0 gesetzt – der Manager hängt den Eintrag
* anschließend am Ende an (max+1).
*/
public function createFromCreateRequest(MahlzeitCreateRequest $req, \DateTimeImmutable $jetzt): Mahlzeit
{
$this->assertRequired($req->name, 'name');
return Mahlzeit::anlegen(
name: $req->name,
notiz: $req->notiz,
wochentage: $req->wochentage,
sortOrder: 0,
jetzt: $jetzt,
);
}
/**
* Wendet ein Teil-Update auf ein bestehendes Modell an. `null`-Felder bleiben unverändert.
*/
public function updateFromUpdateRequest(Mahlzeit $mahlzeit, MahlzeitUpdateRequest $req, \DateTimeImmutable $jetzt): Mahlzeit
{
if ($req->name !== null) {
$this->assertRequired($req->name, 'name');
}
$name = $req->name ?? $mahlzeit->name;
$notiz = $req->notiz ?? $mahlzeit->notiz;
$wochentage = $req->wochentage ?? $mahlzeit->wochentage;
return $mahlzeit->aktualisieren(
name: $name,
notiz: $notiz,
wochentage: $wochentage,
sortOrder: $mahlzeit->sortOrder,
jetzt: $jetzt,
);
}
private function assertRequired(?string $value, string $feld): void
{
if ($value === null || \trim($value) === '') {
throw new BusinessRuleViolationException(\sprintf('%s ist ein Pflichtfeld.', $feld));
}
}
}
+232
View File
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Model;
use App\Logic\Common\Exception\BusinessRuleViolationException;
/**
* Aggregat "Mahlzeit" – reines Rich Domain Model (POPO, kein Framework).
*
* Unveränderlich: Zustandsübergaben geben ein neues Modell zurück und kapseln
* dabei die fachlichen Regeln (Status + Zeitstempel). `sort_order` wird nicht
* über `mitSortOrder` verändert, sondern über eine dedizierte Transition.
*/
final class Mahlzeit
{
/**
* @param int $id
* @param non-empty-string $name
* @param non-empty-string $notiz
* @param list<Wochentag> $wochentage
* @param int $sortOrder Position in der Planungsliste (>= 0)
* @param MahlzeitStatus $status
* @param \DateTimeImmutable $erstelltAm
* @param \DateTimeImmutable $aktualisiertAm
* @param \DateTimeImmutable|null $gegessenAm
*/
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $notiz,
public readonly array $wochentage,
public readonly int $sortOrder,
public readonly MahlzeitStatus $status,
public readonly \DateTimeImmutable $erstelltAm,
public readonly \DateTimeImmutable $aktualisiertAm,
public readonly ?\DateTimeImmutable $gegessenAm,
) {
foreach ($wochentage as $wochentag) {
if (!$wochentag instanceof Wochentag) {
throw new \TypeError('wochentage muss eine Liste von Wochentag sein.');
}
}
if ($status === MahlzeitStatus::Gegessen && $gegessenAm === null) {
throw new BusinessRuleViolationException('Eine gegessene Mahlzeit benötigt einen gegessenAm-Zeitpunkt.');
}
if ($status === MahlzeitStatus::Geplant && $gegessenAm !== null) {
throw new BusinessRuleViolationException('Eine geplante Mahlzeit darf keinen gegessenAm-Zeitpunkt haben.');
}
}
/**
* Anlegen einer neuen, geplanten Mahlzeit (noch keine ID – die bekommt die Persistenz).
*
* @param non-empty-string $name
* @param non-empty-string $notiz
* @param list<Wochentag> $wochentage
*/
public static function anlegen(
string $name,
string $notiz,
array $wochentage,
int $sortOrder,
\DateTimeImmutable $jetzt,
): self {
return new self(
id: 0,
name: \trim($name),
notiz: \trim($notiz),
wochentage: $wochentage,
sortOrder: $sortOrder,
status: MahlzeitStatus::Geplant,
erstelltAm: $jetzt,
aktualisiertAm: $jetzt,
gegessenAm: null,
);
}
/**
* Bearbeiten der Inhalt-Felder (Name, Notiz, Wochentage, Reihenfolge).
* Status/Zeitstempel bleiben dabei erhalten.
*
* @param non-empty-string $name
* @param non-empty-string $notiz
* @param list<Wochentag> $wochentage
*/
public function aktualisieren(
string $name,
string $notiz,
array $wochentage,
int $sortOrder,
\DateTimeImmutable $jetzt,
): self {
if (\trim($name) === '') {
throw new BusinessRuleViolationException('name ist ein Pflichtfeld.');
}
foreach ($wochentage as $wochentag) {
if (!$wochentag instanceof Wochentag) {
throw new BusinessRuleViolationException('wochentage enthält einen ungültigen Wochentag.');
}
}
return $this->neuesModell(
name: \trim($name),
notiz: \trim($notiz),
wochentage: $wochentage,
sortOrder: $sortOrder,
status: $this->status,
gegessenAm: $this->gegessenAm,
aktualisiertAm: $jetzt,
);
}
public function mitSortOrder(int $sortOrder): self
{
if ($sortOrder < 0) {
throw new BusinessRuleViolationException('sortOrder muss >= 0 sein.');
}
return $this->neuesModell(
name: $this->name,
notiz: $this->notiz,
wochentage: $this->wochentage,
sortOrder: $sortOrder,
status: $this->status,
gegessenAm: $this->gegessenAm,
aktualisiertAm: $this->aktualisiertAm,
);
}
public function markAsGegessen(\DateTimeImmutable $jetzt): self
{
if ($this->status === MahlzeitStatus::Gegessen) {
return $this;
}
return $this->neuesModell(
name: $this->name,
notiz: $this->notiz,
wochentage: $this->wochentage,
sortOrder: $this->sortOrder,
status: MahlzeitStatus::Gegessen,
gegessenAm: $jetzt,
aktualisiertAm: $jetzt,
);
}
public function markAsGeplant(\DateTimeImmutable $jetzt): self
{
if ($this->status === MahlzeitStatus::Geplant) {
return $this;
}
return $this->neuesModell(
name: $this->name,
notiz: $this->notiz,
wochentage: $this->wochentage,
sortOrder: $this->sortOrder,
status: MahlzeitStatus::Geplant,
gegessenAm: null,
aktualisiertAm: $jetzt,
);
}
/**
* Setzt die Persistenz-ID (id) – wird vom Processor nach dem Insert verwendet.
*/
public function mitId(int $id): self
{
return new self(
id: $id,
name: $this->name,
notiz: $this->notiz,
wochentage: $this->wochentage,
sortOrder: $this->sortOrder,
status: $this->status,
erstelltAm: $this->erstelltAm,
aktualisiertAm: $this->aktualisiertAm,
gegessenAm: $this->gegessenAm,
);
}
public function istGegessen(): bool
{
return $this->status === MahlzeitStatus::Gegessen;
}
public function istGeplant(): bool
{
return $this->status === MahlzeitStatus::Geplant;
}
/** @return list<Wochentag> */
public function wochentage(): array
{
return $this->wochentage;
}
/**
* @param non-empty-string $name
* @param non-empty-string $notiz
* @param list<Wochentag> $wochentage
* @param int $sortOrder
* @param MahlzeitStatus $status
* @param \DateTimeImmutable|null $gegessenAm
* @param \DateTimeImmutable $aktualisiertAm
*/
private function neuesModell(
string $name,
string $notiz,
array $wochentage,
int $sortOrder,
MahlzeitStatus $status,
?\DateTimeImmutable $gegessenAm,
\DateTimeImmutable $aktualisiertAm,
): self {
return new self(
id: $this->id,
name: $name,
notiz: $notiz,
wochentage: $wochentage,
sortOrder: $sortOrder,
status: $status,
erstelltAm: $this->erstelltAm,
aktualisiertAm: $aktualisiertAm,
gegessenAm: $gegessenAm,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Model;
enum MahlzeitStatus: string
{
case Geplant = 'geplant';
case Gegessen = 'gegessen';
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Model;
/**
* Wochentag als Domänenwert.
*
* Kurzform-Bezeichner (ISO-nah) werden als stabilen Storage-Wert genutzt
* (z. B. in der CSV-Spalte der Datenbank).
*/
enum Wochentag: string
{
case Mo = 'mo';
case Di = 'di';
case Mi = 'mi';
case Do = 'do';
case Fr = 'fr';
case Sa = 'sa';
case So = 'so';
/** Sortierte Werteliste (Mo…So) für UI- und Storage-Zwecke. */
public static function alle(): array
{
return self::cases();
}
public static function fromValue(string $value): ?self
{
return self::tryFrom($value);
}
/** @param list<string> $values */
public static function ausWerten(array $values): array
{
$tage = [];
foreach ($values as $value) {
$tag = self::tryFrom((string) $value);
if ($tag !== null) {
$tage[] = $tag;
}
}
return $tage;
}
/** @return list<string> */
public static function zuWerten(array $tage): array
{
return \array_map(static fn (self $tag): string => $tag->value, $tage);
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Query;
use App\Logic\Common\ClockInterface;
use App\Logic\Essen\Dto\MahlzeitVorschlagResponse;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\MahlzeitStatus;
/**
* Read-Query: Inspiration (ADR-006).
*
* Schlägt Gerichte **aus dem Archiv** (mindestens ein `gegessen`-Eintrag) vor,
* bewertet nach einem gewichteten Mix:
* score = w_slow * staleness (lange nicht gekocht, primär)
* + w_rare * rarity (seltene Gerichte, sekundär)
* + w_hot * popularity (beliebte Gerichte, tertiär)
*
* - staleness: `now - letzte_geschmack`, normalisiert an die längste Pause.
* - rarity: `1 / (auftraete + 1)`, auftraete = Anzahl ALLER Einträge (Name).
* - popularity: `auftraete / max(auftraete)`.
*
* `ausschluss` (bereits vorgeschlagene Repräsentanten-IDs) wird übersprungen,
* damit "Weitere 5" neue Gerichte liefert. Sortierung absteigend nach Score.
*/
final readonly class MahlzeitInspirationQuery
{
public function __construct(
private MahlzeitManagerInterface $mahlzeiten,
private ClockInterface $clock,
private float $gewichtSlow = 0.5,
private float $gewichtRare = 0.3,
private float $gewichtHot = 0.2,
) {
}
/**
* @param list<int> $ausschluss bereits vorgeschlagene Repräsentanten-IDs
* @return list<MahlzeitVorschlagResponse>
*/
public function execute(int $limit = 5, array $ausschluss = []): array
{
$alle = $this->mahlzeiten->list(null);
if ($alle === []) {
return [];
}
$jetzt = $this->clock->now();
/** @var array<string, array{letzte: ?\DateTimeImmutable, anzahl: int, repraesentant: mixed}> $perName */
$perName = [];
foreach ($alle as $m) {
$e = &$perName[$m->name];
if (!isset($e)) {
$e = ['letzte' => null, 'anzahl' => 0, 'repraesentant' => $m];
}
$e['anzahl']++;
if ($m->status === MahlzeitStatus::Gegessen && $m->gegessenAm !== null) {
if ($e['letzte'] === null || $m->gegessenAm > $e['letzte']) {
$e['letzte'] = $m->gegessenAm;
$e['repraesentant'] = $m;
}
}
}
unset($e);
// Nur Gerichte, die im Archiv stehen (>= 1 gegessener Eintrag).
$kandidaten = [];
foreach ($perName as $name => $e) {
if ($e['letzte'] !== null) {
$kandidaten[] = [
'name' => $name,
'letzte' => $e['letzte'],
'anzahl' => $e['anzahl'],
'repraesentant' => $e['repraesentant'],
];
}
}
if ($kandidaten === []) {
return [];
}
// Normalisierungs-Maxima.
$maxStale = 0;
$maxAnzahl = 0;
foreach ($kandidaten as $k) {
$stale = $jetzt->getTimestamp() - $k['letzte']->getTimestamp();
if ($stale > $maxStale) {
$maxStale = $stale;
}
if ($k['anzahl'] > $maxAnzahl) {
$maxAnzahl = $k['anzahl'];
}
}
$scored = [];
foreach ($kandidaten as $k) {
$stale = $jetzt->getTimestamp() - $k['letzte']->getTimestamp();
$staleness = $maxStale > 0 ? $stale / $maxStale : 0.0;
$rarity = 1.0 / ($k['anzahl'] + 1);
$popularity = $maxAnzahl > 0 ? $k['anzahl'] / $maxAnzahl : 0.0;
$score = $this->gewichtSlow * $staleness + $this->gewichtRare * $rarity + $this->gewichtHot * $popularity;
$scored[] = ['k' => $k, 'score' => $score];
}
// Absteigend nach Score; bei Gleichstand alphabetisch (deterministisch).
usort($scored, static fn (array $a, array $b): int => $b['score'] <=> $a['score'] ?: strcmp($a['k']['name'], $b['k']['name']));
$ausschlussSet = array_flip(array_map('intval', $ausschluss));
$ergebnis = [];
foreach ($scored as $s) {
/** @var \App\Logic\Essen\Model\Mahlzeit $repraesentant */
$repraesentant = $s['k']['repraesentant'];
if (isset($ausschlussSet[$repraesentant->id])) {
continue;
}
$ergebnis[] = MahlzeitVorschlagResponse::fromModel($repraesentant, $s['score'], (int) $s['k']['anzahl']);
if (\count($ergebnis) >= $limit) {
break;
}
}
return $ergebnis;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\Query;
use App\Logic\Essen\Dto\MahlzeitListRequest;
use App\Logic\Essen\Dto\MahlzeitListResponse;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
/**
* Read-Query: Mahlzeiten-Liste (geplant, gegessen oder alle).
* Sortierung erfolgt im Provider nach `sortOrder` aufsteigend.
*/
final readonly class MahlzeitListQuery
{
public function __construct(
private MahlzeitManagerInterface $mahlzeiten,
) {
}
public function execute(MahlzeitListRequest $req): MahlzeitListResponse
{
$mahlzeiten = $this->mahlzeiten->list($req->status);
return MahlzeitListResponse::fromModels($mahlzeiten);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Event\MahlzeitAdoptedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Mapping\MahlzeitFactory;
/**
* Use-Case: ein Gericht (z. B. aus dem Archiv) per 1-Klick als NEUEN
* `geplant`-Eintrag übernehmen (Kopie – `gegessenAm` bleibt leer, neue ID,
* am Ende der Planungsliste angehängt).
*
* Wirft `MahlzeitNotFoundException` (→ 404), wenn die Quelle unbekannt ist.
*/
final readonly class MahlzeitAdoptUseCase
{
public function __construct(
private TransactionManagerInterface $tx,
private MahlzeitManagerInterface $mahlzeiten,
private MahlzeitFactory $factory,
private EventDispatcherInterface $events,
private ClockInterface $clock,
) {
}
public function execute(int $quellId): MahlzeitResponse
{
$quelle = $this->mahlzeiten->find($quellId);
$req = new MahlzeitCreateRequest(
name: $quelle->name,
notiz: $quelle->notiz,
wochentage: $quelle->wochentage,
);
$mahlzeit = $this->factory->createFromCreateRequest($req, $this->clock->now());
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->create($mahlzeit));
$this->events->dispatch(new MahlzeitAdoptedEvent(id: $gespeichert->id, name: $gespeichert->name, quellId: $quellId));
return MahlzeitResponse::fromModel($gespeichert);
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
/**
* Use-Case: eine Mahlzeit abhaken (geplant → gegessen).
*
* Idempotent: Eine bereits gegessene Mahlzeit bleibt unverändert (derselbe
* gegessenAm-Zeitpunkt bleibt erhalten). Wirft `MahlzeitNotFoundException`
* (→ 404), wenn die ID unbekannt ist.
*/
final readonly class MahlzeitCheckOffUseCase
{
public function __construct(
private TransactionManagerInterface $tx,
private MahlzeitManagerInterface $mahlzeiten,
private EventDispatcherInterface $events,
private ClockInterface $clock,
) {
}
public function execute(int $id): MahlzeitResponse
{
$jetzt = $this->clock->now();
$bestehend = $this->mahlzeiten->find($id);
$markiert = $bestehend->markAsGegessen($jetzt);
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->update($markiert));
$this->events->dispatch(new MahlzeitStatusChangedEvent(
id: $id,
name: $gespeichert->name,
status: $gespeichert->status,
gegessenAm: $gespeichert->gegessenAm,
));
return MahlzeitResponse::fromModel($gespeichert);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitCreateResponse;
use App\Logic\Essen\Event\MahlzeitCreatedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Mapping\MahlzeitFactory;
/**
* Use-Case: eine Mahlzeit anlegen.
*
* Ablauf: DTO → Modell (Factory) → Persistenz (Manager, in Transaktion)
* → Event (nach Commit) → Response.
*/
final readonly class MahlzeitCreateUseCase
{
public function __construct(
private TransactionManagerInterface $tx,
private MahlzeitManagerInterface $mahlzeiten,
private MahlzeitFactory $factory,
private EventDispatcherInterface $events,
private ClockInterface $clock,
) {
}
public function execute(MahlzeitCreateRequest $req): MahlzeitCreateResponse
{
$jetzt = $this->clock->now();
$mahlzeit = $this->factory->createFromCreateRequest($req, $jetzt);
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->create($mahlzeit));
$this->events->dispatch(new MahlzeitCreatedEvent(id: $gespeichert->id, name: $gespeichert->name));
return MahlzeitCreateResponse::fromModel($gespeichert);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\UseCase;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Event\MahlzeitDeletedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
/**
* Use-Case: eine Mahlzeit hart löschen.
*
* Wirft `MahlzeitNotFoundException` (→ 404), wenn die ID unbekannt ist.
*/
final readonly class MahlzeitDeleteUseCase
{
public function __construct(
private TransactionManagerInterface $tx,
private MahlzeitManagerInterface $mahlzeiten,
private EventDispatcherInterface $events,
) {
}
public function execute(int $id): void
{
$bestehend = $this->mahlzeiten->find($id);
$this->tx->execute(fn () => $this->mahlzeiten->delete($id));
$this->events->dispatch(new MahlzeitDeletedEvent(id: $id, name: $bestehend->name));
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\UseCase;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
use App\Logic\Essen\Event\MahlzeitReorderedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
/**
* Use-Case: die Reihenfolge der Planungsliste ändern (Drag & Drop).
*
* Die übergebene ID-Liste wird in `sortOrder` neu nummeriert (Position 1..N).
* Läuft in einer Transaktion; unbekannte IDs werfen `MahlzeitNotFoundException`
* (→ 404). Eine leere Liste ist ein No-Op.
*/
final readonly class MahlzeitReorderUseCase
{
public function __construct(
private TransactionManagerInterface $tx,
private MahlzeitManagerInterface $mahlzeiten,
private EventDispatcherInterface $events,
) {
}
public function execute(MahlzeitReorderRequest $req): void
{
$reihenfolge = array_values($req->reihenfolge);
if ($reihenfolge === []) {
return;
}
$this->tx->execute(function () use ($reihenfolge): void {
foreach ($reihenfolge as $position => $id) {
$sortOrder = $position + 1;
$mahlzeit = $this->mahlzeiten->find($id);
// Unveränderte Positionen überspringen (keine unnötigen Writes).
if ($mahlzeit->sortOrder !== $sortOrder) {
$this->mahlzeiten->update($mahlzeit->mitSortOrder($sortOrder));
}
}
});
$this->events->dispatch(new MahlzeitReorderedEvent($reihenfolge));
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
/**
* Use-Case: eine Mahlzeit wieder aufheben (gegessen → geplant).
*
* Setzt `gegessenAm` zurück. Idempotent: Eine bereits geplante Mahlzeit bleibt
* unverändert. Wirft `MahlzeitNotFoundException` (→ 404), wenn die ID unbekannt ist.
*/
final readonly class MahlzeitUncheckUseCase
{
public function __construct(
private TransactionManagerInterface $tx,
private MahlzeitManagerInterface $mahlzeiten,
private EventDispatcherInterface $events,
private ClockInterface $clock,
) {
}
public function execute(int $id): MahlzeitResponse
{
$jetzt = $this->clock->now();
$bestehend = $this->mahlzeiten->find($id);
$markiert = $bestehend->markAsGeplant($jetzt);
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->update($markiert));
$this->events->dispatch(new MahlzeitStatusChangedEvent(
id: $id,
name: $gespeichert->name,
status: $gespeichert->status,
gegessenAm: $gespeichert->gegessenAm,
));
return MahlzeitResponse::fromModel($gespeichert);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Dto\MahlzeitUpdateResponse;
use App\Logic\Essen\Event\MahlzeitUpdatedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Mapping\MahlzeitFactory;
/**
* Use-Case: eine bestehende Mahlzeit bearbeiten (Teil-Update).
*
* Wirft `MahlzeitNotFoundException` (→ 404), wenn die ID unbekannt ist.
*/
final readonly class MahlzeitUpdateUseCase
{
public function __construct(
private TransactionManagerInterface $tx,
private MahlzeitManagerInterface $mahlzeiten,
private MahlzeitFactory $factory,
private EventDispatcherInterface $events,
private ClockInterface $clock,
) {
}
public function execute(int $id, MahlzeitUpdateRequest $req): MahlzeitUpdateResponse
{
$jetzt = $this->clock->now();
$bestehend = $this->mahlzeiten->find($id);
$aktualisiert = $this->factory->updateFromUpdateRequest($bestehend, $req, $jetzt);
$gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->update($aktualisiert));
$this->events->dispatch(new MahlzeitUpdatedEvent(id: $id, name: $gespeichert->name));
return MahlzeitUpdateResponse::fromModel($gespeichert);
}
}
+363
View File
@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace App\UI\Http\Essen;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitListRequest;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Dto\MahlzeitVorschlagResponse;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\Query\MahlzeitInspirationQuery;
use App\Logic\Essen\Query\MahlzeitListQuery;
use App\Logic\Essen\UseCase\MahlzeitAdoptUseCase;
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
use App\Logic\Essen\UseCase\MahlzeitReorderUseCase;
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2)
* + Reorder & Inspiration (M3) – Web-UI.
*
* - Reads über `MahlzeitListQuery` / `MahlzeitInspirationQuery`
* / `MahlzeitManagerInterface::find`.
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck/Reorder/Adopt).
* - M1: klassisches POST/Redirect/Flash (Anlegen/Bearbeiten/Löschen).
* - M2: Check-Off als htmx-Toggle – die Endpunkte liefern nur das geänderte
* Mahlzeiten-Partial zurück (Fragment, kein Voll-HTML).
* - M3: Index = flache, per SortableJS sortierbare Planungsliste
* (`POST /mahlzeiten/reorder` → 204); Tagesansicht unter `/mahlzeiten/tage`;
* Inspiration unter `/inspiration` mit 1-Klick-Adopt.
* - Syntaktische Pflichtfeld-Prüfung (Name) passiert hier im UI-Layer
* (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben
* im UseCase/Model und laufen zentral über den ExceptionSubscriber.
*/
final class MahlzeitController extends AbstractController
{
public function __construct(
private readonly MahlzeitCreateUseCase $create,
private readonly MahlzeitUpdateUseCase $update,
private readonly MahlzeitDeleteUseCase $delete,
private readonly MahlzeitCheckOffUseCase $checkOff,
private readonly MahlzeitUncheckUseCase $uncheck,
private readonly MahlzeitReorderUseCase $reorder,
private readonly MahlzeitAdoptUseCase $adopt,
private readonly MahlzeitListQuery $list,
private readonly MahlzeitInspirationQuery $inspiration,
private readonly MahlzeitManagerInterface $mahlzeiten,
) {}
/** GET /mahlzeiten — Planungsliste: geplante Mahlzeiten in sortOrder-Reihenfolge (sortable). */
public function index(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Geplant))->items;
return $this->render('mahlzeiten/index.html.twig', [
'mahlzeiten' => $mahlzeiten,
'total' => \count($mahlzeiten),
]);
}
/** GET /mahlzeiten/tage — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */
public function tage(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest())->items;
return $this->render('mahlzeiten/tage.html.twig', [
'tage' => $this->gruppierenNachTag($mahlzeiten),
'total' => \count($mahlzeiten),
]);
}
/**
* POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop)
* persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld
* `ids` (Array oder kommagetrennte Liste). Antwort: 204 No Content.
* Wirft 404 bei unbekannten IDs.
*/
public function reorder(Request $request): Response
{
$this->reorder->execute(new MahlzeitReorderRequest($this->bindeReihenfolge($request)));
return new Response(null, Response::HTTP_NO_CONTENT);
}
/**
* GET /inspiration — Vorschläge aus dem Archiv (ADR-006).
* `?ausschluss=1,2,3` (kommagetrennte Repräsentanten-IDs) liefert "Weitere 5".
*/
public function inspiration(Request $request): Response
{
$ausschluss = $this->bindeAusschluss($request->query->all()['ausschluss'] ?? null);
// Proben mit 6: so wissen wir, ob hinter den 5 gezeigten noch mehr liegen.
$vorschlaege = $this->inspiration->execute(6, $ausschluss);
$mehrVerfuegbar = \count($vorschlaege) > 5;
$vorschlaege = array_slice($vorschlaege, 0, 5);
return $this->render('mahlzeiten/inspiration.html.twig', [
'vorschlaege' => $vorschlaege,
'ausschluss' => $ausschluss,
'gezeigte' => array_map(static fn (MahlzeitVorschlagResponse $v): int => $v->id, $vorschlaege),
'mehrVerfuegbar' => $mehrVerfuegbar,
'total' => \count($vorschlaege),
]);
}
/**
* POST /mahlzeiten/{id}/adopt — Gericht als NEUE geplante Mahlzeit übernehmen
* (Kopie, s. `MahlzeitAdoptUseCase`). Redirect zurück zur Inspiration,
* dabei die bisherige Ausschluss-Liste (für "Weitere 5") mitnehmen.
*/
public function adopt(Request $request, int $id): Response
{
$ergebnis = $this->adopt->execute($id);
$this->addFlash('notice', sprintf('Gericht „%s“ in die Planungsliste übernommen.', $ergebnis->name));
$params = [];
$ausschluss = $this->bindeAusschluss($request->request->all()['ausschluss'] ?? null);
if ($ausschluss !== []) {
$params['ausschluss'] = implode(',', $ausschluss);
}
return $this->redirectToRoute('mahlzeiten_inspiration', $params);
}
/** GET /mahlzeiten/archiv — Archiv: gegessene Mahlzeiten. */
public function archiv(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Gegessen))->items;
return $this->render('mahlzeiten/archiv.html.twig', [
'mahlzeiten' => $mahlzeiten,
]);
}
/** GET /mahlzeiten/neu — Anlegen-Formular. */
public function neu(): Response
{
return $this->render('mahlzeiten/form.html.twig', $this->formKontext(null, null, [
'name' => '',
'notiz' => '',
'wochentage' => [],
]));
}
/** POST /mahlzeiten — Mahlzeit anlegen. */
public function speichern(Request $request): Response
{
[$name, $notiz, $wochentage] = $this->binde($request);
if ($name === '') {
return $this->render('mahlzeiten/form.html.twig', $this->formKontext(
null,
'Name ist ein Pflichtfeld.',
['name' => '', 'notiz' => $notiz, 'wochentage' => $wochentage],
));
}
$this->create->execute(new MahlzeitCreateRequest(name: $name, notiz: $notiz, wochentage: $wochentage));
$this->addFlash('notice', sprintf('Mahlzeit „%s“ angelegt.', $name));
return $this->redirectToRoute('mahlzeiten_index');
}
/** GET /mahlzeiten/{id}/bearbeiten — Bearbeiten-Formular. */
public function bearbeiten(int $id): Response
{
$mahlzeit = $this->mahlzeiten->find($id);
return $this->render('mahlzeiten/form.html.twig', $this->formKontext($mahlzeit, null, [
'name' => $mahlzeit->name,
'notiz' => $mahlzeit->notiz,
'wochentage' => $mahlzeit->wochentage,
]));
}
/** POST /mahlzeiten/{id} — Mahlzeit bearbeiten. */
public function aktualisieren(Request $request, int $id): Response
{
$mahlzeit = $this->mahlzeiten->find($id);
[$name, $notiz, $wochentage] = $this->binde($request);
if ($name === '') {
return $this->render('mahlzeiten/form.html.twig', $this->formKontext(
$mahlzeit,
'Name ist ein Pflichtfeld.',
['name' => '', 'notiz' => $notiz, 'wochentage' => $wochentage],
));
}
$this->update->execute($id, new MahlzeitUpdateRequest(name: $name, notiz: $notiz, wochentage: $wochentage));
$this->addFlash('notice', sprintf('Mahlzeit „%s“ aktualisiert.', $name));
return $this->redirectToRoute('mahlzeiten_index');
}
/** POST /mahlzeiten/{id}/loeschen — Mahlzeit hart löschen. */
public function loeschen(Request $request, int $id): Response
{
$mahlzeit = $this->mahlzeiten->find($id);
$this->delete->execute($id);
$this->addFlash('notice', sprintf('Mahlzeit „%s“ gelöscht.', $mahlzeit->name));
return $this->redirectToRoute('mahlzeiten_index');
}
/**
* POST /mahlzeiten/{id}/checkoff — htmx-Partial: abhaken (geplant → gegessen).
*
* Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment), das
* htmx an der Stelle des Klicks austauscht. Wirft 404 bei unbekannter ID.
*/
public function checkoff(Request $request, int $id): Response
{
$mahlzeit = $this->checkOff->execute($id);
return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]);
}
/**
* POST /mahlzeiten/{id}/uncheck — htmx-Partial: aufheben (gegessen → geplant).
*
* Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment).
*/
public function uncheck(Request $request, int $id): Response
{
$mahlzeit = $this->uncheck->execute($id);
return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]);
}
/**
* Bindet die Formularfelder (Name/Notiz/Wochentage) aus dem Request.
*
* @return array{0: string, 1: string, 2: list<Wochentag>}
*/
private function binde(Request $request): array
{
$name = trim((string) $request->request->get('name', ''));
$notiz = (string) $request->request->get('notiz', '');
$wochentage = Wochentag::ausWerten((array) $request->request->all('wochentage'));
return [$name, $notiz, $wochentage];
}
/**
* Bindet die neue ID-Reihenfolge (Drag & Drop) aus dem Request.
* Unterstützt JSON `{"ids": [...]}`, das Formularfeld `ids[]`
* und kommagetrennte `ids=1,2,3`. Ungültige Werte werden verworfen.
*
* @return list<int>
*/
private function bindeReihenfolge(Request $request): array
{
$roh = null;
$json = json_decode((string) $request->getContent(), true);
if (\is_array($json) && \is_array($json['ids'] ?? null)) {
$roh = $json['ids'];
} else {
// Raw-Bag-Zugriff: `get()`/`all(key)` werfen je auf Array/Skalar.
$roh = $request->request->all()['ids'] ?? null;
if (\is_string($roh)) {
$roh = array_filter(explode(',', $roh), static fn (string $teil): bool => trim($teil) !== '');
}
}
if (!\is_array($roh)) {
return [];
}
$ids = [];
foreach ($roh as $wert) {
if ((\is_int($wert) || \is_string($wert)) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
$ids[] = (int) $wert;
}
}
return $ids;
}
/**
* Bindet die Ausschluss-Liste bereits gezeigter Vorschläge ("Weitere 5").
* Akzeptiert ein String-Feld (`?ausschluss=1,2,3`) oder ein Array-Feld
* (`ausschluss[]`); rawer Bag-Zugriff, da `ParameterBag::get()`/`all(key)`
* je auf Skalar/Array werfen.
*
* @return list<int>
*/
private function bindeAusschluss(array|string|null $werte): array
{
if ($werte === null || $werte === '') {
return [];
}
if (\is_string($werte)) {
$werte = array_filter(explode(',', $werte), static fn (string $teil): bool => trim($teil) !== '');
}
$ids = [];
foreach ($werte as $wert) {
if (\is_scalar($wert) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
$ids[] = (int) trim((string) $wert);
}
}
return $ids;
}
/**
* @param array{name: string, notiz: string, wochentage: list<Wochentag>} $werte
* @return array<string, mixed>
*/
private function formKontext(?Mahlzeit $mahlzeit, ?string $fehler, array $werte): array
{
return [
'mahlzeit' => $mahlzeit,
'fehler' => $fehler,
'werte' => $werte,
'wochentage_choices' => Wochentag::alle(),
];
}
/**
* Gruppiert Mahlzeiten nach ihren Wochentagen (Mo…So).
*
* Eine Mahlzeit mit mehreren Wochentagen erscheint unter jedem dieser Tage.
* Mahlzeiten ohne Wochentag landen in der Gruppe "Ohne Tag". Leere Gruppen
* werden herausgefiltert; die Reihenfolge folgt Mo…So, danach "Ohne Tag".
*
* @param list<MahlzeitResponse> $mahlzeiten
* @return array<string, list<MahlzeitResponse>>
*/
private function gruppierenNachTag(array $mahlzeiten): array
{
$tage = [];
foreach (Wochentag::alle() as $tag) {
$tage[$tag->name] = [];
}
$tage['Ohne Tag'] = [];
foreach ($mahlzeiten as $m) {
if ($m->wochentage === []) {
$tage['Ohne Tag'][] = $m;
continue;
}
foreach ($m->wochentage as $tag) {
$tage[$tag->name][] = $m;
}
}
return \array_filter($tage, static fn (array $liste) => $liste !== []);
}
}
+51 -2
View File
@@ -12,6 +12,7 @@
--accent: #0a7bff; --accent: #0a7bff;
--muted: #667; --muted: #667;
--warn: #b45309; --warn: #b45309;
--danger: #b91c1c;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
@@ -44,17 +45,65 @@
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 1px 2px rgba(0,0,0,.06); box-shadow: 0 1px 2px rgba(0,0,0,.06);
} }
.muted { color: var(--muted); font-size: 0.9rem; }
.row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.btn {
display: inline-block;
background: var(--accent);
color: #fff;
padding: 0.4rem 0.8rem;
border-radius: 0.35rem;
text-decoration: none;
border: none;
font: inherit;
cursor: pointer;
}
.btn:hover { opacity: 0.92; }
.btn-danger { background: var(--danger); }
.btn-link { background: transparent; color: var(--muted); }
.actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.item { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem; border: 1px solid #eee; border-radius: 0.4rem; align-items: center; flex-wrap: wrap; }
.drag-handle { cursor: grab; color: var(--muted); font-weight: 600; margin-right: 0.55rem; user-select: none; }
.item-drag { opacity: 0.4; }
.tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; }
.tag { background: #eef4ff; color: var(--accent); padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
.status { font-size: 0.8rem; color: var(--muted); }
.status-gegessen { color: #15573b; }
.field { margin-bottom: 1rem; }
.field > label { display: block; font-weight: 600; margin-bottom: 0.3rem; }
.field input[type="text"], .field textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.35rem;
font: inherit;
}
.req { color: var(--danger); }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin-top: 0.3rem; }
.checkbox { display: inline-flex; align-items: center; gap: 0.3rem; font-weight: 400; cursor: pointer; }
.flash { padding: 0.6rem 0.9rem; border-radius: 0.4rem; margin-bottom: 1rem; }
.flash-notice { background: #e6f6ec; color: #15573b; }
.flash-error { background: #fdecec; color: #8a1f1f; }
</style> </style>
{% block stylesheets %}{% endblock %} {% block stylesheets %}{% endblock %}
</head> </head>
<body> <body>
<header> <header>
<strong>essen.ljh31.de</strong> <a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
<nav> <nav>
<a href="logout">Abmelden</a> <a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
<a href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
<a href="{{ path('logout') }}">Abmelden</a>
</nav> </nav>
</header> </header>
<main> <main>
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="flash flash-{{ label }}">{{ message }}</div>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %} {% block body %}{% endblock %}
</main> </main>
<script src="{{ asset('js/htmx.min.js') }}"></script> <script src="{{ asset('js/htmx.min.js') }}"></script>
+51
View File
@@ -0,0 +1,51 @@
{#
Mahlzeiten-Partial (eine Zeile). Wird in Planungsliste/Tagesansicht/Archiv
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
@param MahlzeitResponse m
@param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste)
#}
<li class="item" id="mahlzeit-{{ m.id }}"
{% if sortierbar is defined and sortierbar %}data-id="{{ m.id }}"{% endif %}>
<div>
{% if sortierbar is defined and sortierbar %}
<span class="drag-handle" title="Ziehen zum Sortieren">⋮</span>
{% endif %}
<strong>{{ m.name }}</strong>
{% if m.notiz != '' %}
<div class="muted">{{ m.notiz }}</div>
{% endif %}
{% if m.wochentage|length > 0 %}
<div class="tags">
{% for tag in m.wochentage %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
{% if m.status.value == 'gegessen' and m.gegessenAm %}
· {{ m.gegessenAm|date('d.m.Y H:i') }}
{% endif %}
</span>
</div>
<div class="actions">
{% if m.status.value == 'geplant' %}
<button type="button" class="btn"
hx-post="{{ path('mahlzeiten_checkoff', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">Abhaken</button>
{% else %}
<button type="button" class="btn btn-link"
hx-post="{{ path('mahlzeiten_uncheck', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">Aufheben</button>
{% endif %}
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">Bearbeiten</a>
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
<button type="submit" class="btn btn-danger">Löschen</button>
</form>
</div>
</li>
+27
View File
@@ -0,0 +1,27 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Archiv · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<h1 style="margin: 0;">Archiv</h1>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
</div>
</div>
<p class="muted" style="margin-top: 0.75rem;">Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.</p>
{% if mahlzeiten|length == 0 %}
<p class="muted">Noch nichts gegessen – das Archiv ist leer.</p>
{% else %}
<ul class="list">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends 'layout/base.html.twig' %}
{% set titel = mahlzeit ? 'Mahlzeit bearbeiten' : 'Neue Mahlzeit' %}
{% block title %}{{ titel }} · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<h1 style="margin-top: 0;">{{ titel }}</h1>
{% if fehler %}
<div class="flash flash-error">{{ fehler }}</div>
{% endif %}
<form method="post"
action="{{ mahlzeit ? path('mahlzeiten_aktualisieren', { id: mahlzeit.id }) : path('mahlzeiten_speichern') }}">
<div class="field">
<label for="name">Name <span class="req" title="Pflichtfeld">*</span></label>
<input type="text" id="name" name="name" value="{{ werte.name }}" required>
</div>
<div class="field">
<label for="notiz">Notiz</label>
<textarea id="notiz" name="notiz" rows="3" placeholder="z. B. mit Basilikum, scharf …">{{ werte.notiz }}</textarea>
</div>
<div class="field">
<label>Wochentage</label>
{% set gewaehlte = werte.wochentage|map(t => t.value) %}
<div class="checkboxes">
{% for tag in wochentage_choices %}
<label class="checkbox">
<input type="checkbox" name="wochentage[]" value="{{ tag.value }}"
{{ tag.value in gewaehlte ? 'checked' : '' }}>
{{ tag.name }}
</label>
{% endfor %}
</div>
</div>
<div class="actions">
<button type="submit" class="btn">{{ mahlzeit ? 'Speichern' : 'Anlegen' }}</button>
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">Abbrechen</a>
</div>
</form>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Mahlzeiten · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<div>
<h1 style="margin: 0;">Mahlzeiten</h1>
<div class="muted" style="margin-top: 0.2rem;">Planungsliste – per Drag &amp; Drop sortieren</div>
</div>
<div class="actions">
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_tage') }}">Tagesansicht</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
</div>
</div>
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten in der Planung.</p>
{% else %}
<ul class="list sortierbar">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m, sortierbar: true }) }}
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
{% block javascripts %}
<script src="{{ asset('js/sortable.min.js') }}"></script>
<script>
(function () {
const liste = document.querySelector('ul.sortierbar');
if (!liste || typeof Sortable === 'undefined') { return; }
const url = '{{ path("mahlzeiten_reorder") }}';
Sortable.create(liste, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'item-drag',
onEnd: function () {
const ids = Array.prototype.map.call(
liste.querySelectorAll('li[data-id]'),
function (li) { return Number(li.getAttribute('data-id')); }
);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ids }),
}).then(function (antwort) {
if (!antwort.ok) { window.location.reload(); }
}).catch(function () { window.location.reload(); });
},
});
})();
</script>
{% endblock %}
@@ -0,0 +1,61 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Inspiration · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<div>
<h1 style="margin: 0;">Inspiration</h1>
<div class="muted" style="margin-top: 0.2rem;">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
</div>
</div>
{# Für "Weitere 5" / Adopt-Kontext: bisheriger Ausschluss + die IDs dieser Seite #}
{% set alleAusschluss = ausschluss + gezeigte %}
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
</p>
{% else %}
<ul class="list">
{% for v in vorschlaege %}
<li class="item" id="vorschlag-{{ v.id }}">
<div>
<strong>{{ v.name }}</strong>
{% if v.notiz != '' %}
<div class="muted">{{ v.notiz }}</div>
{% endif %}
<span class="status">
{% if v.gegessenAm %}
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
{% else %}
aus dem Archiv
{% endif %}
· {{ v.auftraete }}× in der Küche
</span>
</div>
<div class="actions">
<form method="post" action="{{ path('mahlzeiten_adopt', { id: v.id }) }}">
{% for id in alleAusschluss %}
<input type="hidden" name="ausschluss[]" value="{{ id }}">
{% endfor %}
<button type="submit" class="btn">Übernehmen</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% if mehrVerfuegbar %}
<p style="margin: 1rem 0 0;">
<a class="btn btn-link"
href="{{ path('mahlzeiten_inspiration', { ausschluss: alleAusschluss|join(',') }) }}">Weitere 5 Vorschläge</a>
</p>
{% endif %}
{% endif %}
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Tagesansicht · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<div>
<h1 style="margin: 0;">Tagesansicht</h1>
<div class="muted" style="margin-top: 0.2rem;">Alle Mahlzeiten nach Wochentag</div>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
</div>
</div>
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
{% else %}
{% for tag, mahlzeiten in tage %}
<h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
<ul class="list">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
{% endfor %}
</ul>
{% endfor %}
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,501 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\UI\Essen;
use App\Data\Essen\Entity\MahlzeitEntity;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\DomCrawler\Crawler;
/**
* M1 Schritt 2: CRUD-Slice "Mahlzeit" über die Web-UI (eingeloggte Session).
*
* `createClient()` darf pro Test nur einmal den Kernel booten, daher wird
* Client + Schema + Login je Test per Helper vorbereitet (Pattern wie SmokeTest).
* Test-DB ist file-basiert (var/test.db), s. doctrine.yaml when@test.
*/
final class MahlzeitControllerTest extends WebTestCase
{
private const AUTH_KEYS = [
'auth_sub' => 'test-sub',
'auth_name' => 'Jens',
'auth_email' => 'jens@ljh31.de',
];
private KernelBrowser $client;
public function testIndexRedirectsToLoginWithoutSession(): void
{
$client = $this->vorbereiten(false);
$client->request('GET', '/mahlzeiten');
self::assertResponseStatusCodeSame(302);
self::assertResponseRedirects('/login?redirect=/mahlzeiten');
}
public function testIndexRendersEmptyListForAuthedUser(): void
{
$client = $this->vorbereiten();
$client->request('GET', '/mahlzeiten');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Mahlzeiten');
self::assertSelectorTextContains('p', 'Noch keine Mahlzeiten');
}
public function testNeuRendersEmptyForm(): void
{
$client = $this->vorbereiten();
$client->request('GET', '/mahlzeiten/neu');
self::assertResponseIsSuccessful();
self::assertSelectorExists('input[name="name"]');
self::assertSelectorExists('textarea[name="notiz"]');
self::assertSelectorExists('input[name="wochentage[]"][value="mo"]');
}
public function testCreateRedirectsAndPersists(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten', [
'name' => 'Bolognese',
'notiz' => 'mit Basilikum',
'wochentage' => ['mo', 'mi'],
]);
self::assertResponseRedirects('/mahlzeiten');
$entity = $this->em()->getRepository(MahlzeitEntity::class)->findOneBy(['name' => 'Bolognese']);
self::assertNotNull($entity);
self::assertSame('mit Basilikum', $entity->getNotiz());
self::assertSame('mo,mi', $entity->getWochentage());
self::assertSame('geplant', $entity->getStatus());
}
public function testCreateWithEmptyNameRendersFormWithError(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten', [
'name' => ' ',
'notiz' => 'x',
'wochentage' => ['di'],
]);
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('div', 'Name ist ein Pflichtfeld');
self::assertCount(0, $this->em()->getRepository(MahlzeitEntity::class)->findAll());
}
public function testUpdatePersistsChanges(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Curry', '', []);
$client->request('POST', sprintf('/mahlzeiten/%d', $id), [
'name' => 'Currywurst',
'notiz' => 'scharf',
'wochentage' => ['do'],
]);
self::assertResponseRedirects('/mahlzeiten');
$this->em()->clear();
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
self::assertSame('Currywurst', $entity->getName());
self::assertSame('scharf', $entity->getNotiz());
self::assertSame('do', $entity->getWochentage());
}
public function testDeleteRemovesMeal(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Pizza', '', []);
$client->request('POST', sprintf('/mahlzeiten/%d/loeschen', $id));
self::assertResponseRedirects('/mahlzeiten');
$this->em()->clear();
self::assertNull($this->em()->getRepository(MahlzeitEntity::class)->find($id));
}
public function testBearbeitenUnknownIdReturns404(): void
{
$client = $this->vorbereiten();
$client->request('GET', '/mahlzeiten/99999/bearbeiten');
self::assertResponseStatusCodeSame(404);
}
public function testIndexShowsPersistedMealWithActions(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'Lasagne', 'Samstag', ['sa']);
$client->request('GET', '/mahlzeiten');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('strong', 'Lasagne');
self::assertSelectorTextContains('span.tag', 'Sa');
self::assertSelectorExists('a[href$="/bearbeiten"]');
self::assertSelectorExists('form[action$="/loeschen"]');
}
// ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ----
public function testTageViewGroupsMealsByWeekday(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'Curry', '', ['di']);
$this->anlegen($client, 'Pizza', '', ['sa']);
$this->anlegen($client, 'Bolognese', '', ['mo', 'mi']);
$client->request('GET', '/mahlzeiten/tage');
$crawler = $client->getCrawler();
self::assertResponseIsSuccessful();
// Curry exakt unter "Di", nicht unter "Sa".
$diUl = $this->listeNachHeading($crawler, 'Di');
self::assertStringContainsString('Curry', $diUl->html());
self::assertStringNotContainsString('Pizza', $diUl->html());
// Pizza exakt unter "Sa".
$saUl = $this->listeNachHeading($crawler, 'Sa');
self::assertStringContainsString('Pizza', $saUl->html());
self::assertStringNotContainsString('Curry', $saUl->html());
// Bolognese hat zwei Wochentage → erscheint unter "Mo" UND "Mi".
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mo')->html());
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html());
}
public function testTageViewGroupsMealWithoutWeekdayUnderOhneTag(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'Reis', '', []);
$client->request('GET', '/mahlzeiten/tage');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html());
}
public function testCheckOffReturnsGegessenPartial(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Pasta', '', ['mo']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
// htmx-Fragment: genau das Mahlzeiten-Partial, keine komplette Seite.
self::assertSelectorExists('li#mahlzeit-' . $id);
self::assertSelectorTextContains('span.status', 'gegessen');
$content = (string) $client->getResponse()->getContent();
self::assertStringNotContainsString('<header', $content);
self::assertStringNotContainsString('<!DOCTYPE', $content);
// Persistenz: Status auf "gegessen" + gegessenAm gesetzt.
$this->em()->clear();
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
self::assertSame('gegessen', $entity->getStatus());
self::assertNotNull($entity->getGegessenAm());
}
public function testUncheckRestoresGeplantStatus(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Suppe', '', []);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
$client->request('POST', sprintf('/mahlzeiten/%d/uncheck', $id));
self::assertResponseIsSuccessful();
self::assertSelectorExists('li#mahlzeit-' . $id);
self::assertSelectorTextContains('span.status', 'geplant');
// Persistenz: Status zurück auf "geplant" + gegessenAm zurückgesetzt.
$this->em()->clear();
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
self::assertSame('geplant', $entity->getStatus());
self::assertNull($entity->getGegessenAm());
}
public function testArchivShowsOnlyGegessenMeals(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'GeplanteMahlzeit', '', ['mo']);
$gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', ['di']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId));
self::assertResponseIsSuccessful();
$client->request('GET', '/mahlzeiten/archiv');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Archiv');
// Nur die Ergebnisliste prüfen (Flash-Meldungen der Anleg-Requests ausblenden).
$liste = $client->getCrawler()->filter('ul')->html();
self::assertStringContainsString('GegesseneMahlzeit', $liste);
self::assertStringNotContainsString('GeplanteMahlzeit', $liste);
}
public function testCheckOffUnknownIdReturns404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/99999/checkoff');
self::assertResponseStatusCodeSame(404);
}
public function testUncheckUnknownIdReturns404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/99999/uncheck');
self::assertResponseStatusCodeSame(404);
}
// ---- M3: Planungsliste (flach/sortierbar), Reorder, Inspiration, Adopt ----
public function testIndexRendersFlatPlannedListOnly(): void
{
$client = $this->vorbereiten();
$geplantId = $this->anlegen($client, 'GeplanteMahlzeit', '', []);
$gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', []);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId));
self::assertResponseIsSuccessful();
$client->request('GET', '/mahlzeiten');
self::assertResponseIsSuccessful();
// Sortierbare Planungsliste mit Drag-Handle + data-id; gegessene Mahlzeit fehlt.
self::assertSelectorExists('ul.sortierbar');
self::assertSelectorExists('li[data-id="' . $geplantId . '"]');
self::assertSelectorExists('li[data-id="' . $geplantId . '"] .drag-handle');
self::assertSelectorNotExists('li[data-id="' . $gegessenId . '"]');
}
public function testIndexKeepsPersistedSortOrder(): void
{
$client = $this->vorbereiten();
$a = $this->anlegen($client, 'Erste', '', []);
$b = $this->anlegen($client, 'Zweite', '', []);
$c = $this->anlegen($client, 'Dritte', '', []);
// Anordnung ändern: C, A, B (Formularfeld `ids[]`).
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [$c, $a, $b]]);
self::assertResponseStatusCodeSame(204);
// Persistenz: sortOrder neu nummeriert.
$this->em()->clear();
$repo = $this->em()->getRepository(MahlzeitEntity::class);
self::assertSame(1, $repo->find($c)->getSortOrder());
self::assertSame(2, $repo->find($a)->getSortOrder());
self::assertSame(3, $repo->find($b)->getSortOrder());
// Index rendert die neue Reihenfolge.
$client->request('GET', '/mahlzeiten');
$reihenfolge = $client->getCrawler()
->filter('li[data-id]')
->each(static fn (Crawler $node): int => (int) $node->attr('data-id'));
self::assertSame([$c, $a, $b], $reihenfolge);
}
public function testReorderUnbekannteIdLiefert404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [99999]]);
self::assertResponseStatusCodeSame(404);
}
public function testReorderAkzeptiertJsonIds(): void
{
$client = $this->vorbereiten();
$a = $this->anlegen($client, 'Erste', '', []);
$b = $this->anlegen($client, 'Zweite', '', []);
// SortableJS schickt JSON: {"ids": [...]} – hier als roher Body.
$client->request('POST', '/mahlzeiten/reorder', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], json_encode(['ids' => [$b, $a]]));
self::assertResponseStatusCodeSame(204);
$this->em()->clear();
$repo = $this->em()->getRepository(MahlzeitEntity::class);
self::assertSame(1, $repo->find($b)->getSortOrder());
self::assertSame(2, $repo->find($a)->getSortOrder());
}
public function testInspirationListetArchivGerichteMitAdopt(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Bolognese', '', ['mo']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
$client->request('GET', '/inspiration');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Inspiration');
self::assertSelectorTextContains('strong', 'Bolognese');
self::assertSelectorExists('form[action$="/adopt"] button[type="submit"]');
}
public function testInspirationLeerOhneArchivEintraege(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'NurGeplant', '', []);
$client->request('GET', '/inspiration');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('p', 'Noch keine Inspiration');
self::assertSelectorNotExists('li[id^="vorschlag-"]');
}
public function testInspirationWeitere5ExkludiertBereitsGezeigte(): void
{
$client = $this->vorbereiten();
foreach (['A', 'B', 'C', 'D', 'E', 'F'] as $buchstabe) {
$id = $this->anlegen($client, sprintf('Gericht%s', $buchstabe), '', []);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
}
$client->request('GET', '/inspiration');
self::assertResponseIsSuccessful();
$ersteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
self::assertCount(5, $ersteSeite);
$ersteSeiteNamen = $ersteSeite->each(static fn (Crawler $n): string => $n->filter('strong')->text());
// "Weitere 5" führt mit Ausschluss-Liste zurück zur Inspiration.
$link = $client->getCrawler()->filter('a:contains("Weitere 5 Vorschläge")');
self::assertCount(1, $link);
self::assertStringContainsString('ausschluss=', (string) $link->link()->getUri());
$client->request('GET', (string) $link->link()->getUri());
$zweiteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
self::assertCount(1, $zweiteSeite);
self::assertNotContains($zweiteSeite->filter('strong')->text(), $ersteSeiteNamen);
}
public function testAdoptErstelltNeuenGeplantEintrag(): void
{
$client = $this->vorbereiten();
$quellId = $this->anlegen($client, 'Pasta', 'mit Knoblauch', ['di']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $quellId));
self::assertResponseIsSuccessful();
$client->request('POST', sprintf('/mahlzeiten/%d/adopt', $quellId));
self::assertResponseRedirects('/inspiration');
$this->em()->clear();
$repo = $this->em()->getRepository(MahlzeitEntity::class);
// Quelle bleibt unverändert im Archiv.
self::assertSame('gegessen', $repo->find($quellId)->getStatus());
// Neue Kopie: gleicher Name/Notiz/Wochentag, aber geplant + ohne gegessenAm.
$kopien = $repo->findBy(['name' => 'Pasta']);
self::assertCount(2, $kopien);
$neue = null;
foreach ($kopien as $kandidat) {
if ((int) $kandidat->getId() !== $quellId) {
$neue = $kandidat;
}
}
self::assertNotNull($neue);
self::assertSame('geplant', $neue->getStatus());
self::assertNull($neue->getGegessenAm());
self::assertSame('mit Knoblauch', $neue->getNotiz());
self::assertSame('di', $neue->getWochentage());
}
public function testAdoptUnbekannteIdLiefert404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/99999/adopt');
self::assertResponseStatusCodeSame(404);
}
/**
* Liefert die <ul>-Liste, die direkt unter der angegebenen <h2>-Überschrift
* (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt.
*/
private function listeNachHeading(Crawler $crawler, string $heading): Crawler
{
$h2 = $crawler->filterXPath(sprintf('//h2[normalize-space()="%s"]', $heading));
self::assertCount(1, $h2, sprintf('Genau ein <h2> "%s" erwartet.', $heading));
// Das <ul> ist der erste nächste Geschwister-Knoten der Überschrift.
return $h2->nextAll()->first();
}
/**
* Client + sauberes Schema (+ optional eingeloggte Session) bereitstellen.
*/
private function vorbereiten(bool $auth = true): KernelBrowser
{
$client = self::createClient();
$client->followRedirects(false);
$this->client = $client;
$em = $this->em();
$schemaTool = new SchemaTool($em);
$schemaTool->dropSchema($em->getMetadataFactory()->getAllMetadata());
$schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata());
if ($auth) {
$session = self::getContainer()->get('session.factory')->createSession();
$session->start();
foreach (self::AUTH_KEYS as $k => $v) {
$session->set($k, $v);
}
$session->save();
$client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
}
return $client;
}
/**
* Legt eine Mahlzeit über die UI an und liefert die neue DB-ID.
*/
private function anlegen(KernelBrowser $client, string $name, string $notiz, array $wochentage): int
{
$client->request('POST', '/mahlzeiten', [
'name' => $name,
'notiz' => $notiz,
'wochentage' => $wochentage,
]);
self::assertResponseRedirects('/mahlzeiten');
$em = $this->em();
$em->clear();
$entity = $em->getRepository(MahlzeitEntity::class)->findOneBy(['name' => $name]);
self::assertNotNull($entity);
return (int) $entity->getId();
}
private function em(): EntityManagerInterface
{
return self::getContainer()->get('doctrine')->getManager();
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Data\Essen;
use App\Data\Essen\Entity\MahlzeitEntity;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Basis für DB-Integrationstests auf In-Memory-SQLite:
* Kernel booten, Schema erzeugen, sauberen EntityManager liefern.
*/
abstract class DatabaseTestCase extends KernelTestCase
{
protected EntityManagerInterface $em;
protected function setUp(): void
{
parent::setUp();
$container = static::getContainer();
$this->em = $container->get('doctrine')->getManager();
$schemaTool = new SchemaTool($this->em);
$schemaTool->dropSchema($this->em->getMetadataFactory()->getAllMetadata());
$schemaTool->createSchema($this->em->getMetadataFactory()->getAllMetadata());
}
protected function tearDown(): void
{
$this->em->close();
parent::tearDown();
}
/** @return list<MahlzeitEntity> */
protected function alleEntitaeten(): array
{
return $this->em->getRepository(MahlzeitEntity::class)->findAll();
}
}
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Data\Essen;
use App\Data\Essen\Entity\MahlzeitEntity;
use App\Data\Essen\Mapper\MahlzeitMapper;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use PHPUnit\Framework\TestCase;
final class MahlzeitMapperTest extends TestCase
{
private function mapper(): MahlzeitMapper
{
return new MahlzeitMapper();
}
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
public function testModelZuEntityMapptAlleFelder(): void
{
$jetzt = $this->jetzt();
$gegessenAm = $jetzt->modify('+1 day');
$model = new Mahlzeit(
id: 0,
name: 'Bolognese',
notiz: 'süß',
wochentage: [Wochentag::Mo, Wochentag::Do, Wochentag::Fr],
sortOrder: 2,
status: MahlzeitStatus::Gegessen,
erstelltAm: $jetzt,
aktualisiertAm: $jetzt,
gegessenAm: $gegessenAm,
);
$entity = $this->mapper()->createEntity($model);
self::assertSame('Bolognese', $entity->getName());
self::assertSame('süß', $entity->getNotiz());
self::assertSame('mo,do,fr', $entity->getWochentage());
self::assertSame(2, $entity->getSortOrder());
self::assertSame('gegessen', $entity->getStatus());
self::assertSame($gegessenAm, $entity->getGegessenAm());
self::assertSame($jetzt, $entity->getErstelltAm());
}
public function testLeereWochentageWerdenNull(): void
{
$jetzt = $this->jetzt();
$model = new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
$entity = $this->mapper()->createEntity($model);
self::assertNull($entity->getWochentage());
}
public function testLeereNotizWirdNull(): void
{
$jetzt = $this->jetzt();
$model = new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
$entity = $this->mapper()->createEntity($model);
self::assertNull($entity->getNotiz());
}
public function testEntityZuModelMapptAlleFelder(): void
{
$jetzt = $this->jetzt();
$gegessenAm = $jetzt->modify('+1 day');
$entity = new MahlzeitEntity();
$entity->setName('Linsen');
$entity->setNotiz('scharf');
$entity->setWochentage('mi,sa');
$entity->setSortOrder(4);
$entity->setStatus('gegessen');
$entity->setGegessenAm($gegessenAm);
$entity->setErstelltAm($jetzt);
$entity->setAktualisiertAm($jetzt);
$model = $this->mapper()->toModel($entity);
self::assertSame('Linsen', $model->name);
self::assertSame('scharf', $model->notiz);
self::assertSame([Wochentag::Mi, Wochentag::Sa], $model->wochentage());
self::assertSame(4, $model->sortOrder);
self::assertSame(MahlzeitStatus::Gegessen, $model->status);
self::assertSame($gegessenAm, $model->gegessenAm);
self::assertTrue($model->istGegessen());
}
public function testEntityMitLeerenWochentageLiefertLeeresArray(): void
{
$jetzt = $this->jetzt();
$entity = new MahlzeitEntity();
$entity->setName('X');
$entity->setNotiz(null);
$entity->setWochentage(null);
$entity->setSortOrder(0);
$entity->setStatus('geplant');
$entity->setGegessenAm(null);
$entity->setErstelltAm($jetzt);
$entity->setAktualisiertAm($jetzt);
$model = $this->mapper()->toModel($entity);
self::assertSame([], $model->wochentage());
self::assertSame('', $model->notiz);
self::assertTrue($model->istGeplant());
}
public function testUngültigeWochentagswerteWerdenGefiltert(): void
{
$jetzt = $this->jetzt();
$entity = new MahlzeitEntity();
$entity->setName('X');
$entity->setWochentage('mo,xx,di');
$entity->setSortOrder(0);
$entity->setStatus('geplant');
$entity->setErstelltAm($jetzt);
$entity->setAktualisiertAm($jetzt);
$model = $this->mapper()->toModel($entity);
self::assertSame([Wochentag::Mo, Wochentag::Di], $model->wochentage());
}
public function testUpdateEntitySchreibtNeueWerte(): void
{
$jetzt = $this->jetzt();
$entity = new MahlzeitEntity();
$entity->setSortOrder(0);
$entity->setErstelltAm($jetzt);
$entity->setAktualisiertAm($jetzt);
$model = new Mahlzeit(5, 'Neu', 'notiz', [Wochentag::So], 9, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
$this->mapper()->updateEntity($model, $entity);
self::assertSame('Neu', $entity->getName());
self::assertSame('so', $entity->getWochentage());
self::assertSame(9, $entity->getSortOrder());
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Data\Essen;
use App\Data\Essen\Mapper\MahlzeitMapper;
use App\Data\Essen\Processor\MahlzeitProcessor;
use App\Data\Essen\Provider\MahlzeitProvider;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
final class MahlzeitProcessorTest extends DatabaseTestCase
{
private function processor(): MahlzeitProcessor
{
return new MahlzeitProcessor($this->em, new MahlzeitMapper());
}
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
public function testCreatePersistiertUndLiefertMitNeuerId(): void
{
$m = Mahlzeit::anlegen('Bolognese', 'süß', [Wochentag::Mo, Wochentag::Do], 0, $this->jetzt());
$gespeichert = $this->processor()->create($m);
self::assertGreaterThan(0, $gespeichert->id);
self::assertCount(1, $this->alleEntitaeten());
$neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($gespeichert->id);
self::assertNotNull($neu);
self::assertSame('Bolognese', $neu->name);
self::assertSame('süß', $neu->notiz);
self::assertSame([Wochentag::Mo, Wochentag::Do], $neu->wochentage());
}
public function testSaveAktualisiertBestehendeZeile(): void
{
$m = Mahlzeit::anlegen('Altes', '', [Wochentag::Mo], 1, $this->jetzt());
$created = $this->processor()->create($m);
$geaendert = $created->aktualisieren(
name: 'Neues',
notiz: 'scharf',
wochentage: [Wochentag::Fr],
sortOrder: $created->sortOrder,
jetzt: $this->jetzt(),
);
$gespeichert = $this->processor()->save($geaendert);
self::assertSame($created->id, $gespeichert->id);
self::assertCount(1, $this->alleEntitaeten());
$neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($created->id);
self::assertNotNull($neu);
self::assertSame('Neues', $neu->name);
self::assertSame('scharf', $neu->notiz);
self::assertSame([Wochentag::Fr], $neu->wochentage());
}
public function testSaveMitUnbekannterIdWirft404(): void
{
$m = Mahlzeit::anlegen('X', '', [], 0, $this->jetzt())->mitId(9999);
$this->expectException(MahlzeitNotFoundException::class);
$this->processor()->save($m);
}
public function testDeleteEntferntZeile(): void
{
$m = Mahlzeit::anlegen('Bolognese', '', [], 1, $this->jetzt());
$created = $this->processor()->create($m);
$this->processor()->delete($created);
self::assertCount(0, $this->alleEntitaeten());
}
public function testDeleteMitUnbekannterIdWirft404(): void
{
$m = Mahlzeit::anlegen('X', '', [], 0, $this->jetzt())->mitId(9999);
$this->expectException(MahlzeitNotFoundException::class);
$this->processor()->delete($m);
}
public function testSavePersistiertAbhakenAlsGegessen(): void
{
$jetzt = $this->jetzt();
$created = $this->processor()->create(Mahlzeit::anlegen('Bolognese', '', [Wochentag::Mo], 1, $jetzt));
$gegessen = $created->markAsGegessen($jetzt);
$this->processor()->save($gegessen);
$neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($created->id);
self::assertNotNull($neu);
self::assertSame(MahlzeitStatus::Gegessen, $neu->status);
self::assertSame($jetzt, $neu->gegessenAm);
self::assertTrue($neu->istGegessen());
}
public function testSavePersistiertAufhebenAlsGeplant(): void
{
$jetzt = $this->jetzt();
$created = $this->processor()->create(Mahlzeit::anlegen('Bolognese', '', [Wochentag::Mo], 1, $jetzt)->markAsGegessen($jetzt));
$geplant = $created->markAsGeplant($jetzt);
$this->processor()->save($geplant);
$neu = (new MahlzeitProvider($this->em, new MahlzeitMapper()))->find($created->id);
self::assertNotNull($neu);
self::assertSame(MahlzeitStatus::Geplant, $neu->status);
self::assertNull($neu->gegessenAm);
self::assertTrue($neu->istGeplant());
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Data\Essen;
use App\Data\Essen\Mapper\MahlzeitMapper;
use App\Data\Essen\Provider\MahlzeitProvider;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
final class MahlzeitProviderTest extends DatabaseTestCase
{
private function provider(): MahlzeitProvider
{
return new MahlzeitProvider($this->em, new MahlzeitMapper());
}
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
private function speichere(string $name, int $sortOrder, string $status, array $tage): Mahlzeit
{
$jetzt = $this->jetzt();
$m = Mahlzeit::anlegen($name, '', $tage, $sortOrder, $jetzt);
if ($status === 'gegessen') {
$m = $m->markAsGegessen($jetzt);
}
$entity = (new MahlzeitMapper())->createEntity($m);
$this->em->persist($entity);
$this->em->flush();
return $m;
}
public function testFindLiefertModell(): void
{
$m = $this->speichere('Bolognese', 1, 'geplant', [Wochentag::Mo]);
$id = $this->alleEntitaeten()[0]->getId();
$gefunden = $this->provider()->find($id);
self::assertNotNull($gefunden);
self::assertSame('Bolognese', $gefunden->name);
self::assertSame($id, $gefunden->id);
}
public function testFindLiefertNullBeiUnbekannterId(): void
{
self::assertNull($this->provider()->find(9999));
}
public function testListOhneFilterGibtAlleSortiertZurück(): void
{
$this->speichere('Zweite', 2, 'geplant', []);
$this->speichere('Erste', 1, 'geplant', []);
$this->speichere('Dritte', 3, 'gegessen', []);
$liste = $this->provider()->list();
self::assertCount(3, $liste);
self::assertSame(['Erste', 'Zweite', 'Dritte'], \array_map(static fn (Mahlzeit $m) => $m->name, $liste));
}
public function testListMitStatusFilter(): void
{
$this->speichere('A', 1, 'geplant', []);
$this->speichere('B', 2, 'gegessen', []);
$this->speichere('C', 3, 'geplant', []);
$geplante = $this->provider()->list(MahlzeitStatus::Geplant);
$gegessene = $this->provider()->list(MahlzeitStatus::Gegessen);
self::assertCount(2, $geplante);
self::assertCount(1, $gegessene);
self::assertSame('B', $gegessene[0]->name);
}
public function testMaxSortOrderLiefertMaximum(): void
{
$this->speichere('A', 1, 'geplant', []);
$this->speichere('B', 7, 'geplant', []);
$this->speichere('C', 3, 'geplant', []);
self::assertSame(7, $this->provider()->maxSortOrder());
}
public function testMaxSortOrderLeereTabelleLiefertNullsafe(): void
{
// provider gibt null->0 zurück
$max = $this->provider()->maxSortOrder();
self::assertIsInt($max);
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\Mapping;
use App\Logic\Common\Exception\BusinessRuleViolationException;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Mapping\MahlzeitFactory;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use PHPUnit\Framework\TestCase;
final class MahlzeitFactoryTest extends TestCase
{
private function factory(): MahlzeitFactory
{
return new MahlzeitFactory();
}
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
public function testCreateFromCreateRequest(): void
{
$jetzt = $this->jetzt();
$req = new MahlzeitCreateRequest(
name: 'Pasta',
notiz: 'vegan',
wochentage: [Wochentag::Fr, Wochentag::Sa],
);
$m = $this->factory()->createFromCreateRequest($req, $jetzt);
self::assertSame(0, $m->id);
self::assertSame('Pasta', $m->name);
self::assertSame('vegan', $m->notiz);
self::assertSame([Wochentag::Fr, Wochentag::Sa], $m->wochentage());
self::assertSame(0, $m->sortOrder);
self::assertSame(MahlzeitStatus::Geplant, $m->status);
self::assertSame($jetzt, $m->erstelltAm);
self::assertSame($jetzt, $m->aktualisiertAm);
}
public function testCreateWithLeerenNameWirft(): void
{
$req = new MahlzeitCreateRequest(name: ' ');
$this->expectException(BusinessRuleViolationException::class);
$this->factory()->createFromCreateRequest($req, $this->jetzt());
}
public function testUpdateÄndertNurGelieferteFelder(): void
{
$vorher = new Mahlzeit(
id: 7,
name: 'Altes',
notiz: 'alte Notiz',
wochentage: [Wochentag::Mo],
sortOrder: 3,
status: MahlzeitStatus::Geplant,
erstelltAm: $this->jetzt(),
aktualisiertAm: $this->jetzt(),
gegessenAm: null,
);
$req = new MahlzeitUpdateRequest(name: 'Neues', wochentage: [Wochentag::Do]);
$nachher = $this->factory()->updateFromUpdateRequest($vorher, $req, $this->jetzt());
self::assertSame('Neues', $nachher->name);
self::assertSame([Wochentag::Do], $nachher->wochentage());
self::assertSame('alte Notiz', $nachher->notiz, 'nicht geliefertes Feld bleibt unverändert');
self::assertSame(3, $nachher->sortOrder);
self::assertSame(7, $nachher->id);
}
public function testUpdateMitNullFeldBelässtBestandwert(): void
{
$vorher = new Mahlzeit(7, 'Altes', 'Notiz', [Wochentag::Mo], 3, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), null);
$nachher = $this->factory()->updateFromUpdateRequest($vorher, new MahlzeitUpdateRequest(), $this->jetzt());
self::assertSame('Altes', $nachher->name);
self::assertSame('Notiz', $nachher->notiz);
self::assertSame([Wochentag::Mo], $nachher->wochentage());
}
public function testUpdateMitLeerenNameWirft(): void
{
$vorher = new Mahlzeit(7, 'Altes', '', [], 0, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), null);
$this->expectException(BusinessRuleViolationException::class);
$this->factory()->updateFromUpdateRequest($vorher, new MahlzeitUpdateRequest(name: ' '), $this->jetzt());
}
public function testUpdateLeereWochentageListeSetztKeineTage(): void
{
$vorher = new Mahlzeit(7, 'Altes', '', [Wochentag::Mo], 0, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), null);
$nachher = $this->factory()->updateFromUpdateRequest($vorher, new MahlzeitUpdateRequest(wochentage: []), $this->jetzt());
self::assertSame([], $nachher->wochentage());
}
}
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\Model;
use App\Logic\Common\Exception\BusinessRuleViolationException;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use PHPUnit\Framework\TestCase;
final class MahlzeitTest extends TestCase
{
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
private function geplant(): Mahlzeit
{
return Mahlzeit::anlegen(
name: 'Spaghetti Bolognese',
notiz: 'mit mehr Soße',
wochentage: [Wochentag::Mo, Wochentag::Di],
sortOrder: 1,
jetzt: $this->jetzt(),
);
}
public function testAnlegenErzeugtGeplanteMahlzeit(): void
{
$m = $this->geplant();
self::assertSame(0, $m->id);
self::assertSame('Spaghetti Bolognese', $m->name);
self::assertSame('mit mehr Soße', $m->notiz);
self::assertSame([Wochentag::Mo, Wochentag::Di], $m->wochentage());
self::assertSame(1, $m->sortOrder);
self::assertSame(MahlzeitStatus::Geplant, $m->status);
self::assertNull($m->gegessenAm);
self::assertTrue($m->istGeplant());
}
public function testAnlegenTrimmtNameUndNotiz(): void
{
$m = Mahlzeit::anlegen(' Bolognese ', ' Notiz ', [], 0, $this->jetzt());
self::assertSame('Bolognese', $m->name);
self::assertSame('Notiz', $m->notiz);
}
public function testMarkAsGegessenSetztStatusZeitpunktUndAktualisiertAm(): void
{
$m = $this->geplant();
$später = $this->jetzt()->modify('+2 days');
$gegessen = $m->markAsGegessen($später);
self::assertSame(MahlzeitStatus::Gegessen, $gegessen->status);
self::assertSame($später, $gegessen->gegessenAm);
self::assertSame($später, $gegessen->aktualisiertAm);
self::assertTrue($gegessen->istGegessen());
// Ursprung bleibt unverändert (Unveränderlichkeit)
self::assertSame(MahlzeitStatus::Geplant, $m->status);
}
public function testMarkAsGeplantSetztGegessenAmZurück(): void
{
$gegessen = $this->geplant()->markAsGegessen($this->jetzt());
$geplant = $gegessen->markAsGeplant($this->jetzt());
self::assertSame(MahlzeitStatus::Geplant, $geplant->status);
self::assertNull($geplant->gegessenAm);
self::assertTrue($geplant->istGeplant());
}
public function testMarkAsGegessenIstIdempotent(): void
{
$m = $this->geplant();
$einmal = $m->markAsGegessen($this->jetzt());
$zweimal = $einmal->markAsGegessen($this->jetzt());
self::assertSame($einmal->gegessenAm, $zweimal->gegessenAm);
self::assertSame(MahlzeitStatus::Gegessen, $zweimal->status);
}
public function testAktualisierenÄndertInhaltUndBelässtStatus(): void
{
$gegessen = $this->geplant()->markAsGegessen($this->jetzt());
$aktualisiert = $gegessen->aktualisieren(
name: 'Linsen',
notiz: 'scharf',
wochentage: [Wochentag::Mi],
sortOrder: $gegessen->sortOrder,
jetzt: $this->jetzt(),
);
self::assertSame('Linsen', $aktualisiert->name);
self::assertSame('scharf', $aktualisiert->notiz);
self::assertSame([Wochentag::Mi], $aktualisiert->wochentage());
self::assertSame(MahlzeitStatus::Gegessen, $aktualisiert->status);
self::assertNotNull($aktualisiert->gegessenAm);
}
public function testAktualisierenMitLeerenNameWirft(): void
{
$this->expectException(BusinessRuleViolationException::class);
$this->geplant()->aktualisieren(' ', '', [], 1, $this->jetzt());
}
public function testMitSortOrderNegativWirft(): void
{
$this->expectException(BusinessRuleViolationException::class);
$this->geplant()->mitSortOrder(-1);
}
public function testMitSortOrderÄndertNurPosition(): void
{
$m = $this->geplant()->mitSortOrder(5);
self::assertSame(5, $m->sortOrder);
self::assertSame('Spaghetti Bolognese', $m->name);
}
public function testMitIdSetztPersistenzId(): void
{
$m = $this->geplant()->mitId(42);
self::assertSame(42, $m->id);
}
public function testGegessenOhneZeitpunktWirft(): void
{
$this->expectException(BusinessRuleViolationException::class);
new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Gegessen, $this->jetzt(), $this->jetzt(), null);
}
public function testGeplanteMitGegessenAmWirft(): void
{
$this->expectException(BusinessRuleViolationException::class);
new Mahlzeit(1, 'X', '', [], 0, MahlzeitStatus::Geplant, $this->jetzt(), $this->jetzt(), $this->jetzt());
}
public function testUngültigerWochentagWirft(): void
{
$this->expectException(\TypeError::class);
Mahlzeit::anlegen('X', '', ['kein-tag'], 0, $this->jetzt());
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\Query;
use App\Logic\Common\ClockInterface;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\Query\MahlzeitInspirationQuery;
use PHPUnit\Framework\TestCase;
final class MahlzeitInspirationQueryTest extends TestCase
{
private \DateTimeImmutable $jetzt;
protected function setUp(): void
{
$this->jetzt = new \DateTimeImmutable('2026-09-18 00:00:00');
}
private function clock(): ClockInterface
{
$clock = $this->createStub(ClockInterface::class);
$clock->method('now')->willReturn($this->jetzt);
return $clock;
}
/** @param list<Mahlzeit> $mahlzeiten */
private function manager(array $mahlzeiten): MahlzeitManagerInterface
{
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('list')->with(null)->willReturn($mahlzeiten);
return $manager;
}
private function gegessen(int $id, string $name, \DateTimeImmutable $gegessenAm): Mahlzeit
{
return new Mahlzeit($id, $name, '', [Wochentag::Mo], 1, MahlzeitStatus::Gegessen, $this->jetzt, $this->jetzt, $gegessenAm);
}
private function geplant(int $id, string $name): Mahlzeit
{
return new Mahlzeit($id, $name, '', [Wochentag::Mo], 1, MahlzeitStatus::Geplant, $this->jetzt, $this->jetzt, null);
}
public function testExecuteLeereListeLiefertKeineVorschlaege(): void
{
$query = new MahlzeitInspirationQuery($this->manager([]), $this->clock());
self::assertSame([], $query->execute());
}
public function testExecuteOhneArchivEintraegeKeineVorschlaege(): void
{
// Nur `geplant` – kein Gericht im Archiv.
$query = new MahlzeitInspirationQuery($this->manager([
$this->geplant(1, 'Bolognese'),
$this->geplant(2, 'Curry'),
]), $this->clock());
self::assertSame([], $query->execute());
}
public function testExecuteSortiertAbsteigendNachScoreUndWaehltRepraesentanten(): void
{
// "Bolognese": gegessen vor 10 Tagen (id 1) + 1 geplanter Eintrag → auftraete 2,
// repraesentant = id 1.
// "Curry": gegessen vor 3 Tagen (id 3), auftraete 1.
$query = new MahlzeitInspirationQuery($this->manager([
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
$this->geplant(2, 'Bolognese'),
$this->gegessen(3, 'Curry', new \DateTimeImmutable('2026-09-15 00:00:00')),
]), $this->clock());
$vorschlaege = $query->execute();
self::assertCount(2, $vorschlaege);
self::assertSame(1, $vorschlaege[0]->id);
self::assertSame('Bolognese', $vorschlaege[0]->name);
self::assertSame(2, $vorschlaege[0]->auftraete);
self::assertSame(3, $vorschlaege[1]->id);
self::assertSame('Curry', $vorschlaege[1]->name);
self::assertSame(1, $vorschlaege[1]->auftraete);
// Staleness dominiert: das länger nicht gekochte Gericht hat den höheren Score.
self::assertGreaterThan($vorschlaege[1]->score, $vorschlaege[0]->score);
}
public function testExecuteWaehltNeuestenGegessenenEintragAlsRepraesentanten(): void
{
$query = new MahlzeitInspirationQuery($this->manager([
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-01 00:00:00')),
$this->gegessen(4, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
]), $this->clock());
$vorschlaege = $query->execute();
self::assertCount(1, $vorschlaege);
self::assertSame(4, $vorschlaege[0]->id);
self::assertEquals(new \DateTimeImmutable('2026-09-08 00:00:00'), $vorschlaege[0]->gegessenAm);
self::assertSame(2, $vorschlaege[0]->auftraete);
}
public function testExecuteAusschlussSpruengtBereitsVorgeschlageneGerichte(): void
{
$query = new MahlzeitInspirationQuery($this->manager([
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
$this->gegessen(3, 'Curry', new \DateTimeImmutable('2026-09-15 00:00:00')),
]), $this->clock());
// "Weitere 5": Bolognese (id 1) wurde bereits vorgeschlagen.
$vorschlaege = $query->execute(5, [1]);
self::assertCount(1, $vorschlaege);
self::assertSame('Curry', $vorschlaege[0]->name);
}
public function testExecuteBemaessErgebnisAnLimit(): void
{
$query = new MahlzeitInspirationQuery($this->manager([
$this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')),
$this->gegessen(2, 'Curry', new \DateTimeImmutable('2026-09-12 00:00:00')),
$this->gegessen(3, 'Pasta', new \DateTimeImmutable('2026-09-15 00:00:00')),
]), $this->clock());
$vorschlaege = $query->execute(2);
self::assertCount(2, $vorschlaege);
self::assertSame(['Bolognese', 'Curry'], array_map(static fn ($v) => $v->name, $vorschlaege));
}
public function testExecuteGleichstandEntscheidetAlphabetisch(): void
{
// Beide Gerichte: gleiche Pause, gleiche Auftritte → Score-Gleichstand.
$query = new MahlzeitInspirationQuery($this->manager([
$this->gegessen(1, 'Zwiebelsuppe', new \DateTimeImmutable('2026-09-13 00:00:00')),
$this->gegessen(2, 'Apfelkuchen', new \DateTimeImmutable('2026-09-13 00:00:00')),
]), $this->clock());
$vorschlaege = $query->execute();
self::assertSame(['Apfelkuchen', 'Zwiebelsuppe'], array_map(static fn ($v) => $v->name, $vorschlaege));
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Event\MahlzeitAdoptedEvent;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Mapping\MahlzeitFactory;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\UseCase\MahlzeitAdoptUseCase;
use PHPUnit\Framework\TestCase;
final class MahlzeitAdoptUseCaseTest extends TestCase
{
private function tx(): TransactionManagerInterface
{
$tx = $this->createStub(TransactionManagerInterface::class);
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work());
return $tx;
}
private function clock(): ClockInterface
{
$clock = $this->createStub(ClockInterface::class);
$clock->method('now')->willReturn(new \DateTimeImmutable('2026-09-18 12:00:00'));
return $clock;
}
public function testExecuteKopiertQuelleAlsNeuenGeplantEintrag(): void
{
$jetzt = new \DateTimeImmutable('2026-09-18 12:00:00');
$quelle = new Mahlzeit(7, 'Bolognese', 'mit Parmesan', [Wochentag::Mo], 3, MahlzeitStatus::Gegessen, $jetzt, $jetzt, $jetzt);
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(7)->willReturn($quelle);
$manager->expects(self::once())->method('create')->willReturnCallback(function (Mahlzeit $m) {
// Neue, geplante Kopie: gleiche Inhalte, aber ohne gegessenAm und ohne ID.
self::assertSame('Bolognese', $m->name);
self::assertSame('mit Parmesan', $m->notiz);
self::assertSame([Wochentag::Mo], $m->wochentage);
self::assertSame(MahlzeitStatus::Geplant, $m->status);
self::assertNull($m->gegessenAm);
self::assertSame(0, $m->sortOrder);
self::assertSame(0, $m->id);
// Persistenz vergibt die neue ID.
return $m->mitId(42);
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch')->with(
self::equalTo(new MahlzeitAdoptedEvent(id: 42, name: 'Bolognese', quellId: 7)),
);
$useCase = new MahlzeitAdoptUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$response = $useCase->execute(7);
self::assertSame(42, $response->id);
self::assertSame('Bolognese', $response->name);
self::assertSame(MahlzeitStatus::Geplant, $response->status);
self::assertNull($response->gegessenAm);
}
public function testExecuteUnbekannteQuelleWirft404UndLegtNichtsAn(): void
{
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->method('find')->willThrowException(new MahlzeitNotFoundException(99));
$manager->expects(self::never())->method('create');
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitAdoptUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$this->expectException(MahlzeitNotFoundException::class);
try {
$useCase->execute(99);
} finally {
// Erwartete Exception oben.
}
}
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
use PHPUnit\Framework\TestCase;
final class MahlzeitCheckOffUseCaseTest extends TestCase
{
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
/** Stub: ruft den übergebenen Closure einfach aus (simuliert die Transaktion). */
private function tx(): TransactionManagerInterface
{
$tx = $this->createStub(TransactionManagerInterface::class);
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work));
return $tx;
}
private function clock(): ClockInterface
{
$clock = $this->createStub(ClockInterface::class);
$clock->method('now')->willReturn($this->jetzt());
return $clock;
}
private function geplant(): Mahlzeit
{
$jetzt = $this->jetzt();
return new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
}
public function testExecuteMarkiertAlsGegessenUndDispatchtEvent(): void
{
$erfasstesModell = null;
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(7)->willReturn($this->geplant());
$manager->expects(self::once())->method('update')->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) {
$erfasstesModell = $m;
return $m;
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch')->with(
self::equalTo(new MahlzeitStatusChangedEvent(
id: 7,
name: 'Bolognese',
status: MahlzeitStatus::Gegessen,
gegessenAm: $this->jetzt(),
)),
);
$useCase = new MahlzeitCheckOffUseCase($this->tx(), $manager, $events, $this->clock());
$response = $useCase->execute(7);
self::assertSame(MahlzeitStatus::Gegessen, $erfasstesModell->status);
self::assertNotNull($erfasstesModell->gegessenAm);
self::assertEquals($this->jetzt(), $erfasstesModell->aktualisiertAm);
self::assertInstanceOf(MahlzeitResponse::class, $response);
self::assertSame(7, $response->id);
self::assertSame(MahlzeitStatus::Gegessen, $response->status);
self::assertNotNull($response->gegessenAm);
}
public function testExecuteIstIdempotentFuerBereitsGegessene(): void
{
$jetzt = $this->jetzt();
$bereitsGegessen = new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Gegessen, $jetzt, $jetzt, $jetzt);
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(7)->willReturn($bereitsGegessen);
$manager->expects(self::once())->method('update')->willReturnArgument(0);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch');
$useCase = new MahlzeitCheckOffUseCase($this->tx(), $manager, $events, $this->clock());
$response = $useCase->execute(7);
// Ursprünglicher gegessenAm-Zeitpunkt bleibt erhalten (markAsGegessen ist idempotent).
self::assertSame($jetzt, $response->gegessenAm);
self::assertSame(MahlzeitStatus::Gegessen, $response->status);
}
public function testExecuteWirft404UndDispatchtKeinEvent(): void
{
$manager = $this->createStub(MahlzeitManagerInterface::class);
$manager->method('find')->willThrowException(new MahlzeitNotFoundException(99));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitCheckOffUseCase($this->tx(), $manager, $events, $this->clock());
$this->expectException(MahlzeitNotFoundException::class);
try {
$useCase->execute(99);
} finally {
// Erwartete Exception oben.
}
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitCreateResponse;
use App\Logic\Essen\Event\MahlzeitCreatedEvent;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Mapping\MahlzeitFactory;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
use PHPUnit\Framework\TestCase;
final class MahlzeitCreateUseCaseTest extends TestCase
{
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
private function mahlzeitMitId(int $id, string $name): Mahlzeit
{
$jetzt = $this->jetzt();
return new Mahlzeit($id, $name, '', [], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
}
/** Stub: ruft den übergebenen Closure einfach aus (simuliert die Transaktion). */
private function tx(): TransactionManagerInterface
{
$tx = $this->createStub(TransactionManagerInterface::class);
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work));
return $tx;
}
private function clock(): ClockInterface
{
$clock = $this->createStub(ClockInterface::class);
$clock->method('now')->willReturn($this->jetzt());
return $clock;
}
public function testExecutePersistiertUndLiefertResponse(): void
{
$gespeichert = $this->mahlzeitMitId(10, 'Bolognese');
$erfasstesModell = null;
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())
->method('create')
->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell, $gespeichert) {
$erfasstesModell = $m;
return $gespeichert;
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())
->method('dispatch')
->with(self::equalTo(new MahlzeitCreatedEvent(id: 10, name: 'Bolognese')));
$useCase = new MahlzeitCreateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$response = $useCase->execute(new MahlzeitCreateRequest(name: 'Bolognese', notiz: 'süß'));
self::assertSame('Bolognese', $erfasstesModell->name, 'Übergebenes Modell trägt den Namen aus dem Request');
self::assertSame('süß', $erfasstesModell->notiz);
self::assertInstanceOf(MahlzeitCreateResponse::class, $response);
self::assertSame(10, $response->id);
self::assertSame('Bolognese', $response->mahlzeit->name);
}
public function testExecuteDispatchtEventNachCommit(): void
{
$mahlzeit = $this->mahlzeitMitId(1, 'X');
$manager = $this->createStub(MahlzeitManagerInterface::class);
$manager->method('create')->willReturn($mahlzeit);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())
->method('dispatch')
->with(self::equalTo(new MahlzeitCreatedEvent(id: 1, name: 'X')));
$useCase = new MahlzeitCreateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$useCase->execute(new MahlzeitCreateRequest(name: 'X'));
}
public function testExecuteDispatchtKeinEventBeiFehler(): void
{
$manager = $this->createStub(MahlzeitManagerInterface::class);
$manager->method('create')->willThrowException(new \RuntimeException('DB down'));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitCreateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$this->expectException(\RuntimeException::class);
try {
$useCase->execute(new MahlzeitCreateRequest(name: 'X'));
} finally {
// Erwartete Exception oben.
}
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\UseCase;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Event\MahlzeitDeletedEvent;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
use PHPUnit\Framework\TestCase;
final class MahlzeitDeleteUseCaseTest extends TestCase
{
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
/** Stub: ruft den übergebenen Closure einfach aus (simuliert die Transaktion). */
private function tx(): TransactionManagerInterface
{
$tx = $this->createStub(TransactionManagerInterface::class);
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work));
return $tx;
}
public function testExecuteLöschtUndDispatchtEvent(): void
{
$jetzt = $this->jetzt();
$mahlzeit = new Mahlzeit(7, 'Bolognese', '', [], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(7)->willReturn($mahlzeit);
$manager->expects(self::once())->method('delete')->with(7);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())
->method('dispatch')
->with(self::equalTo(new MahlzeitDeletedEvent(id: 7, name: 'Bolognese')));
$useCase = new MahlzeitDeleteUseCase($this->tx(), $manager, $events);
$useCase->execute(7);
}
public function testExecuteDispatchtKeinEventBeiFehler(): void
{
$manager = $this->createStub(MahlzeitManagerInterface::class);
$manager->method('find')->willThrowException(new MahlzeitNotFoundException(99));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitDeleteUseCase($this->tx(), $manager, $events);
$this->expectException(MahlzeitNotFoundException::class);
try {
$useCase->execute(99);
} finally {
// Erwartete Exception oben.
}
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\UseCase;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
use App\Logic\Essen\Event\MahlzeitReorderedEvent;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\UseCase\MahlzeitReorderUseCase;
use PHPUnit\Framework\TestCase;
final class MahlzeitReorderUseCaseTest extends TestCase
{
private function tx(): TransactionManagerInterface
{
$tx = $this->createStub(TransactionManagerInterface::class);
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work());
return $tx;
}
private function mahlzeit(int $id, int $sortOrder): Mahlzeit
{
$jetzt = new \DateTimeImmutable('2026-09-18 12:00:00');
return new Mahlzeit($id, 'Gericht' . $id, '', [], $sortOrder, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
}
public function testExecuteRenommertSortOrderUndDispatchtEvent(): void
{
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->method('find')->willReturnMap([
[2, $this->mahlzeit(2, 2)],
[1, $this->mahlzeit(1, 1)],
[3, $this->mahlzeit(3, 3)],
]);
$gespeicherte = [];
$manager->expects(self::exactly(2))->method('update')->willReturnCallback(function (Mahlzeit $m) use (&$gespeicherte) {
$gespeicherte[$m->id] = $m->sortOrder;
return $m;
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch')->with(
self::equalTo(new MahlzeitReorderedEvent([2, 1, 3])),
);
$useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events);
$useCase->execute(new MahlzeitReorderRequest([2, 1, 3]));
// id 2 → Position 1, id 1 → Position 2; id 3 (Position 3) bleibt unverändert.
self::assertSame([2 => 1, 1 => 2], $gespeicherte);
}
public function testExecuteSpruengtBereitsKorrektePosition(): void
{
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->method('find')->willReturnMap([
[1, $this->mahlzeit(1, 1)],
[2, $this->mahlzeit(2, 2)],
]);
$manager->expects(self::never())->method('update');
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch');
$useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events);
$useCase->execute(new MahlzeitReorderRequest([1, 2]));
}
public function testExecuteLeereListeIstNoOp(): void
{
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::never())->method('find');
$manager->expects(self::never())->method('update');
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events);
$useCase->execute(new MahlzeitReorderRequest([]));
}
public function testExecuteUnbekannteIdWirft404(): void
{
$manager = $this->createStub(MahlzeitManagerInterface::class);
$manager->method('find')->willThrowException(new MahlzeitNotFoundException(99));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events);
$this->expectException(MahlzeitNotFoundException::class);
try {
$useCase->execute(new MahlzeitReorderRequest([99]));
} finally {
// Erwartete Exception oben.
}
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Event\MahlzeitStatusChangedEvent;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
use PHPUnit\Framework\TestCase;
final class MahlzeitUncheckUseCaseTest extends TestCase
{
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
/** Stub: ruft den übergebenen Closure einfach aus (simuliert die Transaktion). */
private function tx(): TransactionManagerInterface
{
$tx = $this->createStub(TransactionManagerInterface::class);
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work));
return $tx;
}
private function clock(): ClockInterface
{
$clock = $this->createStub(ClockInterface::class);
$clock->method('now')->willReturn($this->jetzt());
return $clock;
}
private function gegessen(): Mahlzeit
{
$jetzt = $this->jetzt();
return new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Gegessen, $jetzt, $jetzt, $jetzt);
}
public function testExecuteSetztZurueckAufGeplantUndDispatchtEvent(): void
{
$erfasstesModell = null;
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(7)->willReturn($this->gegessen());
$manager->expects(self::once())->method('update')->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) {
$erfasstesModell = $m;
return $m;
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch')->with(
self::equalTo(new MahlzeitStatusChangedEvent(
id: 7,
name: 'Bolognese',
status: MahlzeitStatus::Geplant,
gegessenAm: null,
)),
);
$useCase = new MahlzeitUncheckUseCase($this->tx(), $manager, $events, $this->clock());
$response = $useCase->execute(7);
self::assertSame(MahlzeitStatus::Geplant, $erfasstesModell->status);
self::assertNull($erfasstesModell->gegessenAm, 'gegessenAm wird zurückgesetzt');
self::assertInstanceOf(MahlzeitResponse::class, $response);
self::assertSame(7, $response->id);
self::assertSame(MahlzeitStatus::Geplant, $response->status);
self::assertNull($response->gegessenAm);
}
public function testExecuteIstIdempotentFuerBereitsGeplante(): void
{
$jetzt = $this->jetzt();
$bereitsGeplant = new Mahlzeit(7, 'Bolognese', '', [Wochentag::Mo], 1, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(7)->willReturn($bereitsGeplant);
$manager->expects(self::once())->method('update')->willReturnArgument(0);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch');
$useCase = new MahlzeitUncheckUseCase($this->tx(), $manager, $events, $this->clock());
$response = $useCase->execute(7);
self::assertSame(MahlzeitStatus::Geplant, $response->status);
self::assertNull($response->gegessenAm);
}
public function testExecuteWirft404UndDispatchtKeinEvent(): void
{
$manager = $this->createStub(MahlzeitManagerInterface::class);
$manager->method('find')->willThrowException(new MahlzeitNotFoundException(99));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitUncheckUseCase($this->tx(), $manager, $events, $this->clock());
$this->expectException(MahlzeitNotFoundException::class);
try {
$useCase->execute(99);
} finally {
// Erwartete Exception oben.
}
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Logic\Essen\UseCase;
use App\Logic\Common\ClockInterface;
use App\Logic\Common\EventDispatcherInterface;
use App\Logic\Common\TransactionManagerInterface;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Dto\MahlzeitUpdateResponse;
use App\Logic\Essen\Event\MahlzeitUpdatedEvent;
use App\Logic\Essen\Exception\MahlzeitNotFoundException;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Mapping\MahlzeitFactory;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
use PHPUnit\Framework\TestCase;
final class MahlzeitUpdateUseCaseTest extends TestCase
{
private function jetzt(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-09-18 12:00:00');
}
/** Stub: ruft den übergebenen Closure einfach aus (simuliert die Transaktion). */
private function tx(): TransactionManagerInterface
{
$tx = $this->createStub(TransactionManagerInterface::class);
$tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work($work));
return $tx;
}
private function clock(): ClockInterface
{
$clock = $this->createStub(ClockInterface::class);
$clock->method('now')->willReturn($this->jetzt());
return $clock;
}
private function bestehend(): Mahlzeit
{
$jetzt = $this->jetzt();
return new Mahlzeit(5, 'Altes', 'alte Notiz', [Wochentag::Mo, Wochentag::Di], 3, MahlzeitStatus::Geplant, $jetzt, $jetzt, null);
}
public function testExecuteAktualisiertNameNotizUndWochentage(): void
{
$erfasstesModell = null;
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(5)->willReturn($this->bestehend());
$manager->expects(self::once())
->method('update')
->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) {
$erfasstesModell = $m;
return $m;
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())
->method('dispatch')
->with(self::equalTo(new MahlzeitUpdatedEvent(id: 5, name: 'Neues')));
$useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$response = $useCase->execute(5, new MahlzeitUpdateRequest(name: 'Neues', notiz: 'neu', wochentage: [Wochentag::Fr]));
self::assertSame('Neues', $erfasstesModell->name);
self::assertSame('neu', $erfasstesModell->notiz);
self::assertSame([Wochentag::Fr], $erfasstesModell->wochentage());
self::assertInstanceOf(MahlzeitUpdateResponse::class, $response);
self::assertSame(5, $response->id);
self::assertSame('Neues', $response->mahlzeit->name);
}
public function testExecuteLässtUndefinierteFelderUnerändert(): void
{
$alt = $this->bestehend();
$erfasstesModell = null;
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(5)->willReturn($alt);
$manager->expects(self::once())
->method('update')
->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) {
$erfasstesModell = $m;
return $m;
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch');
$useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$useCase->execute(5, new MahlzeitUpdateRequest(name: null, notiz: null, wochentage: null));
self::assertSame('Altes', $erfasstesModell->name, 'nicht geliefertes Feld bleibt unverändert');
self::assertSame('alte Notiz', $erfasstesModell->notiz);
self::assertSame([Wochentag::Mo, Wochentag::Di], $erfasstesModell->wochentage());
}
public function testExecuteLeertWochentageMitEmptyArray(): void
{
$erfasstesModell = null;
$manager = $this->createMock(MahlzeitManagerInterface::class);
$manager->expects(self::once())->method('find')->with(5)->willReturn($this->bestehend());
$manager->expects(self::once())
->method('update')
->willReturnCallback(function (Mahlzeit $m) use (&$erfasstesModell) {
$erfasstesModell = $m;
return $m;
});
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())->method('dispatch');
$useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$useCase->execute(5, new MahlzeitUpdateRequest(wochentage: []));
self::assertSame([], $erfasstesModell->wochentage());
}
public function testExecuteWirft404WennNichtVorhanden(): void
{
$manager = $this->createStub(MahlzeitManagerInterface::class);
$manager->method('find')->willThrowException(new MahlzeitNotFoundException(99));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
$useCase = new MahlzeitUpdateUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock());
$this->expectException(MahlzeitNotFoundException::class);
try {
$useCase->execute(99, new MahlzeitUpdateRequest(name: 'X'));
} finally {
// Erwartete Exception oben.
}
}
}