Add StateHistory and StateEnum entities to track ticket state changes

Integrated the `StateHistory` entity to manage state transitions in tickets and the `StateEnum` for defining state values (`open`, `closed`). Updated `Ticket` to handle associations with state histories and provide state management methods. Added migration for `state_history` table and extended `TicketTest` for state-related tests.
This commit is contained in:
Julien Fastré 2025-06-03 12:19:52 +02:00
parent 7633e587bb
commit 2b99a480ac
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
5 changed files with 257 additions and 0 deletions

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Entity;
/**
* Represents the state of a ticket (open or closed).
*/
enum StateEnum: string
{
case OPEN = 'open';
case CLOSED = 'closed';
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Represents the history of a state associated with a ticket.
*
* This entity is used to track the changes in a ticket's state over time.
* Implements the TrackCreationInterface for tracking entity lifecycle creation.
*/
#[ORM\Entity]
#[ORM\Table(name: 'state_history', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_state_history' => StateHistory::class])]
class StateHistory implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
#[Serializer\Groups(['read'])]
private ?\DateTimeImmutable $endDate = null;
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: false, enumType: StateEnum::class)]
#[Serializer\Groups(['read'])]
private StateEnum $state,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
#[Serializer\Groups(['read'])]
private \DateTimeImmutable $startDate = new \DateTimeImmutable('now'),
) {
$ticket->addStateHistory($this);
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getState(): StateEnum
{
return $this->state;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getTicket(): Ticket
{
return $this->ticket;
}
public function setEndDate(?\DateTimeImmutable $endDate): void
{
$this->endDate = $endDate;
}
}

View File

@ -37,6 +37,7 @@ use Doctrine\ORM\Mapping as ORM;
* - association between the ticket and motive: @see{MotiveHistory}; * - association between the ticket and motive: @see{MotiveHistory};
* - association between the ticket and addresses: @see{AddresseeHistory}; * - association between the ticket and addresses: @see{AddresseeHistory};
* - association between the ticket and input: @see{InputHistory}; * - association between the ticket and input: @see{InputHistory};
* - association between the ticket and state: @see{StateHistory};
*/ */
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'ticket', schema: 'chill_ticket')] #[ORM\Table(name: 'ticket', schema: 'chill_ticket')]
@ -83,6 +84,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
#[ORM\OneToMany(targetEntity: PersonHistory::class, mappedBy: 'ticket')] #[ORM\OneToMany(targetEntity: PersonHistory::class, mappedBy: 'ticket')]
private Collection $personHistories; private Collection $personHistories;
/**
* @var Collection<int, StateHistory>
*/
#[ORM\OneToMany(targetEntity: StateHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])]
private Collection $stateHistories;
public function __construct() public function __construct()
{ {
$this->addresseeHistory = new ArrayCollection(); $this->addresseeHistory = new ArrayCollection();
@ -90,6 +97,7 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
$this->motiveHistories = new ArrayCollection(); $this->motiveHistories = new ArrayCollection();
$this->personHistories = new ArrayCollection(); $this->personHistories = new ArrayCollection();
$this->inputHistories = new ArrayCollection(); $this->inputHistories = new ArrayCollection();
$this->stateHistories = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@ -225,4 +233,31 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
{ {
return $this->addresseeHistory; return $this->addresseeHistory;
} }
/**
* @internal use @see{StateHistory::__construct} instead
*/
public function addStateHistory(StateHistory $stateHistory): void
{
$this->stateHistories->add($stateHistory);
}
public function getState(): ?StateEnum
{
foreach ($this->stateHistories as $stateHistory) {
if (null === $stateHistory->getEndDate()) {
return $stateHistory->getState();
}
}
return null;
}
/**
* @return ReadableCollection<int, StateHistory>
*/
public function getStateHistories(): ReadableCollection
{
return $this->stateHistories;
}
} }

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Ticket;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250603085035 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a state to tickets';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE SEQUENCE chill_ticket.state_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE chill_ticket.state_history (id INT NOT NULL, ticket_id INT NOT NULL,
endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, state VARCHAR(255) NOT NULL,
startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_B1DCD379700047D2 ON chill_ticket.state_history (ticket_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_B1DCD3793174800F ON chill_ticket.state_history (createdBy_id)
SQL);
$this->addSql(<<<'SQL'
COMMENT ON COLUMN chill_ticket.state_history.endDate IS '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
COMMENT ON COLUMN chill_ticket.state_history.startDate IS '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
COMMENT ON COLUMN chill_ticket.state_history.createdAt IS '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.state_history ADD CONSTRAINT FK_B1DCD379700047D2 FOREIGN KEY (ticket_id)
REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.state_history ADD CONSTRAINT FK_B1DCD3793174800F FOREIGN KEY (createdBy_id)
REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.state_history ADD CONSTRAINT ticket_state_not_overlaps
exclude using gist (ticket_id with =, tsrange(startdate, enddate) with &&)
deferrable initially deferred
SQL);
$this->addSql(<<<'SQL'
INSERT INTO chill_ticket.state_history (id, ticket_id, state, startDate, createdAt, createdBy_id)
SELECT nextval('chill_ticket.state_history_id_seq'), id, 'open', createdAt, createdAt, createdBy_id
FROM chill_ticket.ticket
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
DROP SEQUENCE chill_ticket.state_history_id_seq CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.state_history DROP CONSTRAINT FK_B1DCD379700047D2
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.state_history DROP CONSTRAINT FK_B1DCD3793174800F
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.state_history DROP CONSTRAINT ticket_state_not_overlaps
SQL);
$this->addSql(<<<'SQL'
DROP TABLE chill_ticket.state_history
SQL);
}
}

View File

@ -18,6 +18,8 @@ use Chill\TicketBundle\Entity\AddresseeHistory;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\StateEnum;
use Chill\TicketBundle\Entity\StateHistory;
use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Entity\Ticket;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@ -108,4 +110,29 @@ class TicketTest extends KernelTestCase
// Verify that getExternalRef returns the updated value // Verify that getExternalRef returns the updated value
self::assertSame($newExternalRef, $ticket->getExternalRef()); self::assertSame($newExternalRef, $ticket->getExternalRef());
} }
public function testGetState(): void
{
$ticket = new Ticket();
$openState = new StateEnum(StateEnum::OPEN, ['en' => 'Open', 'fr' => 'Ouvert']);
// Initially, the ticket has no state
self::assertNull($ticket->getState());
// Create a state history entry with the open state
$history = new StateHistory($openState, $ticket);
// Verify that the ticket now has the open state
self::assertSame($openState, $ticket->getState());
self::assertCount(1, $ticket->getStateHistories());
// Change the state to closed
$closedState = new StateEnum(StateEnum::CLOSED, ['en' => 'Closed', 'fr' => 'Fermé']);
$history->setEndDate(new \DateTimeImmutable());
$history2 = new StateHistory($closedState, $ticket);
// Verify that the ticket now has the closed state
self::assertCount(2, $ticket->getStateHistories());
self::assertSame($closedState, $ticket->getState());
}
} }