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:
@@ -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'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Directory module: create the prospect table.
|
||||
*
|
||||
* Purely additive — creates a brand-new table with nullable FKs:
|
||||
* converted_client_id -> 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
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\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Infrastructure\ApiPlatform\State\ConvertProspectProcessor;
|
||||
use App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[Auditable]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Post(
|
||||
uriTemplate: '/prospects/{id}/convert',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: ConvertProspectProcessor::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Domain\Enum;
|
||||
|
||||
enum ProspectStatus: string
|
||||
{
|
||||
case New = 'new';
|
||||
case Contacted = 'contacted';
|
||||
case Qualified = 'qualified';
|
||||
case Won = 'won';
|
||||
case Lost = 'lost';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::New => 'Nouveau',
|
||||
self::Contacted => 'Contacté',
|
||||
self::Qualified => 'Qualifié',
|
||||
self::Won => 'Gagné',
|
||||
self::Lost => 'Perdu',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Domain\Repository;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
|
||||
interface ProspectRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Prospect;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $criteria
|
||||
* @param null|array<string, string> $orderBy
|
||||
*
|
||||
* @return Prospect[]
|
||||
*/
|
||||
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Directory\Domain\Entity\Client;
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Converts a Prospect into a Client.
|
||||
*
|
||||
* Loads the Prospect via the URI id, creates a Client (name = company or name,
|
||||
* copying contact details), links it back via convertedClient and flags the
|
||||
* prospect as Won. Idempotent: if already converted, returns it unchanged.
|
||||
*
|
||||
* @implements ProcessorInterface<Prospect, Prospect>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Prospect>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Entity\Client;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'convert-prospect', description: 'Convert a prospect into a client (admin). Idempotent: returns the prospect unchanged if already converted.')]
|
||||
class ConvertProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-prospect', description: 'Create a prospect (admin). Only name is required. Status defaults to "new".')]
|
||||
class CreateProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $name,
|
||||
?string $company = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
?string $street = null,
|
||||
?string $city = null,
|
||||
?string $postalCode = null,
|
||||
?string $status = null,
|
||||
?string $source = null,
|
||||
?string $notes = null,
|
||||
): string {
|
||||
if (!$this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-prospect', description: 'Delete a prospect (admin).')]
|
||||
class DeleteProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->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)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID with full details.')]
|
||||
class GetProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'list-prospects', description: 'List prospects, optionally filtered by status (new, contacted, qualified, won, lost).')]
|
||||
class ListProspectsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(?string $status = null): string
|
||||
{
|
||||
if (!$this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-prospect', description: 'Update a prospect (admin). Only provided fields change.')]
|
||||
class UpdateProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $name = null,
|
||||
?string $company = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
?string $street = null,
|
||||
?string $city = null,
|
||||
?string $postalCode = null,
|
||||
?string $status = null,
|
||||
?string $source = null,
|
||||
?string $notes = null,
|
||||
): string {
|
||||
if (!$this->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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user