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:
parent
2dc40c6ea5
commit
f5807aefce
39
backend/migrations/Version20260602123707.php
Normal file
39
backend/migrations/Version20260602123707.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
272
backend/src/Entity/Product.php
Normal file
272
backend/src/Entity/Product.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/src/Repository/ProductRepository.php
Normal file
18
backend/src/Repository/ProductRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/src/Security/Voter/ProductVoter.php
Normal file
43
backend/src/Security/Voter/ProductVoter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user