From f5807aefcebf83e05eff8484d32806a24e0e477a Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Tue, 2 Jun 2026 15:20:52 +0200 Subject: [PATCH] Produkte: Produktkatalog-Backend (Visitenkarte/Namensschild/NFC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product-Entität (reseller=null=global, kind, Format-Defaults, sides, nfc/print flags, active, sortOrder) als ResellerOwnedInterface - Sichtbarkeit: global + eigener Reseller (TenantExtension-Sonderfall), Schreiben nur Eigentümer (ProductVoter PRODUCT_EDIT), Reseller-Stamping - CardTemplate koppelt an Product (Design je Firma+Produkt); Editor-, Asset- und PDF-Controller produktbewusst (?product=) - Seed: 3 globale Produkte + 1 Reseller-eigenes; KONZEPT §13 Produktkatalog Co-Authored-By: Claude Opus 4.8 --- backend/migrations/Version20260602123707.php | 39 +++ backend/src/Command/RenderCardCommand.php | 3 +- backend/src/Command/SeedCommand.php | 18 ++ .../Controller/CardAssetUploadController.php | 54 +++- backend/src/Controller/CardPdfController.php | 17 +- .../CardTemplateEditorController.php | 78 ++++- backend/src/Doctrine/TenantExtension.php | 9 + backend/src/Entity/CardTemplate.php | 16 ++ backend/src/Entity/Product.php | 272 ++++++++++++++++++ .../src/Repository/CardTemplateRepository.php | 7 +- backend/src/Repository/ProductRepository.php | 18 ++ backend/src/Security/Voter/ProductVoter.php | 43 +++ backend/src/State/TenantStampProcessor.php | 4 + docs/KONZEPT.md | 62 +++- 14 files changed, 605 insertions(+), 35 deletions(-) create mode 100644 backend/migrations/Version20260602123707.php create mode 100644 backend/src/Entity/Product.php create mode 100644 backend/src/Repository/ProductRepository.php create mode 100644 backend/src/Security/Voter/ProductVoter.php diff --git a/backend/migrations/Version20260602123707.php b/backend/migrations/Version20260602123707.php new file mode 100644 index 0000000..14fff90 --- /dev/null +++ b/backend/migrations/Version20260602123707.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE product (id BINARY(16) NOT NULL, kind VARCHAR(20) NOT NULL, name VARCHAR(120) NOT NULL, description LONGTEXT DEFAULT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, sides SMALLINT NOT NULL, nfc_enabled TINYINT NOT NULL, print_enabled TINYINT NOT NULL, active TINYINT NOT NULL, sort_order INT NOT NULL, created_at DATETIME NOT NULL, reseller_id BINARY(16) DEFAULT NULL, INDEX IDX_D34A04AD91E6A19D (reseller_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE product ADD CONSTRAINT FK_D34A04AD91E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)'); + $this->addSql('ALTER TABLE card_template ADD product_id BINARY(16) DEFAULT NULL'); + $this->addSql('ALTER TABLE card_template ADD CONSTRAINT FK_2E51D1004584665A FOREIGN KEY (product_id) REFERENCES product (id)'); + $this->addSql('CREATE INDEX IDX_2E51D1004584665A ON card_template (product_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE product DROP FOREIGN KEY FK_D34A04AD91E6A19D'); + $this->addSql('DROP TABLE product'); + $this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D1004584665A'); + $this->addSql('DROP INDEX IDX_2E51D1004584665A ON card_template'); + $this->addSql('ALTER TABLE card_template DROP product_id'); + } +} diff --git a/backend/src/Command/RenderCardCommand.php b/backend/src/Command/RenderCardCommand.php index 781e49d..c985aa7 100644 --- a/backend/src/Command/RenderCardCommand.php +++ b/backend/src/Command/RenderCardCommand.php @@ -43,7 +43,8 @@ final class RenderCardCommand extends Command return Command::FAILURE; } - $template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default(); + // Skalierungs-Test: irgendein vorhandenes Design der Firma, sonst Standard. + $template = $this->templates->findOneBy(['company' => $employee->getCompany()]) ?? $this->factory->default(); $pdf = $this->renderer->render($employee, $template); $file = sprintf('/tmp/render-%s.pdf', gethostname()); diff --git a/backend/src/Command/SeedCommand.php b/backend/src/Command/SeedCommand.php index df1f612..8245f3f 100644 --- a/backend/src/Command/SeedCommand.php +++ b/backend/src/Command/SeedCommand.php @@ -7,6 +7,7 @@ use App\Entity\ContactLink; use App\Entity\Employee; use App\Entity\Location; use App\Entity\PlatformPlan; +use App\Entity\Product; use App\Entity\Reseller; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -40,6 +41,11 @@ final class SeedCommand extends Command ->setFeatures(['vcard', 'wallet', 'nfc', 'print']); $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); + $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); + // Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins [$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true); $this->staff($pOrg, 'Thomas', 'Peterson', 'admin@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_ADMIN); @@ -47,6 +53,8 @@ final class SeedCommand extends Command // Reseller „Demo Druckerei" + Org-Firma + Reseller-Admin + Kundenfirma [$demo, $dOrg] = $this->reseller('Demo Druckerei', 'demo', $plan, false); + // 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'); @@ -67,6 +75,16 @@ final class SeedCommand extends Command return Command::SUCCESS; } + private function product(?Reseller $reseller, string $kind, string $name, float $w, float $h, float $bleed, float $safe, int $sides, bool $nfc, int $sort): Product + { + $product = (new Product())->setReseller($reseller)->setKind($kind)->setName($name) + ->setWidthMm($w)->setHeightMm($h)->setBleedMm($bleed)->setSafeMm($safe) + ->setSides($sides)->setNfcEnabled($nfc)->setPrintEnabled(true)->setSortOrder($sort); + $this->em->persist($product); + + return $product; + } + /** @return array{0: Reseller, 1: Company} Reseller + Org-Firma */ private function reseller(string $name, string $slug, PlatformPlan $plan, bool $isPlatform): array { diff --git a/backend/src/Controller/CardAssetUploadController.php b/backend/src/Controller/CardAssetUploadController.php index 8c7b967..d8302f1 100644 --- a/backend/src/Controller/CardAssetUploadController.php +++ b/backend/src/Controller/CardAssetUploadController.php @@ -4,7 +4,9 @@ namespace App\Controller; use App\Entity\CardTemplate; use App\Entity\Company; +use App\Entity\Product; use App\Repository\CardTemplateRepository; +use App\Repository\ProductRepository; use App\Security\TenantContext; use Doctrine\ORM\EntityManagerInterface; use League\Flysystem\FilesystemOperator; @@ -31,6 +33,7 @@ final class CardAssetUploadController { public function __construct( private readonly CardTemplateRepository $templates, + private readonly ProductRepository $products, private readonly EntityManagerInterface $em, private readonly TenantContext $tenant, #[Autowire(service: 'card_assets.storage')] @@ -42,13 +45,14 @@ final class CardAssetUploadController public function uploadBackground(string $id, Request $request): JsonResponse { $company = $this->company($id); + $product = $this->product($request->query->get('product')); $file = $this->file($request); if ('pdf' !== strtolower((string) $file->getClientOriginalExtension())) { throw new BadRequestHttpException('Nur PDF erlaubt.'); } - $template = $this->getOrCreate($company); - $key = $this->store($file, $company->getId(), 'background', 'pdf'); + $template = $this->getOrCreate($company, $product); + $key = $this->store($file, $company->getId(), $product->getId(), 'background', 'pdf'); $template->setBackgroundPath($key); $this->em->persist($template); $this->em->flush(); @@ -57,10 +61,11 @@ final class CardAssetUploadController } #[Route('/api/companies/{id}/card-template/background', name: 'card_bg_get', methods: ['GET'])] - public function getBackground(string $id): Response + public function getBackground(string $id, Request $request): Response { $company = $this->company($id); - $key = $this->templates->findCardForCompany($company)?->getBackgroundPath(); + $product = $this->product($request->query->get('product')); + $key = $this->templates->findForCompanyAndProduct($company, $product)?->getBackgroundPath(); if (!$key || !$this->cardAssets->fileExists($key)) { throw new NotFoundHttpException('Kein Hintergrund-PDF.'); } @@ -71,10 +76,11 @@ final class CardAssetUploadController } #[Route('/api/companies/{id}/card-template/background', name: 'card_bg_delete', methods: ['DELETE'])] - public function deleteBackground(string $id): JsonResponse + public function deleteBackground(string $id, Request $request): JsonResponse { $company = $this->company($id); - $template = $this->templates->findCardForCompany($company); + $product = $this->product($request->query->get('product')); + $template = $this->templates->findForCompanyAndProduct($company, $product); if ($template && $template->getBackgroundPath()) { if ($this->cardAssets->fileExists($template->getBackgroundPath())) { $this->cardAssets->delete($template->getBackgroundPath()); @@ -90,6 +96,7 @@ final class CardAssetUploadController public function uploadFont(string $id, Request $request): JsonResponse { $company = $this->company($id); + $product = $this->product($request->query->get('product')); $file = $this->file($request); $ext = strtolower((string) $file->getClientOriginalExtension()); if (!in_array($ext, ['ttf', 'otf'], true)) { @@ -97,8 +104,8 @@ final class CardAssetUploadController } $family = trim((string) $request->request->get('family')) ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); - $template = $this->getOrCreate($company); - $key = $this->store($file, $company->getId(), 'font', $ext); + $template = $this->getOrCreate($company, $product); + $key = $this->store($file, $company->getId(), $product->getId(), 'font', $ext); $template->addFont($family, $key); $this->em->flush(); @@ -116,18 +123,39 @@ final class CardAssetUploadController } /** Lädt die Datei in den Object-Storage und liefert den Key zurück. */ - private function store(UploadedFile $file, Uuid $companyId, string $prefix, string $ext): string + private function store(UploadedFile $file, Uuid $companyId, Uuid $productId, string $prefix, string $ext): string { - $key = sprintf('%s/%s-%s.%s', $companyId->toRfc4122(), $prefix, bin2hex(random_bytes(4)), $ext); + $key = sprintf('%s/%s/%s-%s.%s', $companyId->toRfc4122(), $productId->toRfc4122(), $prefix, bin2hex(random_bytes(4)), $ext); $this->cardAssets->write($key, (string) file_get_contents($file->getPathname())); return $key; } - private function getOrCreate(Company $company): CardTemplate + private function getOrCreate(Company $company, Product $product): CardTemplate { - return $this->templates->findCardForCompany($company) - ?? (new CardTemplate())->setCompany($company); + return $this->templates->findForCompanyAndProduct($company, $product) + ?? (new CardTemplate())->setCompany($company)->setProduct($product); + } + + /** Lädt ein Produkt und prüft Sichtbarkeit (global oder eigener Reseller). */ + private function product(?string $id): Product + { + if (null === $id || '' === $id) { + throw new NotFoundHttpException('Produkt nicht angegeben.'); + } + $product = $this->products->find(Uuid::fromString($id)); + 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 ist im eigenen Mandanten nicht verfügbar.'); + } + + return $product; } private function company(string $id): Company diff --git a/backend/src/Controller/CardPdfController.php b/backend/src/Controller/CardPdfController.php index cc15a0c..9749b01 100644 --- a/backend/src/Controller/CardPdfController.php +++ b/backend/src/Controller/CardPdfController.php @@ -3,11 +3,14 @@ namespace App\Controller; use App\Entity\Employee; +use App\Entity\Product; use App\Repository\CardTemplateRepository; use App\Repository\EmployeeRepository; +use App\Repository\ProductRepository; use App\Security\TenantContext; use App\Service\CardPdfRenderer; use App\Service\CardTemplateFactory; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -24,6 +27,7 @@ final class CardPdfController public function __construct( private readonly EmployeeRepository $employees, private readonly CardTemplateRepository $templates, + private readonly ProductRepository $products, private readonly CardTemplateFactory $factory, private readonly CardPdfRenderer $renderer, private readonly TenantContext $tenant, @@ -31,7 +35,7 @@ final class CardPdfController } #[Route('/api/employees/{id}/card.pdf', name: 'employee_card_pdf', methods: ['GET'])] - public function __invoke(string $id): Response + public function __invoke(string $id, Request $request): Response { $employee = $this->employees->find(Uuid::fromString($id)); if (!$employee instanceof Employee) { @@ -39,8 +43,15 @@ final class CardPdfController } $this->assertAccess($employee); - $template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default(); - $pdf = $this->renderer->render($employee, $template); + $productId = $request->query->get('product'); + $template = null; + if (null !== $productId && '' !== $productId) { + $product = $this->products->find(Uuid::fromString($productId)); + if ($product instanceof Product) { + $template = $this->templates->findForCompanyAndProduct($employee->getCompany(), $product); + } + } + $pdf = $this->renderer->render($employee, $template ?? $this->factory->default()); return new Response($pdf, 200, [ 'Content-Type' => 'application/pdf', diff --git a/backend/src/Controller/CardTemplateEditorController.php b/backend/src/Controller/CardTemplateEditorController.php index f3f673c..8c500f9 100644 --- a/backend/src/Controller/CardTemplateEditorController.php +++ b/backend/src/Controller/CardTemplateEditorController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Entity\CardTemplate; use App\Entity\Company; +use App\Entity\Product; use App\Repository\CardTemplateRepository; use App\Security\TenantContext; use App\Service\CardTemplateFactory; @@ -17,8 +18,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Uid\Uuid; /** - * Lädt/speichert die Visitenkarten-Vorlage einer Firma für den visuellen Editor. - * Gibt – falls noch keine Vorlage existiert – die Standardvorlage zurück. + * Lädt/speichert das Design einer Firma für ein bestimmtes Produkt (visueller Editor). + * Format (Maße/Beschnitt) wird vom Produkt geerbt; gibt – falls noch kein Design + * existiert – die Standardvorlage im Produktformat zurück. */ #[IsGranted('ROLE_COMPANY_ADMIN')] final class CardTemplateEditorController @@ -32,12 +34,17 @@ final class CardTemplateEditorController } #[Route('/api/companies/{id}/card-template', name: 'company_card_template_get', methods: ['GET'])] - public function get(string $id): JsonResponse + public function get(string $id, Request $request): JsonResponse { $company = $this->company($id); - $template = $this->templates->findCardForCompany($company); + $product = $this->product($request->query->get('product')); + $template = $this->templates->findForCompanyAndProduct($company, $product); - return new JsonResponse($this->serialize($template ?? $this->factory->default(), null === $template)); + return new JsonResponse($this->serialize( + $template ?? $this->seedDefault($product), + null === $template, + $product, + )); } #[Route('/api/companies/{id}/card-template', name: 'company_card_template_put', methods: ['PUT'])] @@ -45,21 +52,38 @@ final class CardTemplateEditorController { $company = $this->company($id); $data = json_decode($request->getContent(), true) ?? []; + $product = $this->product($data['product'] ?? $request->query->get('product')); - $template = $this->templates->findCardForCompany($company) ?? (new CardTemplate())->setCompany($company); + $template = $this->templates->findForCompanyAndProduct($company, $product) + ?? (new CardTemplate())->setCompany($company)->setProduct($product); $template - ->setName((string) ($data['name'] ?? 'Standard')) - ->setWidthMm((float) ($data['widthMm'] ?? 85)) - ->setHeightMm((float) ($data['heightMm'] ?? 55)) - ->setBleedMm((float) ($data['bleedMm'] ?? 2)) - ->setSafeMm((float) ($data['safeMm'] ?? 4)) + ->setName((string) ($data['name'] ?? $product->getName())) + // Format wird vom Produkt geerbt (nicht vom Client überschreibbar) + ->setWidthMm($product->getWidthMm()) + ->setHeightMm($product->getHeightMm()) + ->setBleedMm($product->getBleedMm()) + ->setSafeMm($product->getSafeMm()) ->setFront(is_array($data['front'] ?? null) ? $data['front'] : []) ->setBack(is_array($data['back'] ?? null) ? $data['back'] : []); $this->em->persist($template); $this->em->flush(); - return new JsonResponse($this->serialize($template, false)); + return new JsonResponse($this->serialize($template, false, $product)); + } + + /** Standardvorlage als Startpunkt, an das Produktformat angepasst. */ + private function seedDefault(Product $product): CardTemplate + { + $t = $this->factory->default(); + $t->setProduct($product) + ->setName($product->getName()) + ->setWidthMm($product->getWidthMm()) + ->setHeightMm($product->getHeightMm()) + ->setBleedMm($product->getBleedMm()) + ->setSafeMm($product->getSafeMm()); + + return $t; } private function company(string $id): Company @@ -83,12 +107,40 @@ final class CardTemplateEditorController return $company; } - private function serialize(CardTemplate $t, bool $isDefault): array + /** Lädt ein Produkt und prüft Sichtbarkeit (global oder eigener Reseller). */ + private function product(?string $id): Product + { + if (null === $id || '' === $id) { + throw new NotFoundHttpException('Produkt nicht angegeben.'); + } + $product = $this->em->getRepository(Product::class)->find(Uuid::fromString($id)); + 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 ist im eigenen Mandanten nicht verfügbar.'); + } + + return $product; + } + + private function serialize(CardTemplate $t, bool $isDefault, Product $product): array { return [ 'id' => $isDefault ? null : (string) $t->getId(), 'isDefault' => $isDefault, 'name' => $t->getName(), + 'product' => [ + 'id' => (string) $product->getId(), + 'kind' => $product->getKind(), + 'name' => $product->getName(), + 'sides' => $product->getSides(), + 'nfcEnabled' => $product->isNfcEnabled(), + ], 'widthMm' => $t->getWidthMm(), 'heightMm' => $t->getHeightMm(), 'bleedMm' => $t->getBleedMm(), diff --git a/backend/src/Doctrine/TenantExtension.php b/backend/src/Doctrine/TenantExtension.php index eb038a6..d98031a 100644 --- a/backend/src/Doctrine/TenantExtension.php +++ b/backend/src/Doctrine/TenantExtension.php @@ -11,6 +11,7 @@ use App\Entity\ContactLink; use App\Entity\Domain; use App\Entity\Employee; use App\Entity\Location; +use App\Entity\Product; use App\Security\TenantContext; use Doctrine\ORM\QueryBuilder; @@ -64,6 +65,14 @@ final class TenantExtension implements QueryCollectionExtensionInterface, QueryI $alias = $qb->getRootAliases()[0]; + // Produkte: globale (reseller IS NULL) + eigene des Resellers, ohne Company-Beschränkung. + if (Product::class === $resourceClass) { + $qb->andWhere("$alias.reseller = :tenant_reseller OR $alias.reseller IS NULL") + ->setParameter('tenant_reseller', $reseller->getId(), 'uuid'); + + return; + } + // Join-Pfad zur Reseller-/Company-Spalte je nach Entität [$companyAlias, $resellerExpr] = match ($resourceClass) { Company::class => [$alias, "$alias.reseller"], diff --git a/backend/src/Entity/CardTemplate.php b/backend/src/Entity/CardTemplate.php index b60afcb..bcba4ca 100644 --- a/backend/src/Entity/CardTemplate.php +++ b/backend/src/Entity/CardTemplate.php @@ -59,6 +59,10 @@ class CardTemplate implements ResellerOwnedInterface #[ORM\ManyToOne(targetEntity: Company::class)] private ?Company $company = null; + /** Produkt, für das dieses Design erstellt wurde (Format wird vom Produkt geerbt). */ + #[ORM\ManyToOne(targetEntity: Product::class)] + private ?Product $product = null; + #[ORM\Column(type: 'datetime_immutable')] private \DateTimeImmutable $createdAt; @@ -220,6 +224,18 @@ class CardTemplate implements ResellerOwnedInterface return $this; } + public function getProduct(): ?Product + { + return $this->product; + } + + public function setProduct(?Product $product): self + { + $this->product = $product; + + return $this; + } + public function getReseller(): ?Reseller { return $this->company?->getReseller(); diff --git a/backend/src/Entity/Product.php b/backend/src/Entity/Product.php new file mode 100644 index 0000000..25914be --- /dev/null +++ b/backend/src/Entity/Product.php @@ -0,0 +1,272 @@ +id = Uuid::v7(); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getReseller(): ?Reseller + { + return $this->reseller; + } + + public function setReseller(?Reseller $reseller): self + { + $this->reseller = $reseller; + + return $this; + } + + /** Globales Plattform-Produkt? (serialisiert als "isGlobal"). */ + public function isGlobal(): bool + { + return null === $this->reseller; + } + + public function getKind(): string + { + return $this->kind; + } + + public function setKind(string $kind): self + { + $this->kind = $kind; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getWidthMm(): float + { + return $this->widthMm; + } + + public function setWidthMm(float $widthMm): self + { + $this->widthMm = $widthMm; + + return $this; + } + + public function getHeightMm(): float + { + return $this->heightMm; + } + + public function setHeightMm(float $heightMm): self + { + $this->heightMm = $heightMm; + + return $this; + } + + public function getBleedMm(): float + { + return $this->bleedMm; + } + + public function setBleedMm(float $bleedMm): self + { + $this->bleedMm = $bleedMm; + + return $this; + } + + public function getSafeMm(): float + { + return $this->safeMm; + } + + public function setSafeMm(float $safeMm): self + { + $this->safeMm = $safeMm; + + return $this; + } + + public function getSides(): int + { + return $this->sides; + } + + public function setSides(int $sides): self + { + $this->sides = max(1, min(2, $sides)); + + return $this; + } + + public function isNfcEnabled(): bool + { + return $this->nfcEnabled; + } + + public function setNfcEnabled(bool $nfcEnabled): self + { + $this->nfcEnabled = $nfcEnabled; + + return $this; + } + + public function isPrintEnabled(): bool + { + return $this->printEnabled; + } + + public function setPrintEnabled(bool $printEnabled): self + { + $this->printEnabled = $printEnabled; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getSortOrder(): int + { + return $this->sortOrder; + } + + public function setSortOrder(int $sortOrder): self + { + $this->sortOrder = $sortOrder; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/backend/src/Repository/CardTemplateRepository.php b/backend/src/Repository/CardTemplateRepository.php index d8ee8ec..03bb007 100644 --- a/backend/src/Repository/CardTemplateRepository.php +++ b/backend/src/Repository/CardTemplateRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Entity\CardTemplate; use App\Entity\Company; +use App\Entity\Product; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -17,9 +18,9 @@ class CardTemplateRepository extends ServiceEntityRepository parent::__construct($registry, CardTemplate::class); } - /** Vorlage einer Firma (Karten-Typ), falls vorhanden. */ - public function findCardForCompany(Company $company): ?CardTemplate + /** Design einer Firma für ein bestimmtes Produkt, falls vorhanden. */ + public function findForCompanyAndProduct(Company $company, Product $product): ?CardTemplate { - return $this->findOneBy(['company' => $company, 'type' => CardTemplate::TYPE_CARD]); + return $this->findOneBy(['company' => $company, 'product' => $product]); } } diff --git a/backend/src/Repository/ProductRepository.php b/backend/src/Repository/ProductRepository.php new file mode 100644 index 0000000..4872d35 --- /dev/null +++ b/backend/src/Repository/ProductRepository.php @@ -0,0 +1,18 @@ + + */ +class ProductRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Product::class); + } +} diff --git a/backend/src/Security/Voter/ProductVoter.php b/backend/src/Security/Voter/ProductVoter.php new file mode 100644 index 0000000..bcc7829 --- /dev/null +++ b/backend/src/Security/Voter/ProductVoter.php @@ -0,0 +1,43 @@ +tenant->isPlatformAdmin()) { + return true; + } + + $reseller = $this->tenant->getReseller(); + if (null === $reseller || $subject->isGlobal()) { + return false; + } + + return $subject->getReseller()?->getId()->equals($reseller->getId()) === true; + } +} diff --git a/backend/src/State/TenantStampProcessor.php b/backend/src/State/TenantStampProcessor.php index f270e01..1420cdb 100644 --- a/backend/src/State/TenantStampProcessor.php +++ b/backend/src/State/TenantStampProcessor.php @@ -9,6 +9,7 @@ use App\Entity\ContactLink; use App\Entity\Domain; use App\Entity\Employee; use App\Entity\Location; +use App\Entity\Product; use App\Security\TenantContext; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; @@ -56,6 +57,8 @@ final class TenantStampProcessor implements ProcessorInterface match (true) { $data instanceof Company => $data->setReseller($reseller), + // Reseller dürfen nur eigene Produkte anlegen/ändern (nie globale) + $data instanceof Product => $data->setReseller($reseller), $data instanceof Location, $data instanceof Domain => $this->assertCompany($data->getCompany()), $data instanceof Employee => $this->assertEmployee($data), @@ -94,6 +97,7 @@ final class TenantStampProcessor implements ProcessorInterface private function isTenantOwned(mixed $data): bool { return $data instanceof Company + || $data instanceof Product || $data instanceof Location || $data instanceof Domain || $data instanceof Employee diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index fb78aef..15e12cc 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -337,7 +337,65 @@ Apple Pass Type ID ist an *einen* Apple-Account gebunden. Optionen: **(a)** ein --- -## 13. Druckdaten: Visitenkarten-PDF (Kerngeschäft) +## 13. Produktkatalog (mehrere Produkttypen) + +Die Plattform verkauft nicht nur Visitenkarten, sondern mehrere **Produkttypen**. +Ein **Produkt** ist der Katalogeintrag, den eine Firma als druck-/ausrollbares +Erzeugnis bestellt und gestaltet. Start mit drei Typen: **Visitenkarte**, +**Namensschild**, **NFC-Karte**. + +### Eigentümer & Sichtbarkeit + +Produkte definieren **ausschließlich**: + +| Definiert von | `reseller` | Sichtbar für | +|---------------|-----------|--------------| +| Plattform-Betreiber | `null` (global) | alle Reseller + deren Firmen | +| Reseller | eigener Reseller | nur eigener Mandant (Reseller + dessen Firmen) | + +Firmen(-Admins) **wählen** aus den für sie sichtbaren Produkten (globale + die des +eigenen Resellers), legen aber **keine** Produkte an. Bearbeiten/Löschen darf jeweils +nur der Eigentümer (Plattform globale, Reseller die eigenen). Globale Produkte sind +für Reseller **read-only**. + +### `kind` (Produktart) — legt Fähigkeiten & Defaults fest + +| `kind` | Standardformat | Seiten | Druck | NFC | +|--------|----------------|--------|:----:|:---:| +| `business_card` (Visitenkarte) | 85 × 55 mm, 2 mm Bleed | V/R | ✓ | optional | +| `name_tag` (Namensschild) | 90 × 55 mm, 0 mm Bleed | nur V | ✓ | – | +| `nfc_card` (NFC-Karte) | 85,6 × 54 mm (ID-1), 2 mm Bleed | V/R | ✓ | ✓ | + +`kind` ist ein festes Enum (Fähigkeiten = Druck-PDF und/oder NFC-Programmierung). +Sowohl Plattform als auch Reseller legen Produkte **dieser** Arten an (z. B. ein +Reseller-eigenes „Premium-Visitenkarte 90×50"). + +### Datenmodell + +**`Product`** (`ResellerOwnedInterface`): `reseller_id` (nullable = global), `kind`, +`name`, `description`, `widthMm`, `heightMm`, `bleedMm`, `safeMm`, `sides` (1|2), +`nfcEnabled`, `printEnabled`, `active`, `sortOrder`, `createdAt`. + +**`CardTemplate`** referenziert künftig ein **`product_id`**: das konkrete **Design +einer Firma für ein Produkt** (eine Firma kann je Produkt ein Design haben). Format +(Maße/Bleed/Seiten) wird vom Produkt geerbt; der Renderer bleibt formatagnostisch. + +### Endpunkte + +- `GET /api/products` — sichtbare Produkte (global + eigener Reseller), API-Platform-scoped. +- `POST/PATCH/DELETE /api/products/{id}` — nur Eigentümer (Voter). +- Editor: `GET/PUT /api/companies/{id}/card-template?product={productId}` (Design je Produkt). + +### Renderer/NFC je Produktart + +- `business_card` / `nfc_card` / `name_tag` nutzen denselben `CardPdfRenderer` + (Maße + Elementliste aus Produkt + Design). Beschnitt/Schnittmarken nur wenn `bleedMm > 0`. +- `nfc_card`: zusätzlich NFC-Programmierung über die bestehende `shortCode`/`/t/`-Infra + (Tag schreibt die Kurz-URL des Profils) — Detail in §12/§14. + +--- + +## 14. Druckdaten: Visitenkarten-PDF (Kerngeschäft) Reseller drucken für ihre Firmenkunden Visitenkarten und brauchen **druckfertige PDFs**. Das Layout variiert **pro Firma**. Später kommt **Briefpapier** (gleiches System, anderes Format) dazu. @@ -429,7 +487,7 @@ Font-Embedding ist zugleich Voraussetzung für striktes **PDF/X**. --- -## 14. Zeiterfassung (Modul „Kommen/Gehen") +## 15. Zeiterfassung (Modul „Kommen/Gehen") Erweiterung über die digitale Visitenkarte hinaus: eine **Arbeitszeiterfassung** für die Firmenkunden der Reseller (vorzugsweise Druckshops). Der Mitarbeiter ist