Bestellungen: PrintOrder/OrderItem + OrderController (Backend)

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-03 12:59:36 +02:00
parent 6dd6d3a96e
commit b09931997b
8 changed files with 627 additions and 5 deletions

View File

@ -0,0 +1,43 @@
<?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 Version20260603105617 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 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');
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,246 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\OrderItem;
use App\Entity\PrintOrder;
use App\Entity\Product;
use App\Repository\EmployeeRepository;
use App\Repository\PrintOrderRepository;
use App\Repository\ProductRepository;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Bestellungen/Druckaufträge: Firma bestellt Produkte je Mitarbeiter,
* Reseller wickelt ab. Mandantengeprüft (KONZEPT §13 Bestellungen).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class OrderController
{
public function __construct(
private readonly PrintOrderRepository $orders,
private readonly ProductRepository $products,
private readonly EmployeeRepository $employees,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/orders', name: 'orders_list', methods: ['GET'])]
public function list(): JsonResponse
{
$qb = $this->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<string, mixed> */
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<string, mixed> */
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,
];
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Entity;
use App\Repository\OrderItemRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Eine Position einer Bestellung: ein Produkt für einen Mitarbeiter in bestimmter Auflage.
*/
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
class OrderItem
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: PrintOrder::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false)]
private PrintOrder $order;
#[ORM\ManyToOne(targetEntity: Product::class)]
#[ORM\JoinColumn(nullable: false)]
private Product $product;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
private Employee $employee;
#[ORM\Column(type: 'integer')]
private int $quantity = 1;
public function __construct()
{
$this->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;
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace App\Entity;
use App\Repository\PrintOrderRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Bestellung/Druckauftrag einer Firma (KONZEPT §13 Bestellungen).
* Die Firma bestellt Produkte je Mitarbeiter; der Reseller wickelt ab.
*/
#[ORM\Entity(repositoryClass: PrintOrderRepository::class)]
#[ORM\Table(name: 'print_order')]
class PrintOrder implements ResellerOwnedInterface
{
public const STATUS_NEW = 'new';
public const STATUS_IN_PRODUCTION = 'in_production';
public const STATUS_SHIPPED = 'shipped';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUSES = [
self::STATUS_NEW,
self::STATUS_IN_PRODUCTION,
self::STATUS_SHIPPED,
self::STATUS_COMPLETED,
self::STATUS_CANCELLED,
];
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 20)]
private string $number;
#[ORM\ManyToOne(targetEntity: Company::class)]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_NEW;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $note = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Employee $createdBy = null;
/** @var Collection<int, OrderItem> */
#[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<int, OrderItem> */
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;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\OrderItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<OrderItem>
*/
class OrderItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, OrderItem::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\PrintOrder;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PrintOrder>
*/
class PrintOrderRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PrintOrder::class);
}
}

View File

@ -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)