src/Entity/SharedEventCode.php line 21

  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Entity;
  4. use App\Entity\User\EmailB2B;
  5. use App\Repository\SharedEventCodeRepository;
  6. use Doctrine\DBAL\Types\Types;
  7. use Doctrine\ORM\Mapping as ORM;
  8. use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
  9. use Symfony\Component\Validator\Constraints as Assert;
  10. use Symfony\Component\Validator\Context\ExecutionContextInterface;
  11. /**
  12.  * Code événement partagé : même code pour tous les participants, période de validité, tarification paramétrable.
  13.  */
  14. #[ORM\Entity(repositoryClass: SharedEventCodeRepository::class)]
  15. #[ORM\Table(name: 'app_shared_event_code')]
  16. #[UniqueEntity('code')]
  17. class SharedEventCode
  18. {
  19.     /** Fuseau métier pour les bornes début/fin (saisie BO + comparaison API). */
  20.     public const VALIDITY_TIMEZONE = 'Europe/Paris';
  21.     public const PRICING_OFFERED = 'offered';
  22.     public const PRICING_NORMAL = 'normal';
  23.     public const PRICING_SPECIAL = 'special';
  24.     public const FRAME_10X15 = '10x15';
  25.     public const FRAME_15X10 = '15x10';
  26.     #[ORM\Id]
  27.     #[ORM\GeneratedValue]
  28.     #[ORM\Column]
  29.     private ?int $id = null;
  30.     #[ORM\Column(length: 50)]
  31.     #[Assert\NotBlank]
  32.     #[Assert\Length(min: 1, max: 10)]
  33.     #[Assert\Regex(pattern: '/^[A-Za-z0-9]+$/')]
  34.     private ?string $code = null;
  35.     #[ORM\ManyToOne]
  36.     #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
  37.     private ?EmailB2B $emailB2B = null;
  38.     /** Événement B2B « classique » (admin Code événement) — pour rattacher le code partagé au bon client / campagne. */
  39.     #[ORM\ManyToOne]
  40.     #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
  41.     private ?CodeEvent $codeEvent = null;
  42.     #[ORM\Column(length: 255)]
  43.     #[Assert\NotBlank]
  44.     private ?string $name = null;
  45.     #[ORM\Column(length: 255)]
  46.     #[Assert\NotBlank]
  47.     private ?string $nameInApp = null;
  48.     #[ORM\Column(length: 255, nullable: true)]
  49.     private ?string $companyName = null;
  50.     #[ORM\Column(type: Types::DATETIME_MUTABLE)]
  51.     #[Assert\NotNull]
  52.     private ?\DateTimeInterface $startAt = null;
  53.     #[ORM\Column(type: Types::DATETIME_MUTABLE)]
  54.     #[Assert\NotNull]
  55.     private ?\DateTimeInterface $endAt = null;
  56.     #[ORM\Column(length: 8)]
  57.     #[Assert\Choice(choices: [self::FRAME_10X15, self::FRAME_15X10])]
  58.     private string $frameFormat = self::FRAME_10X15;
  59.     public const CAPACITY_FREE = 'free';
  60.     /**
  61.      * Capacité initiale de la pellicule créée pour ce code (nombre de photos). Si [sizeLocked] est faux,
  62.      * le participant peut ensuite l’étendre dans l’app comme une pellicule B2C (12 → 24 → 36).
  63.      */
  64.     #[ORM\Column]
  65.     #[Assert\Choice(choices: [12, 24, 36])]
  66.     private int $size = 12;
  67.     /** Capacité bloquée : l’app n’autorise ni extension ni réduction de la pellicule. */
  68.     #[ORM\Column(options: ['default' => false])]
  69.     private bool $sizeLocked = false;
  70.     #[ORM\Column(length: 255)]
  71.     private string $country = 'FR';
  72.     #[ORM\Column(length: 255)]
  73.     private string $filenameLogo = '';
  74.     #[ORM\Column(length: 255, nullable: true)]
  75.     private ?string $filenameTemplate = null;
  76.     #[ORM\Column(length: 255, nullable: true)]
  77.     private ?string $filenameTemplateVertical = null;
  78.     #[ORM\Column(length: 255, nullable: true)]
  79.     private ?string $filenameAddonPhoto = null;
  80.     #[ORM\Column(length: 20)]
  81.     #[Assert\Choice(choices: [self::PRICING_OFFERED, self::PRICING_NORMAL, self::PRICING_SPECIAL])]
  82.     private string $pricingType = self::PRICING_OFFERED;
  83.     /**
  84.      * @deprecated Préférer specialDiscountPercent. Conservé pour d’anciens codes encore en prix fixe.
  85.      */
  86.     #[ORM\Column(nullable: true)]
  87.     private ?int $specialPriceCents = null;
  88.     /** Réduction en % sur le prix direct produit (1–100) si pricingType = special */
  89.     #[ORM\Column(nullable: true)]
  90.     #[Assert\Range(min: 1, max: 100)]
  91.     private ?int $specialDiscountPercent = null;
  92.     /** Première fois qu’un participant a utilisé le code (rattachement B2B ou commande). */
  93.     #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
  94.     private ?\DateTimeImmutable $firstUsedAt = null;
  95.     public function getId(): ?int
  96.     {
  97.         return $this->id;
  98.     }
  99.     public function getCode(): ?string
  100.     {
  101.         return $this->code;
  102.     }
  103.     public function setCode(string $code): self
  104.     {
  105.         $this->code = strtoupper(trim($code));
  106.         return $this;
  107.     }
  108.     public function getEmailB2B(): ?EmailB2B
  109.     {
  110.         return $this->emailB2B;
  111.     }
  112.     public function setEmailB2B(?EmailB2B $emailB2B): self
  113.     {
  114.         $this->emailB2B = $emailB2B;
  115.         return $this;
  116.     }
  117.     public function getCodeEvent(): ?CodeEvent
  118.     {
  119.         return $this->codeEvent;
  120.     }
  121.     public function setCodeEvent(?CodeEvent $codeEvent): self
  122.     {
  123.         $this->codeEvent = $codeEvent;
  124.         return $this;
  125.     }
  126.     public function getName(): ?string
  127.     {
  128.         return $this->name;
  129.     }
  130.     public function setName(string $name): self
  131.     {
  132.         $this->name = $name;
  133.         return $this;
  134.     }
  135.     public function getNameInApp(): ?string
  136.     {
  137.         return $this->nameInApp;
  138.     }
  139.     public function setNameInApp(string $nameInApp): self
  140.     {
  141.         $this->nameInApp = $nameInApp;
  142.         return $this;
  143.     }
  144.     public function getCompanyName(): ?string
  145.     {
  146.         return $this->companyName;
  147.     }
  148.     public function setCompanyName(?string $companyName): self
  149.     {
  150.         $this->companyName = $companyName;
  151.         return $this;
  152.     }
  153.     public function getStartAt(): ?\DateTimeInterface
  154.     {
  155.         return $this->startAt;
  156.     }
  157.     public function setStartAt(\DateTimeInterface $startAt): self
  158.     {
  159.         $this->startAt = $startAt;
  160.         return $this;
  161.     }
  162.     public function getEndAt(): ?\DateTimeInterface
  163.     {
  164.         return $this->endAt;
  165.     }
  166.     public function setEndAt(\DateTimeInterface $endAt): self
  167.     {
  168.         $this->endAt = $endAt;
  169.         return $this;
  170.     }
  171.     public function getFrameFormat(): string
  172.     {
  173.         return $this->frameFormat;
  174.     }
  175.     public function setFrameFormat(string $frameFormat): self
  176.     {
  177.         $this->frameFormat = $frameFormat;
  178.         return $this;
  179.     }
  180.     public function getSize(): int
  181.     {
  182.         return $this->size;
  183.     }
  184.     public function setSize(int $size): self
  185.     {
  186.         $this->size = $size;
  187.         return $this;
  188.     }
  189.     public function isSizeLocked(): bool
  190.     {
  191.         return $this->sizeLocked;
  192.     }
  193.     public function setSizeLocked(bool $sizeLocked): self
  194.     {
  195.         $this->sizeLocked = $sizeLocked;
  196.         return $this;
  197.     }
  198.     /**
  199.      * Choix BO unique « 12 / 24 / 36 / Libre » : un nombre = capacité bloquée à ce palier,
  200.      * « libre » = pellicule 12 extensible par le participant.
  201.      */
  202.     public function getCapacityChoice(): string
  203.     {
  204.         return $this->sizeLocked ? (string) $this->size : self::CAPACITY_FREE;
  205.     }
  206.     public function setCapacityChoice(string $choice): self
  207.     {
  208.         if ($choice === self::CAPACITY_FREE) {
  209.             $this->size = 12;
  210.             $this->sizeLocked = false;
  211.         } else {
  212.             $this->size = (int) $choice;
  213.             $this->sizeLocked = true;
  214.         }
  215.         return $this;
  216.     }
  217.     public function getCountry(): string
  218.     {
  219.         return $this->country;
  220.     }
  221.     public function setCountry(string $country): self
  222.     {
  223.         $this->country = $country;
  224.         return $this;
  225.     }
  226.     public function getFilenameLogo(): string
  227.     {
  228.         return $this->filenameLogo;
  229.     }
  230.     public function setFilenameLogo(string $filenameLogo): self
  231.     {
  232.         $this->filenameLogo = $filenameLogo;
  233.         return $this;
  234.     }
  235.     public function getFilenameTemplate(): ?string
  236.     {
  237.         return $this->filenameTemplate;
  238.     }
  239.     public function setFilenameTemplate(?string $filenameTemplate): self
  240.     {
  241.         $this->filenameTemplate = $filenameTemplate;
  242.         return $this;
  243.     }
  244.     public function getFilenameTemplateVertical(): ?string
  245.     {
  246.         return $this->filenameTemplateVertical;
  247.     }
  248.     public function setFilenameTemplateVertical(?string $filenameTemplateVertical): self
  249.     {
  250.         $this->filenameTemplateVertical = $filenameTemplateVertical;
  251.         return $this;
  252.     }
  253.     public function getFilenameAddonPhoto(): ?string
  254.     {
  255.         return $this->filenameAddonPhoto;
  256.     }
  257.     public function setFilenameAddonPhoto(?string $filenameAddonPhoto): self
  258.     {
  259.         $this->filenameAddonPhoto = $filenameAddonPhoto;
  260.         return $this;
  261.     }
  262.     public function getPricingType(): string
  263.     {
  264.         return $this->pricingType;
  265.     }
  266.     public function setPricingType(string $pricingType): self
  267.     {
  268.         $this->pricingType = $pricingType;
  269.         return $this;
  270.     }
  271.     public function getSpecialPriceCents(): ?int
  272.     {
  273.         return $this->specialPriceCents;
  274.     }
  275.     public function setSpecialPriceCents(?int $specialPriceCents): self
  276.     {
  277.         $this->specialPriceCents = $specialPriceCents;
  278.         return $this;
  279.     }
  280.     public function getSpecialDiscountPercent(): ?int
  281.     {
  282.         return $this->specialDiscountPercent;
  283.     }
  284.     public function setSpecialDiscountPercent(?int $specialDiscountPercent): self
  285.     {
  286.         $this->specialDiscountPercent = $specialDiscountPercent;
  287.         return $this;
  288.     }
  289.     /**
  290.      * Les dates en base (DATETIME) et le formulaire admin sont interprétées comme heure locale France.
  291.      * Compare à l’instant courant dans ce même fuseau, pour éviter les décalages si PHP/serveur est en UTC.
  292.      */
  293.     public function isValidAt(?\DateTimeInterface $now = null): bool
  294.     {
  295.         $start = $this->startAt;
  296.         $end = $this->endAt;
  297.         if ($start === null || $end === null) {
  298.             return false;
  299.         }
  300.         $tz = new \DateTimeZone(self::VALIDITY_TIMEZONE);
  301.         $startParis = new \DateTimeImmutable($start->format('Y-m-d H:i:s'), $tz);
  302.         $endParis = new \DateTimeImmutable($end->format('Y-m-d H:i:s'), $tz);
  303.         $nowParis = $now === null
  304.             ? new \DateTimeImmutable('now', $tz)
  305.             : \DateTimeImmutable::createFromInterface($now)->setTimezone($tz);
  306.         return $startParis <= $nowParis && $nowParis <= $endParis;
  307.     }
  308.     public function getFirstUsedAt(): ?\DateTimeImmutable
  309.     {
  310.         return $this->firstUsedAt;
  311.     }
  312.     public function setFirstUsedAt(?\DateTimeImmutable $firstUsedAt): self
  313.     {
  314.         $this->firstUsedAt = $firstUsedAt;
  315.         return $this;
  316.     }
  317.     /** Enregistre la première utilisation (idempotent). */
  318.     public function markFirstUseIfUnset(): void
  319.     {
  320.         if ($this->firstUsedAt !== null) {
  321.             return;
  322.         }
  323.         $this->firstUsedAt = new \DateTimeImmutable();
  324.     }
  325.     public function isUsed(): bool
  326.     {
  327.         return $this->firstUsedAt !== null;
  328.     }
  329.     #[Assert\Callback]
  330.     public function validateSpecialPricing(ExecutionContextInterface $context): void
  331.     {
  332.         if (self::PRICING_SPECIAL !== $this->pricingType) {
  333.             return;
  334.         }
  335.         $okPercent = $this->specialDiscountPercent !== null
  336.             && $this->specialDiscountPercent >= 1
  337.             && $this->specialDiscountPercent <= 100;
  338.         $okLegacy = $this->specialPriceCents !== null && $this->specialPriceCents > 0;
  339.         if (!$okPercent && !$okLegacy) {
  340.             $context->buildViolation('Pour le tarif spécial, renseignez une réduction entre 1 et 100 %.')
  341.                 ->atPath('specialDiscountPercent')
  342.                 ->addViolation();
  343.         }
  344.     }
  345. }