Produkte: Produktkatalog-Backend (Visitenkarte/Namensschild/NFC)

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-02 15:20:52 +02:00
parent 2dc40c6ea5
commit f5807aefce
14 changed files with 605 additions and 35 deletions

View File

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

View File

@ -43,7 +43,8 @@ final class RenderCardCommand extends Command
return Command::FAILURE; 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); $pdf = $this->renderer->render($employee, $template);
$file = sprintf('/tmp/render-%s.pdf', gethostname()); $file = sprintf('/tmp/render-%s.pdf', gethostname());

View File

@ -7,6 +7,7 @@ use App\Entity\ContactLink;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\Location; use App\Entity\Location;
use App\Entity\PlatformPlan; use App\Entity\PlatformPlan;
use App\Entity\Product;
use App\Entity\Reseller; use App\Entity\Reseller;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@ -40,6 +41,11 @@ final class SeedCommand extends Command
->setFeatures(['vcard', 'wallet', 'nfc', 'print']); ->setFeatures(['vcard', 'wallet', 'nfc', 'print']);
$this->em->persist($plan); $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 // Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins
[$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true); [$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true);
$this->staff($pOrg, 'Thomas', 'Peterson', 'admin@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_ADMIN); $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 // Reseller „Demo Druckerei" + Org-Firma + Reseller-Admin + Kundenfirma
[$demo, $dOrg] = $this->reseller('Demo Druckerei', 'demo', $plan, false); [$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->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');
@ -67,6 +75,16 @@ final class SeedCommand extends Command
return Command::SUCCESS; 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 */ /** @return array{0: Reseller, 1: Company} Reseller + Org-Firma */
private function reseller(string $name, string $slug, PlatformPlan $plan, bool $isPlatform): array private function reseller(string $name, string $slug, PlatformPlan $plan, bool $isPlatform): array
{ {

View File

@ -4,7 +4,9 @@ namespace App\Controller;
use App\Entity\CardTemplate; use App\Entity\CardTemplate;
use App\Entity\Company; use App\Entity\Company;
use App\Entity\Product;
use App\Repository\CardTemplateRepository; use App\Repository\CardTemplateRepository;
use App\Repository\ProductRepository;
use App\Security\TenantContext; use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
@ -31,6 +33,7 @@ final class CardAssetUploadController
{ {
public function __construct( public function __construct(
private readonly CardTemplateRepository $templates, private readonly CardTemplateRepository $templates,
private readonly ProductRepository $products,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant, private readonly TenantContext $tenant,
#[Autowire(service: 'card_assets.storage')] #[Autowire(service: 'card_assets.storage')]
@ -42,13 +45,14 @@ final class CardAssetUploadController
public function uploadBackground(string $id, Request $request): JsonResponse public function uploadBackground(string $id, Request $request): JsonResponse
{ {
$company = $this->company($id); $company = $this->company($id);
$product = $this->product($request->query->get('product'));
$file = $this->file($request); $file = $this->file($request);
if ('pdf' !== strtolower((string) $file->getClientOriginalExtension())) { if ('pdf' !== strtolower((string) $file->getClientOriginalExtension())) {
throw new BadRequestHttpException('Nur PDF erlaubt.'); throw new BadRequestHttpException('Nur PDF erlaubt.');
} }
$template = $this->getOrCreate($company); $template = $this->getOrCreate($company, $product);
$key = $this->store($file, $company->getId(), 'background', 'pdf'); $key = $this->store($file, $company->getId(), $product->getId(), 'background', 'pdf');
$template->setBackgroundPath($key); $template->setBackgroundPath($key);
$this->em->persist($template); $this->em->persist($template);
$this->em->flush(); $this->em->flush();
@ -57,10 +61,11 @@ final class CardAssetUploadController
} }
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_get', methods: ['GET'])] #[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); $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)) { if (!$key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Hintergrund-PDF.'); 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'])] #[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); $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 ($template && $template->getBackgroundPath()) {
if ($this->cardAssets->fileExists($template->getBackgroundPath())) { if ($this->cardAssets->fileExists($template->getBackgroundPath())) {
$this->cardAssets->delete($template->getBackgroundPath()); $this->cardAssets->delete($template->getBackgroundPath());
@ -90,6 +96,7 @@ final class CardAssetUploadController
public function uploadFont(string $id, Request $request): JsonResponse public function uploadFont(string $id, Request $request): JsonResponse
{ {
$company = $this->company($id); $company = $this->company($id);
$product = $this->product($request->query->get('product'));
$file = $this->file($request); $file = $this->file($request);
$ext = strtolower((string) $file->getClientOriginalExtension()); $ext = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($ext, ['ttf', 'otf'], true)) { 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); $family = trim((string) $request->request->get('family')) ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$template = $this->getOrCreate($company); $template = $this->getOrCreate($company, $product);
$key = $this->store($file, $company->getId(), 'font', $ext); $key = $this->store($file, $company->getId(), $product->getId(), 'font', $ext);
$template->addFont($family, $key); $template->addFont($family, $key);
$this->em->flush(); $this->em->flush();
@ -116,18 +123,39 @@ final class CardAssetUploadController
} }
/** Lädt die Datei in den Object-Storage und liefert den Key zurück. */ /** 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())); $this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
return $key; return $key;
} }
private function getOrCreate(Company $company): CardTemplate private function getOrCreate(Company $company, Product $product): CardTemplate
{ {
return $this->templates->findCardForCompany($company) return $this->templates->findForCompanyAndProduct($company, $product)
?? (new CardTemplate())->setCompany($company); ?? (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 private function company(string $id): Company

View File

@ -3,11 +3,14 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\Product;
use App\Repository\CardTemplateRepository; use App\Repository\CardTemplateRepository;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Repository\ProductRepository;
use App\Security\TenantContext; use App\Security\TenantContext;
use App\Service\CardPdfRenderer; use App\Service\CardPdfRenderer;
use App\Service\CardTemplateFactory; use App\Service\CardTemplateFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -24,6 +27,7 @@ final class CardPdfController
public function __construct( public function __construct(
private readonly EmployeeRepository $employees, private readonly EmployeeRepository $employees,
private readonly CardTemplateRepository $templates, private readonly CardTemplateRepository $templates,
private readonly ProductRepository $products,
private readonly CardTemplateFactory $factory, private readonly CardTemplateFactory $factory,
private readonly CardPdfRenderer $renderer, private readonly CardPdfRenderer $renderer,
private readonly TenantContext $tenant, private readonly TenantContext $tenant,
@ -31,7 +35,7 @@ final class CardPdfController
} }
#[Route('/api/employees/{id}/card.pdf', name: 'employee_card_pdf', methods: ['GET'])] #[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)); $employee = $this->employees->find(Uuid::fromString($id));
if (!$employee instanceof Employee) { if (!$employee instanceof Employee) {
@ -39,8 +43,15 @@ final class CardPdfController
} }
$this->assertAccess($employee); $this->assertAccess($employee);
$template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default(); $productId = $request->query->get('product');
$pdf = $this->renderer->render($employee, $template); $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, [ return new Response($pdf, 200, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',

View File

@ -4,6 +4,7 @@ namespace App\Controller;
use App\Entity\CardTemplate; use App\Entity\CardTemplate;
use App\Entity\Company; use App\Entity\Company;
use App\Entity\Product;
use App\Repository\CardTemplateRepository; use App\Repository\CardTemplateRepository;
use App\Security\TenantContext; use App\Security\TenantContext;
use App\Service\CardTemplateFactory; use App\Service\CardTemplateFactory;
@ -17,8 +18,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* Lädt/speichert die Visitenkarten-Vorlage einer Firma für den visuellen Editor. * Lädt/speichert das Design einer Firma für ein bestimmtes Produkt (visueller Editor).
* Gibt falls noch keine Vorlage existiert die Standardvorlage zurück. * Format (Maße/Beschnitt) wird vom Produkt geerbt; gibt falls noch kein Design
* existiert die Standardvorlage im Produktformat zurück.
*/ */
#[IsGranted('ROLE_COMPANY_ADMIN')] #[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardTemplateEditorController final class CardTemplateEditorController
@ -32,12 +34,17 @@ final class CardTemplateEditorController
} }
#[Route('/api/companies/{id}/card-template', name: 'company_card_template_get', methods: ['GET'])] #[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); $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'])] #[Route('/api/companies/{id}/card-template', name: 'company_card_template_put', methods: ['PUT'])]
@ -45,21 +52,38 @@ final class CardTemplateEditorController
{ {
$company = $this->company($id); $company = $this->company($id);
$data = json_decode($request->getContent(), true) ?? []; $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 $template
->setName((string) ($data['name'] ?? 'Standard')) ->setName((string) ($data['name'] ?? $product->getName()))
->setWidthMm((float) ($data['widthMm'] ?? 85)) // Format wird vom Produkt geerbt (nicht vom Client überschreibbar)
->setHeightMm((float) ($data['heightMm'] ?? 55)) ->setWidthMm($product->getWidthMm())
->setBleedMm((float) ($data['bleedMm'] ?? 2)) ->setHeightMm($product->getHeightMm())
->setSafeMm((float) ($data['safeMm'] ?? 4)) ->setBleedMm($product->getBleedMm())
->setSafeMm($product->getSafeMm())
->setFront(is_array($data['front'] ?? null) ? $data['front'] : []) ->setFront(is_array($data['front'] ?? null) ? $data['front'] : [])
->setBack(is_array($data['back'] ?? null) ? $data['back'] : []); ->setBack(is_array($data['back'] ?? null) ? $data['back'] : []);
$this->em->persist($template); $this->em->persist($template);
$this->em->flush(); $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 private function company(string $id): Company
@ -83,12 +107,40 @@ final class CardTemplateEditorController
return $company; 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 [ return [
'id' => $isDefault ? null : (string) $t->getId(), 'id' => $isDefault ? null : (string) $t->getId(),
'isDefault' => $isDefault, 'isDefault' => $isDefault,
'name' => $t->getName(), 'name' => $t->getName(),
'product' => [
'id' => (string) $product->getId(),
'kind' => $product->getKind(),
'name' => $product->getName(),
'sides' => $product->getSides(),
'nfcEnabled' => $product->isNfcEnabled(),
],
'widthMm' => $t->getWidthMm(), 'widthMm' => $t->getWidthMm(),
'heightMm' => $t->getHeightMm(), 'heightMm' => $t->getHeightMm(),
'bleedMm' => $t->getBleedMm(), 'bleedMm' => $t->getBleedMm(),

View File

@ -11,6 +11,7 @@ use App\Entity\ContactLink;
use App\Entity\Domain; use App\Entity\Domain;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\Location; use App\Entity\Location;
use App\Entity\Product;
use App\Security\TenantContext; use App\Security\TenantContext;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
@ -64,6 +65,14 @@ final class TenantExtension implements QueryCollectionExtensionInterface, QueryI
$alias = $qb->getRootAliases()[0]; $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 // Join-Pfad zur Reseller-/Company-Spalte je nach Entität
[$companyAlias, $resellerExpr] = match ($resourceClass) { [$companyAlias, $resellerExpr] = match ($resourceClass) {
Company::class => [$alias, "$alias.reseller"], Company::class => [$alias, "$alias.reseller"],

View File

@ -59,6 +59,10 @@ class CardTemplate implements ResellerOwnedInterface
#[ORM\ManyToOne(targetEntity: Company::class)] #[ORM\ManyToOne(targetEntity: Company::class)]
private ?Company $company = null; 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')] #[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt; private \DateTimeImmutable $createdAt;
@ -220,6 +224,18 @@ class CardTemplate implements ResellerOwnedInterface
return $this; return $this;
} }
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): self
{
$this->product = $product;
return $this;
}
public function getReseller(): ?Reseller public function getReseller(): ?Reseller
{ {
return $this->company?->getReseller(); return $this->company?->getReseller();

View File

@ -0,0 +1,272 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ProductRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Produkttyp im Katalog (Visitenkarte, Namensschild, NFC-Karte ).
* reseller = null globales Plattform-Produkt (für alle sichtbar).
* reseller != null Reseller-eigenes Produkt (nur im eigenen Mandanten sichtbar).
* Firmen wählen aus sichtbaren Produkten, legen aber keine an (siehe KONZEPT §13).
*/
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ApiResource(
operations: [
// Lesen: auch Firmen-Admins (Tenant-Extension liefert globale + eigene)
new GetCollection(security: "is_granted('ROLE_COMPANY_ADMIN')"),
new Get(security: "is_granted('ROLE_COMPANY_ADMIN')"),
// Anlegen: Reseller-Admins (eigene) und Plattform-Admins (global)
new Post(security: "is_granted('ROLE_RESELLER_ADMIN')"),
// Ändern/Löschen: nur Eigentümer (Voter)
new Patch(security: "is_granted('PRODUCT_EDIT', object)"),
new Delete(security: "is_granted('PRODUCT_EDIT', object)"),
],
)]
class Product implements ResellerOwnedInterface
{
public const KIND_BUSINESS_CARD = 'business_card';
public const KIND_NAME_TAG = 'name_tag';
public const KIND_NFC_CARD = 'nfc_card';
public const KINDS = [
self::KIND_BUSINESS_CARD,
self::KIND_NAME_TAG,
self::KIND_NFC_CARD,
];
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
/** null = globales Plattform-Produkt. */
#[ORM\ManyToOne(targetEntity: Reseller::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Reseller $reseller = null;
#[ORM\Column(length: 20)]
private string $kind = self::KIND_BUSINESS_CARD;
#[ORM\Column(length: 120)]
private string $name = '';
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'float')]
private float $widthMm = 85.0;
#[ORM\Column(type: 'float')]
private float $heightMm = 55.0;
#[ORM\Column(type: 'float')]
private float $bleedMm = 2.0;
#[ORM\Column(type: 'float')]
private float $safeMm = 4.0;
#[ORM\Column(type: 'smallint')]
private int $sides = 2;
#[ORM\Column]
private bool $nfcEnabled = false;
#[ORM\Column]
private bool $printEnabled = true;
#[ORM\Column]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $sortOrder = 0;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->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;
}
}

View File

@ -4,6 +4,7 @@ namespace App\Repository;
use App\Entity\CardTemplate; use App\Entity\CardTemplate;
use App\Entity\Company; use App\Entity\Company;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@ -17,9 +18,9 @@ class CardTemplateRepository extends ServiceEntityRepository
parent::__construct($registry, CardTemplate::class); parent::__construct($registry, CardTemplate::class);
} }
/** Vorlage einer Firma (Karten-Typ), falls vorhanden. */ /** Design einer Firma für ein bestimmtes Produkt, falls vorhanden. */
public function findCardForCompany(Company $company): ?CardTemplate public function findForCompanyAndProduct(Company $company, Product $product): ?CardTemplate
{ {
return $this->findOneBy(['company' => $company, 'type' => CardTemplate::TYPE_CARD]); return $this->findOneBy(['company' => $company, 'product' => $product]);
} }
} }

View File

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

View File

@ -0,0 +1,43 @@
<?php
namespace App\Security\Voter;
use App\Entity\Product;
use App\Security\TenantContext;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Ändern/Löschen eines Produkts darf nur sein Eigentümer:
* - Plattform-Admin alle (inkl. globale).
* - Reseller-Admin nur die eigenen (reseller == eigener Reseller); globale + fremde sind read-only.
*/
final class ProductVoter extends Voter
{
public const EDIT = 'PRODUCT_EDIT';
public function __construct(private readonly TenantContext $tenant)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::EDIT === $attribute && $subject instanceof Product;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
\assert($subject instanceof Product);
if ($this->tenant->isPlatformAdmin()) {
return true;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $subject->isGlobal()) {
return false;
}
return $subject->getReseller()?->getId()->equals($reseller->getId()) === true;
}
}

View File

@ -9,6 +9,7 @@ use App\Entity\ContactLink;
use App\Entity\Domain; use App\Entity\Domain;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\Location; use App\Entity\Location;
use App\Entity\Product;
use App\Security\TenantContext; use App\Security\TenantContext;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
@ -56,6 +57,8 @@ final class TenantStampProcessor implements ProcessorInterface
match (true) { match (true) {
$data instanceof Company => $data->setReseller($reseller), $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 Location,
$data instanceof Domain => $this->assertCompany($data->getCompany()), $data instanceof Domain => $this->assertCompany($data->getCompany()),
$data instanceof Employee => $this->assertEmployee($data), $data instanceof Employee => $this->assertEmployee($data),
@ -94,6 +97,7 @@ final class TenantStampProcessor implements ProcessorInterface
private function isTenantOwned(mixed $data): bool private function isTenantOwned(mixed $data): bool
{ {
return $data instanceof Company return $data instanceof Company
|| $data instanceof Product
|| $data instanceof Location || $data instanceof Location
|| $data instanceof Domain || $data instanceof Domain
|| $data instanceof Employee || $data instanceof Employee

View File

@ -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. 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** Erweiterung über die digitale Visitenkarte hinaus: eine **Arbeitszeiterfassung**
für die Firmenkunden der Reseller (vorzugsweise Druckshops). Der Mitarbeiter ist für die Firmenkunden der Reseller (vorzugsweise Druckshops). Der Mitarbeiter ist