Le service CalculateOfferFromRequestService gère le calcul des tarifs pour les offres de maintenance (récurrentes et ponctuelles). Il applique différents coefficients selon le type d'offre, la zone géographique et le profil client (particulier/professionnel).
Cette documentation couvre :
Le calcul des tarifs se décompose en trois parties :
flowchart TD
A[Request] --> B[CalculateOfferFromRequestService]
B --> C[calculateShift]
B --> D[calculateWorkforce]
B --> E[calculateSupplies]
C --> F{Type offre?}
F -->|Ponctuel| G[Prix × coef_shift × coef_ponctuel]
F -->|Récurrent| H[Prix × coef_récurrent]
G --> I[Appliquer TVA]
H --> I
I --> J[Price total]
D --> J
E --> J
Tous les montants sont stockés en centimes entiers. Pour garantir la cohérence avec l'application SAV+, la règle suivante s'applique à chaque composant (déplacement, main d'œuvre, fournitures) :
TTC = HT arrondi + TVA arrondie$priceHt = Math::roundAwayFromZero($rawHt); // arrondir au centime
$tva = Math::roundAwayFromZero($priceHt * $vat); // TVA sur entier
$priceTtc = $priceHt + $tva;
Ce comportement est équivalent à MidpointRounding.AwayFromZero en C# .NET (PHP_ROUND_HALF_UP pour les valeurs positives), implémenté via Math::roundAwayFromZero() (RoundingMode::HalfAwayFromZero — PHP 8.4+).
Pourquoi cette approche ? La multiplication
supplyPrice × coefficientpeut produire un HT sub-centime (ex:3129 × 1.2 = 3754.8). Calculer la TVA directement sur3754.8 × 1.055 = 3961.314arrondit à 3961, alors que C# utiliseround(3755 × 0.055) = 207→ TTC = 3962. Sans arrondir le HT en premier, on perd 1 centime.
Le calcul de la main d'œuvre applique un coefficient basé sur le nombre d'équipements par catégorie, pas le nombre total d'équipements. Cette distinction est cruciale pour un calcul correct.
Formule pour chaque équipement :
prix_ht = round(hourlyPrice × hourRequire × coefficientRate × maintenanceOfferCoefficient)
tva = round(prix_ht × taux_tva)
prix_ttc = prix_ht + tva
Le coefficientRate est déterminé par :
Exemple de coefficients :
| Nombre d'équipements par catégorie | Coefficient |
|---|---|
| 1 équipement | 1.0 |
| 2 équipements | 0.92 |
| 3 équipements | 0.83 |
Problème identifié : Le calcul utilisait le nombre total d'équipements pour toutes les catégories, ce qui donnait un coefficient incorrect quand plusieurs catégories étaient présentes.
Exemple du bug :
Impact : Différence de ~350€ sur le prix final pour ce type de configuration.
// api/sources/src/Service/CalculateOfferFromRequestService.php
private function calculateWorkforce(MaintenanceOffer $maintenanceOffer): PriceWorkforce
{
$totalPriceHt = 0;
$totalPriceTtc = 0;
$nbEquipment = $this->request->getEquipments()->count();
$equipments = [];
$contact = $this->request->getContact();
// Compter les équipements par catégorie (FIX du bug)
$equipmentCountByCategory = [];
foreach ($this->request->getEquipments() as $eq) {
$catId = $eq->getType()->getCategory()->getId();
$equipmentCountByCategory[$catId] = ($equipmentCountByCategory[$catId] ?? 0) + 1;
}
foreach ($this->request->getEquipments() as $equipment) {
$type = $equipment->getType();
$category = $type->getCategory();
// Utiliser le nombre d'équipements de cette catégorie spécifique
$nbEquipmentForCategory = $equipmentCountByCategory[$category->getId()] ?? 1;
$coefficientRate = $this->coefficientRateRepository->findOneBy([
'minEquipment' => $nbEquipmentForCategory,
'category' => $category
]);
$maintenanceOfferCoefficient = $maintenanceOffer->getCoefficient();
$hourlyPrice = $type->getHourlyPrice();
$requireHourly = $type->getHourRequire();
// Arrondir le HT au centime avant de calculer la TVA
$priceHt = Math::roundAwayFromZero(
$hourlyPrice * $requireHourly * $coefficientRate->getCoefficient() * $maintenanceOfferCoefficient
);
// TVA calculée séparément sur le HT entier (cohérence avec C# MidpointRounding.AwayFromZero)
$tva = Math::roundAwayFromZero($priceHt * $vat);
$priceTtc = $priceHt + $tva;
$totalPriceHt += $priceHt;
$totalPriceTtc += $priceTtc;
}
return new PriceWorkforce(Math::roundAwayFromZero($totalPriceHt), Math::roundAwayFromZero($totalPriceTtc), $equipments);
}
Configuration : 2 PAC Air/Eau + 1 Casette
| Équipement | Catégorie | Nb dans catégorie | Coef. | Calcul brut | HT arrondi |
|---|---|---|---|---|---|
| PAC 1 | Air/Eau (60) | 2 | 0.92 | 7388 × 2h × 0.92 × 1.0 = 13 593.92 | 13 594 |
| PAC 2 | Air/Eau (60) | 2 | 0.92 | 7388 × 2h × 0.92 × 1.0 = 13 593.92 | 13 594 |
| Casette | Air/Air (58) | 1 | 1.0 | 7388 × 1h × 1.0 × 1.0 = 7 388.00 | 7 388 |
| Total | 34 576 |
Pour les offres ponctuelles, le coefficient de déplacement s'applique au prix de base :
prix_final = prix_postal_code × punctual_shift_coefficient × nb_visites × punctual_coefficient
Pour les offres récurrentes, pas de majoration de déplacement :
prix_final = prix_postal_code × nb_visites
// api/sources/src/Service/CalculateOfferFromRequestService.php
private function calculateShift(MaintenanceOffer $maintenanceOffer): ?PriceShift
{
$contact = $this->request->getContact();
$addressPostalCode = $contact->getPostalCode();
$postalCode = $this->postalCodeService->request($addressPostalCode);
if (!$postalCode instanceof PostalCode) {
return null;
}
$visit = $maintenanceOffer->getSubscriptionVisit();
$basePrice = $postalCode->getPrice(); // Prix en centimes
// Appliquer le coefficient de déplacement ponctuel
if ($maintenanceOffer->getType() === MaintenanceOfferTypeEnum::PUNCTUAL) {
$shiftCoefficient = $this->parameterRepository->findOneByCode('punctual_shift_coefficient') ?? 1.0;
$basePrice *= $shiftCoefficient;
}
$priceHt = Math::roundAwayFromZero($basePrice * $visit);
$punctualCoefficient = null;
// Appliquer le coefficient ponctuel global
if ($maintenanceOffer->getType() === MaintenanceOfferTypeEnum::PUNCTUAL) {
$punctualCoefficient = $this->parameterRepository->findOneByCode('punctual_coefficient') ?? 1.0;
$priceHt = Math::roundAwayFromZero($priceHt * $punctualCoefficient);
}
// TVA selon le type de client
$proVat = $this->parameterRepository->findOneByCode('pro_vat') ?? 0.2;
$individualVat = $this->parameterRepository->findOneByCode('individual_vat') ?? 0.1;
$vat = $contact->isPro() || $this->request->isLessThanTwoYears()
? $proVat : $individualVat;
// TVA calculée séparément sur le HT entier
$tva = Math::roundAwayFromZero($priceHt * $vat);
$priceTtc = $priceHt + $tva;
return new PriceShift($priceHt, $priceTtc, $punctualCoefficient, $vat);
}
Bordeaux Métropole (50€) - Offre ponctuelle
| Étape | Calcul | Résultat |
|---|---|---|
| Prix base | 5000 centimes | 50€ |
| Coefficient déplacement | 5000 × 1.2 | 6000 centimes (60€) |
| Nombre de visites | round(6000 × 1) | 6000 centimes |
| Coefficient ponctuel | round(6000 × 1.0) | 6000 centimes HT |
| TVA particulier (10%) | round(6000 × 0.10) | 600 centimes |
| Prix TTC | 6000 + 600 | 6600 centimes (66€) |
Les paramètres suivants sont stockés en base de données et configurables via le back-office :
| Code | Label | Valeur défaut | Description |
|---|---|---|---|
punctual_shift_coefficient | Coefficient ponctuel déplacement | 1.2 | Majoration du tarif de déplacement pour les offres ponctuelles |
punctual_coefficient | Coefficient ponctuel | 1.0 | Coefficient global pour les offres ponctuelles |
pro_vat | TVA Pro | 0.2 | TVA pour clients professionnels (20%) |
individual_vat | TVA Particulier | 0.1 | TVA pour clients particuliers (10%) |
Le service utilise l'opérateur de coalescence nulle (??) pour fournir des valeurs par défaut si un paramètre est absent en base de données :
$shiftCoefficient = $this->parameterRepository->findOneByCode('punctual_shift_coefficient') ?? 1.0;
Cela évite les erreurs fatales et garantit un fonctionnement dégradé mais stable de l'application.
Les tests sont situés dans api/sources/tests/Service/CalculateOfferFromRequestServiceTest.php et utilisent PHPUnit 12.
Les dépendances sont mockées avec des annotations PHPDoc pour la compatibilité PHPStan :
use PHPUnit\Framework\MockObject\MockObject;
/** @var PostalCodeService&MockObject */
private PostalCodeService $postalCodeService;
/** @var ParameterRepository&MockObject */
private ParameterRepository $parameterRepository;
flowchart LR
A[Test Setup] --> B[Mock Dependencies]
B --> C[Create Test Data]
C --> D[Call Private Method]
D --> E[Assert Results]
B --> F[PostalCodeService]
B --> G[ParameterRepository]
B --> H[CoefficientRateRepository]
Vérifie l'application correcte du coefficient de déplacement.
public function testCalculateShiftForPunctualWithAllParameters(): void
{
// Arrange
$request = $this->createRequest('33000', false);
$maintenanceOffer = $this->createMaintenanceOffer(MaintenanceOfferTypeEnum::PUNCTUAL, 1);
$postalCode = $this->createPostalCode('33000', 5000); // 50€ en centimes
$this->parameterRepository
->method('findOneByCode')
->willReturnMap([
['punctual_shift_coefficient', 1.2],
['punctual_coefficient', 1.0],
['pro_vat', 0.2],
['individual_vat', 0.1],
]);
// Act
$result = $this->invokePrivateMethod($this->service, 'calculateShift', [$maintenanceOffer]);
// Assert
$this->assertEquals(6000.0, $result->getHt()); // 60€
$this->assertEquals(6600.0, $result->getTtc()); // 66€
}
Vérifie que le coefficient s'applique au prix de base avant la multiplication par les visites.
Calcul : (5000 × 1.2) × 2 visites = 12000 centimes (120€) HT
Vérifie que le coefficient n'est PAS appliqué pour les offres récurrentes.
Calcul : 5000 × 1 visite = 5000 centimes (50€) HT
Vérifie que l'application fonctionne avec les valeurs par défaut si les paramètres sont absents.
public function testCalculateShiftWithMissingParameters(): void
{
// Tous les paramètres retournent null
$this->parameterRepository
->method('findOneByCode')
->willReturn(null);
$result = $this->invokePrivateMethod($this->service, 'calculateShift', [$maintenanceOffer]);
// Doit utiliser les valeurs par défaut
$this->assertEquals(5000.0, $result->getHt()); // Coefficient 1.0 par défaut
}
Vérifie l'application de la TVA professionnelle (20%) au lieu de la TVA particulier (10%).
Calcul : 6000 × 1.2 (TVA pro) = 7200 centimes (72€) TTC
Teste le calcul pour plusieurs zones géographiques :
Vérifie que le coefficient est appliqué correctement pour plusieurs équipements identiques.
public function testCalculateWorkforceWithMultipleEquipmentsOfSameCategory(): void
{
// 2 PAC de catégorie 60
// Coefficient attendu: 0.92 pour 2 équipements
// Résultat: 27188 centimes HT
}
Ce test valide le bug corrigé.
Vérifie que chaque catégorie utilise son propre compteur d'équipements.
public function testCalculateWorkforceWithEquipmentsFromDifferentCategories(): void
{
// Configuration: 2 PAC (catégorie 60) + 1 Casette (catégorie 58)
// Total: 3 équipements
// Coefficients attendus:
// - PAC: 0.92 (basé sur 2 équipements de catégorie 60)
// - Casette: 1.0 (basé sur 1 équipement de catégorie 58)
// Calcul:
// 2 PAC: 2 × (7388 × 2h × 0.92 × 1.0) = 27187.84 HT
// 1 Casette: 1 × (7388 × 1h × 1.0 × 1.0) = 7388 HT
// Total: 34576 centimes HT (arrondi)
$this->assertEquals(34576, $result->getHt());
}
Sans le fix, ce test échouerait car l'ancien code utilisait 3 (total) pour tous les équipements, donnant un coefficient de 0.83 au lieu de 0.92 pour les PAC.
Vérifie le cas de base avec coefficient 1.0.
Vérifie que le coefficient de l'offre de maintenance (Essentiel: 1.0, Confort: 1.2, Premium: 1.7) est correctement appliqué.
# Lancer tous les tests
make tests
# Lancer uniquement les tests du service
docker compose exec api env APP_ENV=test APP_DEBUG=1 bin/phpunit tests/Service/CalculateOfferFromRequestServiceTest.php
Résultat attendu :
PHPUnit 12.5.4 by Sebastian Bergmann and contributors.
NNNNNNNNNN 10 / 10 (100%)
OK, but there were issues!
Tests: 10, Assertions: 32
Répartition des tests :
calculateShift (déplacement)calculateWorkforce (main d'œuvre)Les tests utilisent la réflexion PHP pour tester directement la méthode privée calculateShift() :
private function invokePrivateMethod(object $object, string $methodName, array $parameters = []): mixed
{
$reflection = new ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
return $method->invokeArgs($object, $parameters);
}
Note : En PHP 8.1+, setAccessible() n'est plus nécessaire car les méthodes privées sont automatiquement accessibles via la réflexion.
Cela permet des tests unitaires purs sans dépendances sur les autres méthodes du service.
make fixtures
Le paramètre est défini dans api/sources/src/DataFixtures/AppFixtures.php :
$this->createParameter('Coefficient ponctuel déplacement', 'punctual_shift_coefficient', '1.2');
Coefficient ponctuel déplacementpunctual_shift_coefficient1.2Math::roundAwayFromZero($rawHt) puis calculer $tva = Math::roundAwayFromZero($priceHt * $vat) — ne jamais faire $priceHt * (1 + $vat) directement sur un HT non arrondiMath::roundAwayFromZero() : Équivalent PHP 8.4 de MidpointRounding.AwayFromZero en C# — ne pas utiliser round() natif sans préciser le mode?? à des vérifications nulles pour les paramètres configurables@var Type&MockObject pour éviter les erreurs d'analyse statiquepunctual_shift_coefficient (1.2) s'applique au prix de base pour les offres ponctuellescoefficientRate est déterminé par catégorie d'équipement et nombre d'équipements dans cette catégorieTTC = HT + round(HT × tva) — pour cohérence avec C# (MidpointRounding.AwayFromZero)Problème : La multiplication supplyPrice × coefficient (ex: 3129 × 1.2 = 3754.8) produisait un prix HT sub-centime. Calculer la TVA directement sur cette valeur brute (3754.8 × 1.055 = 3961.314) arrondissait à 3961, alors que le système C# partenaire travaille sur des centimes entiers et obtenait 3962. Écart de 1 centime sur le TTC final (281,67 € au lieu de 281,68 €).
Solution : Dans les trois méthodes de calcul (calculateSupplies, calculateWorkforce, calculateShift) :
Math::roundAwayFromZero() après chaque multiplication par un coefficient$tva = Math::roundAwayFromZero($priceHt * $vat)$priceTtc = $priceHt + $tva (au lieu de $priceHt * (1 + $vat))Impact : Cohérence exacte avec MidpointRounding.AwayFromZero en C# .NET.
Test mis à jour : testCalculateWorkforceWithDifferentMaintenanceOfferCoefficient (valeur attendue corrigée de 32625 → 32626, reflétant l'arrondi par équipement).
Problème : Le service utilisait le nombre total d'équipements pour rechercher le coefficient, ce qui donnait un prix incorrect quand plusieurs catégories étaient présentes.
Solution : Ajout d'un compteur par catégorie ($equipmentCountByCategory) avant la boucle de calcul.
Impact : Différence de prix significative (~350€) sur les configurations multi-catégories.
Tests ajoutés :
testCalculateWorkforceWithMultipleEquipmentsOfSameCategorytestCalculateWorkforceWithEquipmentsFromDifferentCategories (validation du fix)testCalculateWorkforceWithSingleEquipmenttestCalculateWorkforceWithDifferentMaintenanceOfferCoefficientVoir le Changelog complet pour l'historique détaillé de toutes les modifications du projet.