From b09931997b80b363e8dfd2052a55a71fc6b13a46 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Wed, 3 Jun 2026 12:59:36 +0200 Subject: [PATCH] Bestellungen: PrintOrder/OrderItem + OrderController (Backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrintOrder (company, status-Workflow new/in_production/shipped/completed/ cancelled, number, createdBy, items) + OrderItem (product, employee, quantity) - OrderController: GET Liste (scoped: Firma eigene / Reseller alle seiner Firmen / Plattform alle), GET Detail (inkl. PDF-Link je Position), POST anlegen (Firmen-Admin), PATCH /status (Reseller wickelt ab / Firma storniert solange neu). Produkt-/Mitarbeiter-Sichtbarkeit geprüft. - Migration + Demo-Bestellung (Muster: 100 Visitenkarten + 10 NFC). KONZEPT §13. Co-Authored-By: Claude Opus 4.8 --- backend/migrations/Version20260603105617.php | 43 +++ backend/src/Command/SeedCommand.php | 21 +- backend/src/Controller/OrderController.php | 246 ++++++++++++++++++ backend/src/Entity/OrderItem.php | 92 +++++++ backend/src/Entity/PrintOrder.php | 164 ++++++++++++ .../src/Repository/OrderItemRepository.php | 18 ++ .../src/Repository/PrintOrderRepository.php | 18 ++ docs/KONZEPT.md | 30 +++ 8 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/Version20260603105617.php create mode 100644 backend/src/Controller/OrderController.php create mode 100644 backend/src/Entity/OrderItem.php create mode 100644 backend/src/Entity/PrintOrder.php create mode 100644 backend/src/Repository/OrderItemRepository.php create mode 100644 backend/src/Repository/PrintOrderRepository.php diff --git a/backend/migrations/Version20260603105617.php b/backend/migrations/Version20260603105617.php new file mode 100644 index 0000000..26a76f3 --- /dev/null +++ b/backend/migrations/Version20260603105617.php @@ -0,0 +1,43 @@ +addSql('CREATE TABLE order_item (id BINARY(16) NOT NULL, quantity INT NOT NULL, order_id BINARY(16) NOT NULL, product_id BINARY(16) NOT NULL, employee_id BINARY(16) NOT NULL, INDEX IDX_52EA1F098D9F6D38 (order_id), INDEX IDX_52EA1F094584665A (product_id), INDEX IDX_52EA1F098C03F15C (employee_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE print_order (id BINARY(16) NOT NULL, number VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, note LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) NOT NULL, created_by_id BINARY(16) DEFAULT NULL, INDEX IDX_844C1953979B1AD6 (company_id), INDEX IDX_844C1953B03A8386 (created_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F098D9F6D38 FOREIGN KEY (order_id) REFERENCES print_order (id)'); + $this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F094584665A FOREIGN KEY (product_id) REFERENCES product (id)'); + $this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F098C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)'); + $this->addSql('ALTER TABLE print_order ADD CONSTRAINT FK_844C1953979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)'); + $this->addSql('ALTER TABLE print_order ADD CONSTRAINT FK_844C1953B03A8386 FOREIGN KEY (created_by_id) REFERENCES employee (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F098D9F6D38'); + $this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F094584665A'); + $this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F098C03F15C'); + $this->addSql('ALTER TABLE print_order DROP FOREIGN KEY FK_844C1953979B1AD6'); + $this->addSql('ALTER TABLE print_order DROP FOREIGN KEY FK_844C1953B03A8386'); + $this->addSql('DROP TABLE order_item'); + $this->addSql('DROP TABLE print_order'); + } +} diff --git a/backend/src/Command/SeedCommand.php b/backend/src/Command/SeedCommand.php index 8245f3f..5e229b0 100644 --- a/backend/src/Command/SeedCommand.php +++ b/backend/src/Command/SeedCommand.php @@ -6,7 +6,9 @@ use App\Entity\Company; use App\Entity\ContactLink; use App\Entity\Employee; use App\Entity\Location; +use App\Entity\OrderItem; use App\Entity\PlatformPlan; +use App\Entity\PrintOrder; use App\Entity\Product; use App\Entity\Reseller; use Doctrine\ORM\EntityManagerInterface; @@ -42,9 +44,9 @@ final class SeedCommand extends Command $this->em->persist($plan); // Globaler Produktkatalog (Plattform-Produkte, für alle Reseller sichtbar) - $this->product(null, Product::KIND_BUSINESS_CARD, 'Visitenkarte', 85.0, 55.0, 2.0, 4.0, 2, false, 0); + $card = $this->product(null, Product::KIND_BUSINESS_CARD, 'Visitenkarte', 85.0, 55.0, 2.0, 4.0, 2, false, 0); $this->product(null, Product::KIND_NAME_TAG, 'Namensschild', 90.0, 55.0, 0.0, 3.0, 1, false, 1); - $this->product(null, Product::KIND_NFC_CARD, 'NFC-Karte', 85.6, 54.0, 2.0, 4.0, 2, true, 2); + $nfc = $this->product(null, Product::KIND_NFC_CARD, 'NFC-Karte', 85.6, 54.0, 2.0, 4.0, 2, true, 2); // Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins [$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true); @@ -56,7 +58,7 @@ final class SeedCommand extends Command // Reseller-eigenes Produkt (nur im Mandanten der Demo Druckerei sichtbar) $this->product($demo, Product::KIND_BUSINESS_CARD, 'Premium-Visitenkarte 90×50', 90.0, 50.0, 3.0, 4.0, 2, true, 0); $this->staff($dOrg, 'Demo', 'Reseller', 'reseller@demo.de', 'reseller', Employee::ROLE_RESELLER_ADMIN); - $this->customer($demo, 'Muster GmbH', 'muster', 'firma@muster.de', 'firma', 'Erika', 'Mustermann'); + $this->customer($demo, 'Muster GmbH', 'muster', 'firma@muster.de', 'firma', 'Erika', 'Mustermann', $card, $nfc); // Reseller „Print Studio" [$ps, $psOrg] = $this->reseller('Print Studio', 'printstudio', $plan, false); @@ -108,7 +110,7 @@ final class SeedCommand extends Command return $e; } - private function customer(Reseller $reseller, string $companyName, string $companySlug, string $adminEmail, string $adminPw, string $first, string $last): void + private function customer(Reseller $reseller, string $companyName, string $companySlug, string $adminEmail, string $adminPw, string $first, string $last, ?Product $card = null, ?Product $nfc = null): void { $company = (new Company())->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true); $this->em->persist($company); @@ -118,7 +120,7 @@ final class SeedCommand extends Command $this->em->persist($location); // Firmen-Admin (Mitarbeiter mit Login) - $this->staff($company, 'Firmen', 'Admin', $adminEmail, $adminPw, Employee::ROLE_COMPANY_ADMIN) + $admin = $this->staff($company, 'Firmen', 'Admin', $adminEmail, $adminPw, Employee::ROLE_COMPANY_ADMIN) ->setSlug('firmen-admin'); // Öffentliches Profil-Beispiel (ohne Login) @@ -131,5 +133,14 @@ final class SeedCommand extends Command $link = (new ContactLink())->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($first))->setPosition(0); $employee->addContactLink($link); $this->em->persist($link); + + // Demo-Bestellung: 100 Visitenkarten (Profil) + 10 NFC-Karten (Admin) + if (null !== $card && null !== $nfc) { + $order = (new PrintOrder())->setCompany($company)->setCreatedBy($admin) + ->setNote('Erstausstattung neues Team'); + $order->addItem((new OrderItem())->setProduct($card)->setEmployee($employee)->setQuantity(100)); + $order->addItem((new OrderItem())->setProduct($nfc)->setEmployee($admin)->setQuantity(10)); + $this->em->persist($order); + } } } diff --git a/backend/src/Controller/OrderController.php b/backend/src/Controller/OrderController.php new file mode 100644 index 0000000..a1d1be2 --- /dev/null +++ b/backend/src/Controller/OrderController.php @@ -0,0 +1,246 @@ +orders->createQueryBuilder('o') + ->join('o.company', 'c')->addSelect('c') + ->orderBy('o.createdAt', 'DESC'); + + if (!$this->tenant->isPlatformAdmin()) { + $reseller = $this->tenant->getReseller(); + if (null === $reseller) { + return new JsonResponse(['member' => []]); + } + $qb->andWhere('c.reseller = :r')->setParameter('r', $reseller->getId(), 'uuid'); + $company = $this->tenant->getCompany(); + if (null !== $company) { + $qb->andWhere('c = :company')->setParameter('company', $company->getId(), 'uuid'); + } + } + + $member = array_map(fn (PrintOrder $o) => $this->serializeSummary($o), $qb->getQuery()->getResult()); + + return new JsonResponse(['member' => $member, 'totalItems' => count($member)]); + } + + #[Route('/api/orders/{id}', name: 'orders_get', methods: ['GET'])] + public function get(string $id): JsonResponse + { + return new JsonResponse($this->serializeDetail($this->order($id))); + } + + #[Route('/api/orders', name: 'orders_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $company = $this->resolveCompany($data['company'] ?? null); + + $rawItems = is_array($data['items'] ?? null) ? $data['items'] : []; + if (0 === count($rawItems)) { + throw new BadRequestHttpException('Bestellung enthält keine Positionen.'); + } + + $order = (new PrintOrder())->setCompany($company) + ->setNote(isset($data['note']) ? trim((string) $data['note']) ?: null : null) + ->setCreatedBy($this->tenant->getEmployee()); + + foreach ($rawItems as $row) { + $product = $this->product((string) ($row['product'] ?? '')); + $employee = $this->employeeInCompany((string) ($row['employee'] ?? ''), $company); + $qty = (int) ($row['quantity'] ?? 0); + if ($qty < 1) { + throw new BadRequestHttpException('Menge muss mindestens 1 sein.'); + } + $item = (new OrderItem())->setProduct($product)->setEmployee($employee)->setQuantity($qty); + $order->addItem($item); + } + + $this->em->persist($order); + $this->em->flush(); + + return new JsonResponse($this->serializeDetail($order), 201); + } + + #[Route('/api/orders/{id}/status', name: 'orders_status', methods: ['PATCH'])] + public function status(string $id, Request $request): JsonResponse + { + $order = $this->order($id); + $data = json_decode($request->getContent(), true) ?? []; + $target = (string) ($data['status'] ?? ''); + if (!in_array($target, PrintOrder::STATUSES, true)) { + throw new BadRequestHttpException('Ungültiger Status.'); + } + + $isFulfiller = null === $this->tenant->getCompany(); // Reseller-/Plattform-Admin + if (PrintOrder::STATUS_CANCELLED === $target) { + // Firma darf nur stornieren, solange „neu"; Reseller/Plattform jederzeit + if (!$isFulfiller && PrintOrder::STATUS_NEW !== $order->getStatus()) { + throw new AccessDeniedHttpException('Stornieren nur möglich, solange die Bestellung neu ist.'); + } + } elseif (!$isFulfiller) { + throw new AccessDeniedHttpException('Status wird vom Reseller (Druckshop) gesetzt.'); + } + + $order->setStatus($target); + $this->em->flush(); + + return new JsonResponse($this->serializeDetail($order)); + } + + // --- Hilfen --- + + private function order(string $id): PrintOrder + { + $order = $this->orders->find(Uuid::fromString($id)); + if (!$order instanceof PrintOrder) { + throw new NotFoundHttpException('Bestellung nicht gefunden.'); + } + $this->assertScope($order->getCompany()); + + return $order; + } + + /** Firma der Bestellung: Firmen-Admin = eigene; Reseller/Plattform = aus Body. */ + private function resolveCompany(?string $companyId): Company + { + $own = $this->tenant->getCompany(); + if (null !== $own) { + return $own; + } + if (null === $companyId || '' === $companyId) { + throw new BadRequestHttpException('Firma (company) erforderlich.'); + } + $company = $this->em->getRepository(Company::class)->find(Uuid::fromString($companyId)); + if (!$company instanceof Company) { + throw new NotFoundHttpException('Firma nicht gefunden.'); + } + $this->assertScope($company); + + return $company; + } + + private function assertScope(Company $company): void + { + if ($this->tenant->isPlatformAdmin()) { + return; + } + $reseller = $this->tenant->getReseller(); + if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) { + throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.'); + } + $own = $this->tenant->getCompany(); + if (null !== $own && !$company->getId()->equals($own->getId())) { + throw new AccessDeniedHttpException('Nur die eigene Firma.'); + } + } + + private function product(string $id): Product + { + $product = '' !== $id ? $this->products->find(Uuid::fromString($id)) : null; + if (!$product instanceof Product) { + throw new NotFoundHttpException('Produkt nicht gefunden.'); + } + if ($this->tenant->isPlatformAdmin() || $product->isGlobal()) { + return $product; + } + $reseller = $this->tenant->getReseller(); + if (null === $reseller || $product->getReseller()?->getId()->equals($reseller->getId()) !== true) { + throw new AccessDeniedHttpException('Produkt im eigenen Mandanten nicht verfügbar.'); + } + + return $product; + } + + private function employeeInCompany(string $id, Company $company): Employee + { + $employee = '' !== $id ? $this->employees->find(Uuid::fromString($id)) : null; + if (!$employee instanceof Employee) { + throw new NotFoundHttpException('Mitarbeiter nicht gefunden.'); + } + if (!$employee->getCompany()->getId()->equals($company->getId())) { + throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zur bestellenden Firma.'); + } + + return $employee; + } + + /** @return array */ + private function serializeSummary(PrintOrder $o): array + { + return [ + 'id' => (string) $o->getId(), + 'number' => $o->getNumber(), + 'status' => $o->getStatus(), + 'company' => ['id' => (string) $o->getCompany()->getId(), 'name' => $o->getCompany()->getName()], + 'itemCount' => $o->getItems()->count(), + 'totalQuantity' => $o->getTotalQuantity(), + 'createdAt' => $o->getCreatedAt()->format(\DATE_ATOM), + ]; + } + + /** @return array */ + private function serializeDetail(PrintOrder $o): array + { + $items = []; + foreach ($o->getItems() as $item) { + $emp = $item->getEmployee(); + $items[] = [ + 'id' => (string) $item->getId(), + 'product' => ['id' => (string) $item->getProduct()->getId(), 'name' => $item->getProduct()->getName(), 'kind' => $item->getProduct()->getKind()], + 'employee' => ['id' => (string) $emp->getId(), 'name' => trim($emp->getFirstName().' '.$emp->getLastName())], + 'quantity' => $item->getQuantity(), + 'pdfUrl' => sprintf('/api/employees/%s/card.pdf?product=%s', $emp->getId(), $item->getProduct()->getId()), + ]; + } + + return [ + 'id' => (string) $o->getId(), + 'number' => $o->getNumber(), + 'status' => $o->getStatus(), + 'note' => $o->getNote(), + 'company' => ['id' => (string) $o->getCompany()->getId(), 'name' => $o->getCompany()->getName()], + 'createdBy' => $o->getCreatedBy() ? trim($o->getCreatedBy()->getFirstName().' '.$o->getCreatedBy()->getLastName()) : null, + 'createdAt' => $o->getCreatedAt()->format(\DATE_ATOM), + 'totalQuantity' => $o->getTotalQuantity(), + 'items' => $items, + ]; + } +} diff --git a/backend/src/Entity/OrderItem.php b/backend/src/Entity/OrderItem.php new file mode 100644 index 0000000..c8e40c6 --- /dev/null +++ b/backend/src/Entity/OrderItem.php @@ -0,0 +1,92 @@ +id = Uuid::v7(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getOrder(): PrintOrder + { + return $this->order; + } + + public function setOrder(PrintOrder $order): self + { + $this->order = $order; + + return $this; + } + + public function getProduct(): Product + { + return $this->product; + } + + public function setProduct(Product $product): self + { + $this->product = $product; + + return $this; + } + + public function getEmployee(): Employee + { + return $this->employee; + } + + public function setEmployee(Employee $employee): self + { + $this->employee = $employee; + + return $this; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function setQuantity(int $quantity): self + { + $this->quantity = max(1, $quantity); + + return $this; + } +} diff --git a/backend/src/Entity/PrintOrder.php b/backend/src/Entity/PrintOrder.php new file mode 100644 index 0000000..eaae164 --- /dev/null +++ b/backend/src/Entity/PrintOrder.php @@ -0,0 +1,164 @@ + */ + #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $items; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + public function __construct() + { + $this->id = Uuid::v7(); + $this->createdAt = new \DateTimeImmutable(); + $this->items = new ArrayCollection(); + $this->number = 'B-'.strtoupper(bin2hex(random_bytes(3))); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getNumber(): string + { + return $this->number; + } + + public function getCompany(): Company + { + return $this->company; + } + + public function setCompany(Company $company): self + { + $this->company = $company; + + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): self + { + $this->status = $status; + + return $this; + } + + public function getNote(): ?string + { + return $this->note; + } + + public function setNote(?string $note): self + { + $this->note = $note; + + return $this; + } + + public function getCreatedBy(): ?Employee + { + return $this->createdBy; + } + + public function setCreatedBy(?Employee $createdBy): self + { + $this->createdBy = $createdBy; + + return $this; + } + + /** @return Collection */ + public function getItems(): Collection + { + return $this->items; + } + + public function addItem(OrderItem $item): self + { + if (!$this->items->contains($item)) { + $this->items->add($item); + $item->setOrder($this); + } + + return $this; + } + + /** Gesamtauflage aller Positionen. */ + public function getTotalQuantity(): int + { + $sum = 0; + foreach ($this->items as $item) { + $sum += $item->getQuantity(); + } + + return $sum; + } + + public function getReseller(): ?Reseller + { + return $this->company->getReseller(); + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/backend/src/Repository/OrderItemRepository.php b/backend/src/Repository/OrderItemRepository.php new file mode 100644 index 0000000..0a7492f --- /dev/null +++ b/backend/src/Repository/OrderItemRepository.php @@ -0,0 +1,18 @@ + + */ +class OrderItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OrderItem::class); + } +} diff --git a/backend/src/Repository/PrintOrderRepository.php b/backend/src/Repository/PrintOrderRepository.php new file mode 100644 index 0000000..bd7de5f --- /dev/null +++ b/backend/src/Repository/PrintOrderRepository.php @@ -0,0 +1,18 @@ + + */ +class PrintOrderRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PrintOrder::class); + } +} diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index 15e12cc..e4f0608 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -393,6 +393,36 @@ einer Firma für ein Produkt** (eine Firma kann je Produkt ein Design haben). Fo - `nfc_card`: zusätzlich NFC-Programmierung über die bestehende `shortCode`/`/t/`-Infra (Tag schreibt die Kurz-URL des Profils) — Detail in §12/§14. +### Bestellungen (PrintOrder) + +Firmen-Admins **bestellen** Produkte für ihre Mitarbeiter; der Reseller (Druckshop) +**wickelt ab** (produziert, versendet). Beispiel: *10 Visitenkarten + 5 NFC-Karten für +verschiedene Mitarbeiter*. + +**Datenmodell:** + +- **`PrintOrder`** (`ResellerOwnedInterface` via `company.reseller`): `number` + (kurze Bestellnr.), `company`, `status`, `note`, `createdBy` (Employee), `createdAt`, + `items[]`. +- **`OrderItem`**: `product`, `employee` (für wen — personalisiert), `quantity` (Auflage). + +**Status-Workflow:** `new` → `in_production` → `shipped` → `completed`; `cancelled` quer. +- Firmen-Admin: legt Bestellung an (`new`), kann sie **stornieren** solange `new`. +- Reseller-/Plattform-Admin: schiebt den Status vorwärts (Produktion/Versand/erledigt), + kann jederzeit stornieren. + +**Druckdaten:** je Position liefert das bestehende `GET /api/employees/{id}/card.pdf?product={productId}` +das druckfertige PDF (Mitarbeiter × Produkt). Sammel-/Bogen-PDF später. + +**Endpunkte (`OrderController`, mandantengeprüft):** +- `GET /api/orders` — Liste (Firma: eigene; Reseller: alle seiner Firmen; Plattform: alle). +- `GET /api/orders/{id}` — Detail inkl. Positionen + PDF-Links. +- `POST /api/orders` — anlegen (Firmen-Admin; `items[]` = Produkt+Mitarbeiter+Menge). +- `PATCH /api/orders/{id}/status` — Status setzen (Reseller wickelt ab / Firma storniert). + +**Sichtbarkeit:** Produkt muss im Mandanten sichtbar sein (global oder eigener Reseller), +Mitarbeiter muss zur bestellenden Firma gehören. Preise/Abrechnung = spätere Ausbaustufe. + --- ## 14. Druckdaten: Visitenkarten-PDF (Kerngeschäft)