src/Entity/Cvs/JobPublication.php line 51

Open in your IDE?
  1. <?php
  2. namespace App\Entity\Cvs;
  3. use Doctrine\ORM\Mapping as ORM;
  4. /**
  5. * =============================================================================
  6. * JobPublication — le cycle de vie d'une offre
  7. * =============================================================================
  8. *
  9. * ⚠️ UNE TABLE À PART, ET C'EST DÉLIBÉRÉ.
  10. *
  11. * On aurait pu ajouter une colonne `expires_at` à `cvs_jobs`. Techniquement,
  12. * une colonne NULLABLE est additive : rien n'aurait cassé, ni l'app mobile,
  13. * ni Grapajobs.
  14. *
  15. * Mais tu as demandé « sans modifier », et je le prends au mot : `Jobs.php`
  16. * n'est pas touché. Zéro ligne. Ce qui rend impossible, par construction, de
  17. * casser ce qui marche.
  18. *
  19. * ⚠️ ET CETTE TABLE APPORTE CE QUE `Jobs` N'A PAS.
  20. *
  21. * `Jobs` a `createdAt` et `updatedAt`. Il n'a PAS de date de PUBLICATION.
  22. *
  23. * Ce n'est pas la même chose : une offre peut être créée en brouillon le 3
  24. * janvier, publiée le 12, modifiée le 20. `updatedAt` dit « le 20 », et
  25. * compter 90 jours à partir de là ferait vivre l'offre plus longtemps à
  26. * chaque correction de faute de frappe.
  27. *
  28. * Google, lui, veut `datePosted` — la vraie. Et `validThrough` — la vraie.
  29. *
  30. * · published_at → `datePosted`
  31. * · expires_at → `validThrough`
  32. * · renewed_at → un renouvellement est une décision, pas un effet de bord
  33. *
  34. * ⚠️ L'INDEX LE PLUS RENTABLE DE TOUT LE SCHÉMA.
  35. *
  36. * `excludeExpired` ajoute « il n'existe pas de publication expirée pour
  37. * cette offre » à CHAQUE requête publique — liste, recherche, landing,
  38. * accueil. Sans index sur (job_id, expires_at), cette sous-requête
  39. * relit la table pour chaque offre examinée.
  40. *
  41. * @ORM\Table(name="cvs_job_publication", indexes={
  42. * @ORM\Index(name="idx_pub_job_expires", columns={"job_id", "expires_at"}),
  43. * @ORM\Index(name="idx_pub_closed", columns={"closed_reason"})
  44. * })
  45. * @ORM\Entity(repositoryClass="App\Repository\Cvs\JobPublicationRepository")
  46. * =============================================================================
  47. */
  48. class JobPublication
  49. {
  50. /**
  51. * @ORM\Column(name="id", type="integer")
  52. * @ORM\Id
  53. * @ORM\GeneratedValue(strategy="IDENTITY")
  54. */
  55. private $id;
  56. /**
  57. * ⚠️ OneToOne : une offre, une publication.
  58. *
  59. * `onDelete="CASCADE"` : si l'offre disparaît, sa publication n'a plus de
  60. * sens. Une ligne orpheline ici, c'est un `validThrough` qui pointe vers
  61. * une annonce qui n'existe plus.
  62. *
  63. * @ORM\OneToOne(targetEntity="App\Entity\Cvs\Jobs")
  64. * @ORM\JoinColumn(name="job_id", referencedColumnName="id", nullable=false, onDelete="CASCADE", unique=true)
  65. */
  66. private $job;
  67. /**
  68. * La date de PUBLICATION. Pas de création, pas de modification.
  69. *
  70. * C'est `datePosted` dans le JSON-LD que lit Google.
  71. *
  72. * @ORM\Column(name="published_at", type="datetime")
  73. */
  74. private $publishedAt;
  75. /**
  76. * La date d'expiration. `validThrough` pour Google.
  77. *
  78. * ⚠️ SANS ELLE, GOOGLE FOR JOBS DÉCLASSE OU RETIRE L'ANNONCE.
  79. *
  80. * C'est le champ que Google utilise pour savoir si une offre est encore
  81. * valide. Une annonce sans `validThrough` est traitée comme suspecte —
  82. * et pour un site d'emploi, Google Jobs représente couramment un tiers à
  83. * la moitié du trafic.
  84. *
  85. * @ORM\Column(name="expires_at", type="datetime")
  86. */
  87. private $expiresAt;
  88. /**
  89. * Le dernier renouvellement. `null` si jamais renouvelée.
  90. *
  91. * @ORM\Column(name="renewed_at", type="datetime", nullable=true)
  92. */
  93. private $renewedAt;
  94. /**
  95. * Combien de fois. Un compteur, pas une opinion : il dira un jour si les
  96. * offres qu'on renouvelle sans arrêt sont celles qui ne recrutent jamais.
  97. *
  98. * @ORM\Column(name="renew_count", type="integer", options={"default": 0})
  99. */
  100. private $renewCount = 0;
  101. /**
  102. * Pourquoi elle n'est plus en ligne, si elle ne l'est plus.
  103. *
  104. * 'expired' → les 90 jours sont passés
  105. * 'filled' → le poste est pourvu (le recruteur l'a dit)
  106. * 'closed' → le recruteur l'a retirée
  107. *
  108. * ⚠️ Un poste POURVU et un poste EXPIRÉ ne se disent pas pareil au
  109. * candidat. « Cette offre a expiré » et « Ce poste a été pourvu » ne
  110. * produisent pas la même émotion — ni la même action.
  111. *
  112. * @ORM\Column(name="closed_reason", type="string", length=16, nullable=true)
  113. */
  114. private $closedReason;
  115. /**
  116. * @ORM\Column(name="created_at", type="datetime")
  117. */
  118. private $createdAt;
  119. /**
  120. * @ORM\Column(name="updated_at", type="datetime")
  121. */
  122. private $updatedAt;
  123. // =========================================================================
  124. public function getId(): ?int
  125. {
  126. return $this->id;
  127. }
  128. public function getJob(): ?Jobs
  129. {
  130. return $this->job;
  131. }
  132. public function setJob(Jobs $job): self
  133. {
  134. $this->job = $job;
  135. return $this;
  136. }
  137. public function getPublishedAt(): ?\DateTimeInterface
  138. {
  139. return $this->publishedAt;
  140. }
  141. public function setPublishedAt(\DateTimeInterface $publishedAt): self
  142. {
  143. $this->publishedAt = $publishedAt;
  144. return $this;
  145. }
  146. public function getExpiresAt(): ?\DateTimeInterface
  147. {
  148. return $this->expiresAt;
  149. }
  150. public function setExpiresAt(\DateTimeInterface $expiresAt): self
  151. {
  152. $this->expiresAt = $expiresAt;
  153. return $this;
  154. }
  155. public function getRenewedAt(): ?\DateTimeInterface
  156. {
  157. return $this->renewedAt;
  158. }
  159. public function setRenewedAt(?\DateTimeInterface $renewedAt): self
  160. {
  161. $this->renewedAt = $renewedAt;
  162. return $this;
  163. }
  164. public function getRenewCount(): int
  165. {
  166. return (int) $this->renewCount;
  167. }
  168. public function setRenewCount(int $renewCount): self
  169. {
  170. $this->renewCount = $renewCount;
  171. return $this;
  172. }
  173. public function getClosedReason(): ?string
  174. {
  175. return $this->closedReason;
  176. }
  177. public function setClosedReason(?string $closedReason): self
  178. {
  179. $this->closedReason = $closedReason;
  180. return $this;
  181. }
  182. public function getCreatedAt(): ?\DateTimeInterface
  183. {
  184. return $this->createdAt;
  185. }
  186. public function setCreatedAt(\DateTimeInterface $createdAt): self
  187. {
  188. $this->createdAt = $createdAt;
  189. return $this;
  190. }
  191. public function getUpdatedAt(): ?\DateTimeInterface
  192. {
  193. return $this->updatedAt;
  194. }
  195. public function setUpdatedAt(\DateTimeInterface $updatedAt): self
  196. {
  197. $this->updatedAt = $updatedAt;
  198. return $this;
  199. }
  200. // =========================================================================
  201. /** L'offre a-t-elle dépassé sa date ? */
  202. public function isExpired(): bool
  203. {
  204. return $this->expiresAt < new \DateTime();
  205. }
  206. /**
  207. * Combien de jours reste-t-il ?
  208. *
  209. * Négatif si l'offre est expirée — on ne renvoie pas 0. La différence
  210. * compte : « expirée depuis 40 jours » et « expirée hier » n'appellent pas
  211. * la même décision.
  212. */
  213. public function daysLeft(): int
  214. {
  215. $diff = (new \DateTime())->diff($this->expiresAt);
  216. $days = (int) $diff->days;
  217. return $diff->invert ? -$days : $days;
  218. }
  219. }