diff --git a/config/services.yaml b/config/services.yaml index cb54a66..e3625b1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -101,4 +101,6 @@ services: App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository' + App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository' + App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier' diff --git a/migrations/Version20260620190000.php b/migrations/Version20260620190000.php new file mode 100644 index 0000000..26f5306 --- /dev/null +++ b/migrations/Version20260620190000.php @@ -0,0 +1,48 @@ + client(id) ON DELETE SET NULL + * created_by/updated_by -> "user"(id) ON DELETE SET NULL (Blamable) + * No DROP/ALTER on existing data. Columns are lowercase snake_case. + * Hand-written to mirror the schema dump and guarantee zero destructive + * instruction. down() drops the new table. + */ +final class Version20260620190000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Directory: create prospect table (additive)'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE prospect (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, company VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, street VARCHAR(255) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, postal_code VARCHAR(20) DEFAULT NULL, status VARCHAR(32) NOT NULL, source VARCHAR(255) DEFAULT NULL, notes TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, converted_client_id INT DEFAULT NULL, created_by INT DEFAULT NULL, updated_by INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_C9CE8C7D5AA408DD ON prospect (converted_client_id)'); + $this->addSql('CREATE INDEX IDX_C9CE8C7DDE12AB56 ON prospect (created_by)'); + $this->addSql('CREATE INDEX IDX_C9CE8C7D16FE72E1 ON prospect (updated_by)'); + $this->addSql('ALTER TABLE prospect ADD CONSTRAINT FK_C9CE8C7D5AA408DD FOREIGN KEY (converted_client_id) REFERENCES client (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE prospect ADD CONSTRAINT FK_C9CE8C7DDE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE prospect ADD CONSTRAINT FK_C9CE8C7D16FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql("COMMENT ON COLUMN prospect.status IS 'Prospect pipeline status (ProspectStatus enum: new, contacted, qualified, won, lost)'"); + $this->addSql("COMMENT ON COLUMN prospect.converted_client_id IS 'Client created when the prospect is converted (FK client.id, SET NULL on delete)'"); + $this->addSql("COMMENT ON COLUMN prospect.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'"); + $this->addSql("COMMENT ON COLUMN prospect.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'"); + $this->addSql("COMMENT ON COLUMN prospect.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'"); + $this->addSql("COMMENT ON COLUMN prospect.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE prospect'); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index f52805f..da9cfab 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -15,6 +15,8 @@ use App\Module\Absence\Domain\Enum\AbsenceType; use App\Module\Core\Application\Rbac\RbacSeeder; use App\Module\Core\Domain\Entity\User; use App\Module\Directory\Domain\Entity\Client; +use App\Module\Directory\Domain\Entity\Prospect; +use App\Module\Directory\Domain\Enum\ProspectStatus; use App\Module\ProjectManagement\Domain\Entity\Project; use App\Module\ProjectManagement\Domain\Entity\Task; use App\Module\ProjectManagement\Domain\Entity\TaskEffort; @@ -104,6 +106,43 @@ class AppFixtures extends Fixture $clientNova->setPostalCode('69007'); $manager->persist($clientNova); + // Prospects + $prospectLead = new Prospect(); + $prospectLead->setName('Marie Dupont'); + $prospectLead->setCompany('Atelier Dupont'); + $prospectLead->setEmail('marie@atelier-dupont.fr'); + $prospectLead->setPhone('06 11 22 33 44'); + $prospectLead->setCity('Nantes'); + $prospectLead->setPostalCode('44000'); + $prospectLead->setStatus(ProspectStatus::New); + $prospectLead->setSource('Site web'); + $prospectLead->setNotes('Demande de devis via le formulaire de contact.'); + $manager->persist($prospectLead); + + $prospectQualified = new Prospect(); + $prospectQualified->setName('Jean Martin'); + $prospectQualified->setCompany('Martin & Fils'); + $prospectQualified->setEmail('contact@martin-fils.fr'); + $prospectQualified->setPhone('07 55 66 77 88'); + $prospectQualified->setStreet('22 rue du Commerce'); + $prospectQualified->setCity('Bordeaux'); + $prospectQualified->setPostalCode('33000'); + $prospectQualified->setStatus(ProspectStatus::Qualified); + $prospectQualified->setSource('Salon professionnel'); + $manager->persist($prospectQualified); + + $prospectWon = new Prospect(); + $prospectWon->setName('Sophie Bernard'); + $prospectWon->setCompany('ACME Corp'); + $prospectWon->setEmail('contact@acme.com'); + $prospectWon->setPhone('01 23 45 67 89'); + $prospectWon->setCity('Paris'); + $prospectWon->setPostalCode('75002'); + $prospectWon->setStatus(ProspectStatus::Won); + $prospectWon->setSource('Recommandation'); + $prospectWon->setConvertedClient($clientAcme); + $manager->persist($prospectWon); + // Workflow par défaut $standardWorkflow = new Workflow(); $standardWorkflow->setName('Standard'); diff --git a/src/Mcp/Tool/Serializer.php b/src/Mcp/Tool/Serializer.php index 5111143..2871484 100644 --- a/src/Mcp/Tool/Serializer.php +++ b/src/Mcp/Tool/Serializer.php @@ -9,6 +9,7 @@ use App\Module\Absence\Domain\Entity\AbsencePolicy; use App\Module\Absence\Domain\Entity\AbsenceRequest; use App\Module\Core\Domain\Entity\User; use App\Module\Directory\Domain\Entity\Client; +use App\Module\Directory\Domain\Entity\Prospect; use App\Module\ProjectManagement\Domain\Entity\Project; use App\Module\ProjectManagement\Domain\Entity\Task; use App\Module\ProjectManagement\Domain\Entity\TaskDocument; @@ -372,6 +373,35 @@ final class Serializer ]; } + /** + * @return array + */ + public static function prospect(Prospect $p): array + { + $client = $p->getConvertedClient(); + + return [ + 'id' => $p->getId(), + 'name' => $p->getName(), + 'company' => $p->getCompany(), + 'email' => $p->getEmail(), + 'phone' => $p->getPhone(), + 'street' => $p->getStreet(), + 'city' => $p->getCity(), + 'postalCode' => $p->getPostalCode(), + 'status' => $p->getStatus()->value, + 'statusLabel' => $p->getStatus()->label(), + 'source' => $p->getSource(), + 'notes' => $p->getNotes(), + 'convertedClient' => null === $client ? null : [ + 'id' => $client->getId(), + 'name' => $client->getName(), + ], + 'createdAt' => $p->getCreatedAt()?->format('c'), + 'updatedAt' => $p->getUpdatedAt()?->format('c'), + ]; + } + /** * @return array */ diff --git a/src/Module/Directory/DirectoryModule.php b/src/Module/Directory/DirectoryModule.php index 6ccf2aa..a3b9ecf 100644 --- a/src/Module/Directory/DirectoryModule.php +++ b/src/Module/Directory/DirectoryModule.php @@ -36,6 +36,8 @@ final class DirectoryModule implements ModuleInterface return [ ['code' => 'directory.clients.view', 'label' => 'Voir les clients'], ['code' => 'directory.clients.manage', 'label' => 'Gérer les clients'], + ['code' => 'directory.prospects.view', 'label' => 'Voir les prospects'], + ['code' => 'directory.prospects.manage', 'label' => 'Gérer les prospects'], ]; } } diff --git a/src/Module/Directory/Domain/Entity/Prospect.php b/src/Module/Directory/Domain/Entity/Prospect.php new file mode 100644 index 0000000..2806fd7 --- /dev/null +++ b/src/Module/Directory/Domain/Entity/Prospect.php @@ -0,0 +1,239 @@ + ['prospect:read']], + denormalizationContext: ['groups' => ['prospect:write']], + order: ['name' => 'ASC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['status' => 'exact'])] +#[ORM\Entity(repositoryClass: DoctrineProspectRepository::class)] +#[ORM\Table(name: 'prospect')] +class Prospect implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['prospect:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $name = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $company = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $email = null; + + #[ORM\Column(length: 50, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $phone = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $street = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $city = null; + + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $postalCode = null; + + #[ORM\Column(type: Types::STRING, length: 32, enumType: ProspectStatus::class)] + #[Groups(['prospect:read', 'prospect:write'])] + private ProspectStatus $status = ProspectStatus::New; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $source = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['prospect:read', 'prospect:write'])] + private ?string $notes = null; + + #[ORM\ManyToOne(targetEntity: ClientInterface::class)] + #[ORM\JoinColumn(name: 'converted_client_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['prospect:read'])] + private ?ClientInterface $convertedClient = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getCompany(): ?string + { + return $this->company; + } + + public function setCompany(?string $company): static + { + $this->company = $company; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(?string $phone): static + { + $this->phone = $phone; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): static + { + $this->street = $street; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): static + { + $this->city = $city; + + return $this; + } + + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + public function setPostalCode(?string $postalCode): static + { + $this->postalCode = $postalCode; + + return $this; + } + + public function getStatus(): ProspectStatus + { + return $this->status; + } + + public function setStatus(ProspectStatus $status): static + { + $this->status = $status; + + return $this; + } + + public function getSource(): ?string + { + return $this->source; + } + + public function setSource(?string $source): static + { + $this->source = $source; + + return $this; + } + + public function getNotes(): ?string + { + return $this->notes; + } + + public function setNotes(?string $notes): static + { + $this->notes = $notes; + + return $this; + } + + public function getConvertedClient(): ?ClientInterface + { + return $this->convertedClient; + } + + public function setConvertedClient(?ClientInterface $convertedClient): static + { + $this->convertedClient = $convertedClient; + + return $this; + } +} diff --git a/src/Module/Directory/Domain/Enum/ProspectStatus.php b/src/Module/Directory/Domain/Enum/ProspectStatus.php new file mode 100644 index 0000000..b23c169 --- /dev/null +++ b/src/Module/Directory/Domain/Enum/ProspectStatus.php @@ -0,0 +1,25 @@ + 'Nouveau', + self::Contacted => 'Contacté', + self::Qualified => 'Qualifié', + self::Won => 'Gagné', + self::Lost => 'Perdu', + }; + } +} diff --git a/src/Module/Directory/Domain/Repository/ProspectRepositoryInterface.php b/src/Module/Directory/Domain/Repository/ProspectRepositoryInterface.php new file mode 100644 index 0000000..c4fba0c --- /dev/null +++ b/src/Module/Directory/Domain/Repository/ProspectRepositoryInterface.php @@ -0,0 +1,20 @@ + $criteria + * @param null|array $orderBy + * + * @return Prospect[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; +} diff --git a/src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php b/src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php new file mode 100644 index 0000000..51d801a --- /dev/null +++ b/src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php @@ -0,0 +1,63 @@ + + */ +final readonly class ConvertProspectProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private ProspectRepositoryInterface $prospectRepository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Prospect + { + $id = $uriVariables['id'] ?? null; + $prospect = is_numeric($id) ? $this->prospectRepository->findById((int) $id) : null; + + if (!$prospect instanceof Prospect) { + throw new NotFoundHttpException('Prospect not found.'); + } + + // Idempotent: already converted, return as-is. + if (null !== $prospect->getConvertedClient()) { + return $prospect; + } + + $client = new Client(); + $client->setName($prospect->getCompany() ?: (string) $prospect->getName()); + $client->setEmail($prospect->getEmail()); + $client->setPhone($prospect->getPhone()); + $client->setStreet($prospect->getStreet()); + $client->setCity($prospect->getCity()); + $client->setPostalCode($prospect->getPostalCode()); + + $this->entityManager->persist($client); + + $prospect->setConvertedClient($client); + $prospect->setStatus(ProspectStatus::Won); + + $this->entityManager->flush(); + + return $prospect; + } +} diff --git a/src/Module/Directory/Infrastructure/Doctrine/DoctrineProspectRepository.php b/src/Module/Directory/Infrastructure/Doctrine/DoctrineProspectRepository.php new file mode 100644 index 0000000..7bcf2da --- /dev/null +++ b/src/Module/Directory/Infrastructure/Doctrine/DoctrineProspectRepository.php @@ -0,0 +1,26 @@ + + */ +final class DoctrineProspectRepository extends ServiceEntityRepository implements ProspectRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Prospect::class); + } + + public function findById(int $id): ?Prospect + { + return $this->find($id); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/ConvertProspectTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/ConvertProspectTool.php new file mode 100644 index 0000000..a80cf60 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/ConvertProspectTool.php @@ -0,0 +1,58 @@ +security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); + } + + $prospect = $this->prospectRepository->findById($id); + if (null === $prospect) { + throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id)); + } + + if (null === $prospect->getConvertedClient()) { + $client = new Client(); + $client->setName($prospect->getCompany() ?: (string) $prospect->getName()); + $client->setEmail($prospect->getEmail()); + $client->setPhone($prospect->getPhone()); + $client->setStreet($prospect->getStreet()); + $client->setCity($prospect->getCity()); + $client->setPostalCode($prospect->getPostalCode()); + + $this->entityManager->persist($client); + + $prospect->setConvertedClient($client); + $prospect->setStatus(ProspectStatus::Won); + + $this->entityManager->flush(); + } + + return json_encode(Serializer::prospect($prospect)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/CreateProspectTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateProspectTool.php new file mode 100644 index 0000000..3663fc4 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/CreateProspectTool.php @@ -0,0 +1,66 @@ +security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); + } + + $prospect = new Prospect(); + $prospect->setName($name); + $prospect->setCompany($company); + $prospect->setEmail($email); + $prospect->setPhone($phone); + $prospect->setStreet($street); + $prospect->setCity($city); + $prospect->setPostalCode($postalCode); + $prospect->setSource($source); + $prospect->setNotes($notes); + + if (null !== $status) { + $statusEnum = ProspectStatus::tryFrom($status); + if (null === $statusEnum) { + throw new InvalidArgumentException(sprintf('Invalid status "%s". Allowed: new, contacted, qualified, won, lost.', $status)); + } + $prospect->setStatus($statusEnum); + } + + $this->entityManager->persist($prospect); + $this->entityManager->flush(); + + return json_encode(Serializer::prospect($prospect)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteProspectTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteProspectTool.php new file mode 100644 index 0000000..ab20019 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/DeleteProspectTool.php @@ -0,0 +1,42 @@ +security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); + } + + $prospect = $this->prospectRepository->findById($id); + if (null === $prospect) { + throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id)); + } + + $name = $prospect->getName(); + $this->entityManager->remove($prospect); + $this->entityManager->flush(); + + return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $name)]); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php new file mode 100644 index 0000000..b19e14c --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php @@ -0,0 +1,37 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedException('Access denied: ROLE_USER required.'); + } + + $prospect = $this->prospectRepository->findById($id); + if (null === $prospect) { + throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id)); + } + + return json_encode(Serializer::prospect($prospect)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/ListProspectsTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/ListProspectsTool.php new file mode 100644 index 0000000..671a55b --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/ListProspectsTool.php @@ -0,0 +1,44 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedException('Access denied: ROLE_USER required.'); + } + + $criteria = []; + if (null !== $status) { + $statusEnum = ProspectStatus::tryFrom($status); + if (null === $statusEnum) { + throw new InvalidArgumentException(sprintf('Invalid status "%s". Allowed: new, contacted, qualified, won, lost.', $status)); + } + $criteria['status'] = $statusEnum; + } + + $prospects = $this->prospectRepository->findBy($criteria, ['name' => 'ASC']); + + return json_encode(array_map(static fn ($prospect) => Serializer::prospect($prospect), $prospects)); + } +} diff --git a/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateProspectTool.php b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateProspectTool.php new file mode 100644 index 0000000..e155112 --- /dev/null +++ b/src/Module/Directory/Infrastructure/Mcp/Tool/UpdateProspectTool.php @@ -0,0 +1,88 @@ +security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); + } + + $prospect = $this->prospectRepository->findById($id); + if (null === $prospect) { + throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id)); + } + + if (null !== $name) { + $prospect->setName($name); + } + if (null !== $company) { + $prospect->setCompany($company); + } + if (null !== $email) { + $prospect->setEmail($email); + } + if (null !== $phone) { + $prospect->setPhone($phone); + } + if (null !== $street) { + $prospect->setStreet($street); + } + if (null !== $city) { + $prospect->setCity($city); + } + if (null !== $postalCode) { + $prospect->setPostalCode($postalCode); + } + if (null !== $status) { + $statusEnum = ProspectStatus::tryFrom($status); + if (null === $statusEnum) { + throw new InvalidArgumentException(sprintf('Invalid status "%s". Allowed: new, contacted, qualified, won, lost.', $status)); + } + $prospect->setStatus($statusEnum); + } + if (null !== $source) { + $prospect->setSource($source); + } + if (null !== $notes) { + $prospect->setNotes($notes); + } + + $this->entityManager->flush(); + + return json_encode(Serializer::prospect($prospect)); + } +} diff --git a/tests/Functional/Module/Directory/ProspectConversionTest.php b/tests/Functional/Module/Directory/ProspectConversionTest.php new file mode 100644 index 0000000..852b2f8 --- /dev/null +++ b/tests/Functional/Module/Directory/ProspectConversionTest.php @@ -0,0 +1,77 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testConvertCreatesClientAndFlagsProspectWon(): void + { + $prospect = new Prospect(); + $prospect->setName('Lead Test'); + $prospect->setCompany('Lead Company '.uniqid()); + $prospect->setEmail('lead@example.com'); + $prospect->setPhone('06 00 00 00 00'); + $prospect->setStreet('1 rue du Test'); + $prospect->setCity('Testville'); + $prospect->setPostalCode('00000'); + $prospect->setStatus(ProspectStatus::Qualified); + $this->em->persist($prospect); + $this->em->flush(); + + $result = $this->processor()->process($prospect, new Post(), ['id' => $prospect->getId()]); + + self::assertSame(ProspectStatus::Won, $result->getStatus()); + $client = $result->getConvertedClient(); + self::assertNotNull($client); + self::assertSame($prospect->getCompany(), $client->getName()); + } + + public function testConvertIsIdempotent(): void + { + $prospect = new Prospect(); + $prospect->setName('Idempotent Lead'); + $prospect->setStatus(ProspectStatus::New); + $this->em->persist($prospect); + $this->em->flush(); + + $processor = $this->processor(); + $first = $processor->process($prospect, new Post(), ['id' => $prospect->getId()]); + $clientId = $first->getConvertedClient()?->getId(); + + $second = $processor->process($prospect, new Post(), ['id' => $prospect->getId()]); + + self::assertNotNull($clientId); + self::assertSame($clientId, $second->getConvertedClient()?->getId()); + } + + private function processor(): ConvertProspectProcessor + { + $c = self::getContainer(); + + return new ConvertProspectProcessor( + $c->get(EntityManagerInterface::class), + $c->get(DoctrineProspectRepository::class), + ); + } +}