feat(directory) : add Prospect entity with conversion to Client (back)

LST-58 (2.4), part 2 — Prospect (new entity). Completes the Directory backend.

- ProspectStatus enum (new/contacted/qualified/won/lost) + Prospect entity
  (name, company, email, phone, address, status, source, notes,
  convertedClient -> ClientInterface) with Timestampable/Blamable + #[Auditable].
- API: GetCollection/Get (ROLE_USER), Post/Patch/Delete (ROLE_ADMIN),
  custom POST /prospects/{id}/convert (ConvertProspectProcessor: creates a
  Client from the prospect, links convertedClient, sets status=Won; idempotent).
  SearchFilter on status.
- Repository interface + Doctrine impl (bound); 6 MCP tools (list/get/create/
  update/delete/convert-prospect); Serializer::prospect(). Module perms
  directory.prospects.view/manage. Demo fixtures (3 prospects, one converted).
- Additive migration: CREATE TABLE prospect + FKs ON DELETE SET NULL + COMMENT.

163 tests green (incl. conversion test), mapping valid, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 19:09:12 +02:00
parent c5738d269b
commit d42b288434
17 changed files with 906 additions and 0 deletions
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Directory;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Enum\ProspectStatus;
use App\Module\Directory\Infrastructure\ApiPlatform\State\ConvertProspectProcessor;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
class ProspectConversionTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->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),
);
}
}