Les points clés de ce retour d'expérience :
- Refonte du legacy JavaScript : Remplacement d'un script vanilla non typé de près de 300 lignes manipulant localStorage par un composant modulaire TypeScript fortement typé.
- Découplage par DTO & FormType (InventoryDetailsDto) : Isolation de la saisie des compteurs (eau, gaz, électricité), des clés remises et des commentaires par rapport à l'entité Domaine.
- Modélisation par pièce (InventoryImageDTO) : Structure de données arborescente associant à chaque pièce son type, son état de propreté et sa collection de photos horodatées.
- Pipeline d'upload asynchrone : Téléversement en tâche de fond avec contrôle de taille (8 Mo max), filtrage des formats (JPG, PNG, HEIC) et mise à jour temps réel des conteneurs visuels.
- Compilation & Génération PDF : Transformation du DTO de pièces en document PDF scellé via Dompdf et raccordement au visualiseur interactif SignablePdfRenderer.
1. Exigences produit et diagnostic de la dette technique
Côté produit et métier, la réalisation d'un état des lieux contradictoire complet est indispensable pour valider la restitution de caution lors des départs de locataires. Les spécifications exigeaient une saisie dynamique pièce par pièce et l'ajout de justificatifs photographiques.
Sur le plan technique, l'implémentation historique souffrait d'une dette accumulée critique :
- Le script inventory_helper.js stockait l'état des pièces directement dans le localStorage du navigateur sans synchronisation serveur immédiate. En cas de changement de téléphone ou de vidage du cache, le locataire perdait l'intégralité de sa saisie.
- Côté backend, les saisies étaient extraites de tableaux $_POST bruts non typés sans passer par la couche de formulaires Symfony (FormType) ni par des DTOs de transfert, créant un couplage fort entre les requêtes HTTP et l'entité Domaine.
- Les photos étaient envoyées via des identifiants DOM incrémentaux codés en dur (#picbox-1 à #picbox-6), rendant toute réorganisation de pièce impossible sans dupliquer du code JS.
Pour résoudre ces blocages, j'ai entrepris une refonte intégrale en architecturant un composant TypeScript modulaire relié à un formulaire Symfony Form typé (InventoryDetailsType) et son DTO d'hydratation (InventoryDetailsDto).
Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.
2. Analyse du code legacy (Le problème d'origine)
Avant cette refonte, la gestion de l'état des lieux souffrait de faiblesses d'architecture majeures réparties sur les deux couches de l'application :
- Fragilité du frontend (Vanilla JS & localStorage) : Le script historique inventory_helper.js s'appuyait sur une manipulation manuelle et non typée du DOM. Pour conserver temporairement l'état des pièces lors de la navigation, le script écrivait et lisait directement des structures JSON dans le localStorage du navigateur. Cette dépendance entraînait des erreurs JS silencieuses et des désynchronisations dès qu'un utilisateur changeait d'onglet ou vidait son cache.
- Absence de structure backend (Tableaux POST bruts) : Côté serveur, les données d'état des lieux étaient manipulées sous forme de tableaux $_POST bruts non typés sans véritable validation. Aucune couche DTO n'isolait les métadonnées de saisie (compteurs d'eau, gaz, électricité, clés remises) de l'entité Domaine Inventory, rendant les évolutions de modèle périlleuses et le contrôleur inutilement complexe.
Voici l'extrait du code JavaScript legacy que j'ai supprimé, illustrant la dépendance fragile au localStorage et aux sélecteurs DOM non isolés :
// Extrait de l'ancien inventory_helper.js (Legacy supprimé)
static initRooms() {
const _inventoryDiv = document.getElementById('js-inventory-id');
const inventoryId = _inventoryDiv.value;
const inventoryImageUrl = document.getElementById('js-inventory-get-image-url').value;
Ajax.get({
url: inventoryImageUrl,
onResult: (response) => {
const inventoryImages = JSON.parse(response);
for (const roomId in inventoryImages) {
// Dépendance directe et fragile au localStorage
const storage = JSON.parse(localStorage.getItem(inventoryId + roomId));
if (storage) {
const _room = this.addRoom(roomId);
inventoryImages[roomId].forEach((value) => {
_room.querySelector('#image-' + value.position).style.backgroundImage = "url(" + value.file + ")";
_room.querySelector('#add-btn-' + value.position).style.display = 'none';
_room.querySelector('#modify-btn-' + value.position).style.display = 'block';
});
}
}
}
});
}
3. Tableau comparatif : Avant / Après refactoring
| Critère d'architecture | Avant (Legacy JS Vanilla) | Après (Refactorisé TypeScript / Symfony) |
|---|---|---|
| Typage & Modélisation | Objets JSON bruts sans validation, risques d'erreurs d'exécution silencieuses. | Interfaces TypeScript strictes (InventoryImageDTO, InventoryImages). |
| Persistance des pièces | Volatile dans localStorage, désynchronisation fréquente. | Persistance transactionnelle en base via API REST avec synchronisation temps réel. |
| Structure des formulaires | Traitements impératifs et tableaux $_POST bruts non typés. | Formulaire typé (InventoryDetailsType) hydraté par DTO (InventoryDetailsDto). |
| Intégration documentaire | Impression HTML basique sans signature. | Génération Dompdf scellée et intégration du visualiseur interactif signé. |
4. Découplage de la saisie par DTO et Symfony Form (InventoryDetailsDto & InventoryDetailsType)
Pour éviter de lier directement le formulaire de saisie des détails d'état des lieux à l'entité Domaine Inventory, j'ai créé le DTO InventoryDetailsDto. Ce DTO encapsule les relevés de compteurs (eau, gaz, électricité), le nombre de clés remises et les commentaires généraux :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Form\Inventory\Dto;
class InventoryDetailsDto
{
private ?string $waterMeter = null;
private ?string $gasMeter = null;
private ?string $electricityMeter = null;
private ?string $propertyKeys = null;
private ?string $comment = null;
// Getters et setters fluent (getWaterMeter(), setWaterMeter(), etc.)
}
Le type de formulaire InventoryDetailsType s'appuie sur ce DTO en définissant la constante de correspondance INVENTORY_TO_INVENTORY_DETAILS_MAP pour transférer les valeurs de manière bidirectionnelle avec le composant PropertyAccess :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Form\Inventory;
use App\Infrastructure\Form\Inventory\Dto\InventoryDetailsDto;
use ReflectionClass;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
final class InventoryDetailsType extends AbstractType
{
public const INVENTORY_TO_INVENTORY_DETAILS_MAP = [
'waterMeter' => 'waterMeter',
'gasMeter' => 'gasMeter',
'electricityMeter' => 'electricityMeter',
'propertyKeys' => 'propertyKeys',
'comment' => 'comment',
];
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$reflectionInvetoryDetails = new ReflectionClass(InventoryDetailsDto::class);
foreach ($reflectionInvetoryDetails->getProperties() as $property) {
$name = $property->getName();
match ($name) {
'comment' => $builder->add(
$name,
TextareaType::class,
['required' => false]
),
'propertyKeys' => $builder->add(
$name,
ChoiceType::class,
['choices' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 'attr' => ['class' => 'global-select']]
),
default => $builder->add(
$name,
TextType::class,
['required' => false]
),
};
}
}
}
5. Architecture TypeScript du nouveau helper (InventoryHelper)
J'ai développé le composant InventoryHelper.ts en m'appuyant sur des utilitaires DOM stricts (DomUtils.requireOne, DomUtils.requireOneOfType) évitant les erreurs de référence nulle :
import * as ss from 'simple-ajax-uploader'
import Ajax from '@TS/utils/Ajax'
import JsonUtils from '@TS/utils/JsonUtils'
import Notify from '@TS/utils/Notify'
import DomUtils from '@TS/utils/DomUtils'
export interface InventoryImageDTO {
file: string
position: number
roomType: string
roomState: string
}
export type InventoryImages = Record<string, InventoryImageDTO[]>
export default class InventoryHelper {
static initRooms(): void {
const inventoryImageUrl = window.APP_INVENTORY_GET_IMAGE_URL
if (!inventoryImageUrl) {
throw new Error('APP_INVENTORY_GET_IMAGE_URL is not defined')
}
Ajax.get({
url: inventoryImageUrl,
onResult: (response) => {
const inventoryImages = JsonUtils.parse(response, 'getInventoryImage') as InventoryImages
if (0 === Object.keys(inventoryImages).length) {
this.initRoom()
return
}
for (const roomId in inventoryImages) {
const _room = this.addRoom(roomId)
inventoryImages[roomId].forEach((value) => {
DomUtils.requireOneOfType('#image-' + value.position, HTMLElement, _room).style.backgroundImage = 'url(' + value.file + ')'
DomUtils.requireOneOfType('#add-btn-' + value.position, HTMLElement, _room).style.display = 'none'
DomUtils.requireOneOfType('#modify-btn-' + value.position, HTMLElement, _room).style.display = 'block'
DomUtils.requireOneOfType('#delete-btn-' + value.position, HTMLElement, _room).style.display = 'block'
DomUtils.requireOneOfType(`select[name="room${roomId}[type]"`, HTMLSelectElement, _room).value = value.roomType
DomUtils.requireOneOfType(`select[name="room${roomId}[state]"`, HTMLSelectElement, _room).value = value.roomState
})
}
document.getElementById('room-form')?.remove()
},
})
document.getElementById('add-room')?.addEventListener('click', () => {
this.addRoom(this.generateUniqueId(), true)
})
}
static initUploadPhoto(_room: HTMLElement): void {
for (let i = 1; i <= 6; ++i) {
const _uploadBtn = DomUtils.requireOne('#add-btn-' + i, _room)
const _modifyBtn = DomUtils.requireOne('#modify-btn-' + i, _room)
const _deleteBtn = DomUtils.requireOne('#delete-btn-' + i, _room)
const _picBox = DomUtils.requireOne('#picbox-' + i, _room)
const _loader = DomUtils.requireOne('#loader-' + i, _room)
const _image = DomUtils.requireOne('#image-' + i, _room)
const _roomForm = DomUtils.requireClosest(_picBox, '.room-form')
const roomId = _roomForm.id
const defaultUrl = DomUtils.requireOneOfType('#image-upload-url-' + i, HTMLInputElement, _room).value
new ss.SimpleUpload({
button: [_uploadBtn, _modifyBtn, _picBox],
url: defaultUrl + '?roomId=' + roomId + '&position=' + i,
name: 'uploadfile',
maxSize: 8000,
allowedExtensions: ['jpg', 'jpeg', 'png', 'heic'],
responseType: 'json',
onSubmit: () => {
_image.style.zIndex = '-1'
_loader.style.visibility = 'initial'
},
onComplete: (file, response) => {
_loader.style.visibility = 'hidden'
_image.style.zIndex = '1'
_image.style.backgroundImage = `url(${response.file})`
_deleteBtn.style.display = 'block'
_uploadBtn.style.display = 'none'
_modifyBtn.style.display = 'block'
Notify.success(window.APP_NOTIFY_SAVED)
},
})
}
}
}
6. Workflow d'affichage dynamique dans InventoryController
Côté backend, l'aiguillage du parcours utilisateur repose sur le statut de l'entité Inventory dans le contrôleur principal InventoryController :
- Absence ou édition (CREATED / EDITING) : Le contrôleur renvoie le formulaire Twig/TypeScript interactif (myspace.inventory.form.html.twig) hydraté via le DTO InventoryDetailsDto pour saisir les pièces et uploader les photos.
- Attente de signature ou finalisé (TO_SIGN / COMPLETED) : Le contrôleur renvoie le document interactif signable (myspace.inventory.signable.html.twig) via le ReadObject InventorySignableRo. Si un utilisateur clique sur "Modifier", le statut repasse à l'état EDITING, ce qui ré-affiche le formulaire et révoque les signatures temporaires.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Controller\front\mySpace\inbox;
use App\Domain\Enum\InventoryStatus;
use App\Infrastructure\Controller\front\MainControllerV2;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class InventoryController extends MainControllerV2
{
public function show(Request $request, string $type): Response
{
$inventory = $this->inventoryRepository->findByRelationAndType($booking, $type)
?? $this->createInventory($booking, $type);
$data = $this->hydrateInventoryData($inventory);
$form = $this->formFactory->create(InventoryDetailsType::class, $data);
$form->handleRequest($request);
return match ($inventory->getStatus()) {
InventoryStatus::CREATED,
InventoryStatus::EDITING => $this->renderForm($inventory, $form),
InventoryStatus::TO_SIGN,
InventoryStatus::COMPLETED => $this->renderSignable($inventory),
default => throw new \LogicException('bad / unhandled inventory status'),
};
}
}
7. Compilation documentaire et génération PDF (Dompdf)
L'action dédiée generatePdf() s'occupe de la compilation documentaire. Elle extrait l'arborescence des pièces via $this->inventoryManager->buildRooms($inventory), vérifie l'état d'avancement des signatures (tntSigned, lndSigned) et injecte le tout dans le template Twig myspace.inventory.pdf.html.twig avant d'émettre le PDF via PdfGenerator :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Controller\front\mySpace\inbox;
use App\Domain\Entity\Inventory;
use App\Domain\Enum\SignatureStatus;
use Symfony\Component\Routing\Annotation\Route;
class InventoryController extends MainControllerV2
{
#[Route(
path: '/myspace/inbox/inventory/{inventory}/generate',
name: 'myspace_inbox_inventory_generate_pdf',
methods: ['GET'],
priority: 1
)]
public function generatePdf(Inventory $inventory): void
{
$booking = $inventory->getRelation();
$guest = $booking->getTenant();
$host = $booking->getLandlord();
if (!\in_array($this->siteManager->getUser(), [$guest, $host], true)) {
throw new NotFoundException();
}
$html = $this->twig->render('myspace/inventory/myspace.inventory.pdf.html.twig', [
'inventory' => $inventory,
'rooms' => $this->inventoryManager->buildRooms($inventory),
'signable' => true,
'tntSigned' => SignatureStatus::VALIDATED === $inventory->getTenantSignature()?->getStatus(),
'lndSigned' => SignatureStatus::VALIDATED === $inventory->getLandlordSignature()?->getStatus(),
]);
$this->pdfGenerator->streamPdfAndExit($html, 'inventory');
}
}
Voici le composant d'infrastructure PdfGenerator instanciant Dompdf avec la configuration du chroot et le streaming direct de l'état des lieux :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Lib;
use App\Infrastructure\Repository\Cache\TextFrontCache;
use Dompdf\Adapter\PDFLib;
use Dompdf\Dompdf;
use Dompdf\FontMetrics;
use Dompdf\Options;
class PdfGenerator
{
public function __construct(
private readonly TextFrontCache $textFrontCache,
) {
}
public function streamPdfAndExit(string $html, string $filename, bool $watermark = false, bool $attachment = false): void
{
$domPdf = new Dompdf(new Options([
'isRemoteEnabled' => true,
'isPhpEnabled' => true,
]));
$options = $domPdf->getOptions();
$options->setChroot([
'assets/build/fonts',
]);
$domPdf->setHttpContext(
\stream_context_create([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
])
);
$domPdf->loadHtml($html);
$domPdf->setPaper('A4');
$domPdf->render();
!$watermark ?: $this->addWatermark($domPdf);
$domPdf->stream($filename . '.pdf', [
'Attachment' => $attachment,
]);
exit(0);
}
private function addWatermark(Dompdf $domPdf): void
{
/** @var PDFLib $canvas */
$canvas = $domPdf->getCanvas();
$fontMetrics = new FontMetrics($canvas, $domPdf->getOptions());
$font = $fontMetrics->getFont('times');
$text = \strtoupper($this->textFrontCache->find('watermark', 'block', 'pdf'));
$canvas->page_script('$pdf->set_opacity(.3, "Multiply");');
$x = (($canvas->get_width() - $fontMetrics->getTextWidth($text, $font, 30)) / 2);
$y = (($canvas->get_height() - $fontMetrics->getFontHeight($font, -550)) / 2);
$canvas->page_text($x, $y, $text, $font, 60, [255, 0, 0], 0.0, 0.0, -55);
}
public function generatePdf(string $html): Dompdf
{
$domPdf = new Dompdf(new Options(['isRemoteEnabled' => true]));
$domPdf->setHttpContext(
\stream_context_create([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
])
);
$domPdf->loadHtml($html);
$domPdf->setPaper('A4');
$domPdf->render();
return $domPdf;
}
}
Articulations avec le module de Signature :
Lorsque l'état des lieux bascule à l'état TO_SIGN, le visualiseur frontend SignablePdfRenderer prend le relais pour l'incrustation des signatures manuscrites et la validation OTP. Pour découvrir le détail du rendu Canvas et des signatures sous Dompdf, consultez l'article dédié :
👉 Lire l'article sur le Rendu PDF interactif & Signatures manuscrites →
8. Enseignements et arbitrages d'ingénierie
Sur le plan technique et personnel, cette refonte m'a permis d'expérimenter et de mettre en place plusieurs principes d'architecture fondamentaux :
- Découplage des relevés par DTO (InventoryDetailsDto) : J'ai créé le DTO InventoryDetailsDto (src/Infrastructure/Form/Inventory/Dto/InventoryDetailsDto.php) pour encapsuler la saisie des compteurs (eau, gaz, électricité), la remise des clés et les commentaires généraux. Associé à FormUtils::fillDetailsFromEntity et au composant PropertyAccess, ce DTO isole la couche de présentation Symfony Form (InventoryDetailsType) de l'entité Domaine Inventory.
- Rigueur du typage strict TypeScript : J'ai migré le script legacy inventory.js vers la classe TypeScript orientée objet inventoryHelper.ts en définissant des types stricts (InventoryImageDTO, InventoryImages). Le flux de saisie dynamique des pièces est ainsi devenu 100% prévisible et maintenable.
- Gestion de l'upload multimédia asynchrone : J'ai orchestré l'intégration du gestionnaire SimpleUpload pour l'envoi asynchrone des photos de pièces avec validation de taille (8 Mo max), gestion des événements de chargement sur le DOM et mise à jour dynamique des conteneurs visuels.