Save France
Backend

Calcul des tarifs et tests

Service de calcul des offres de maintenance et tests unitaires

Introduction

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 de déplacement avec coefficient ponctuel
  • La gestion robuste des paramètres manquants
  • Les tests unitaires validant les calculs

Vue d'ensemble

Le calcul des tarifs se décompose en trois parties :

  1. Déplacement (shift) : Frais de déplacement basés sur le code postal
  2. Main d'œuvre (workforce) : Temps de travail par équipement
  3. Fournitures (supplies) : Pièces et consommables
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

Règle d'arrondi TVA

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) :

  1. Arrondir le prix HT au centime après toute multiplication par un coefficient
  2. Calculer la TVA séparément sur ce montant entier et l'arrondir
  3. 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 × coefficient peut produire un HT sub-centime (ex: 3129 × 1.2 = 3754.8). Calculer la TVA directement sur 3754.8 × 1.055 = 3961.314 arrondit à 3961, alors que C# utilise round(3755 × 0.055) = 207 → TTC = 3962. Sans arrondir le HT en premier, on perd 1 centime.


Calcul de la main d'œuvre (Workforce)

Logique de calcul

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

Coefficient par catégorie

Le coefficientRate est déterminé par :

  • La catégorie d'équipement (PAC Air/Eau, PAC Air/Air, etc.)
  • Le nombre d'équipements de cette catégorie (pas le total)

Exemple de coefficients :

Nombre d'équipements par catégorieCoefficient
1 équipement1.0
2 équipements0.92
3 équipements0.83

Bug corrigé (Février 2026)

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 :

  • Configuration : 2 PAC (catégorie A) + 1 Casette (catégorie B) = 3 équipements
  • Ancien comportement : Utilisait coefficient 0.83 pour TOUS les équipements
  • Nouveau comportement :
    • 2 PAC → coefficient 0.92 (basé sur 2 équipements de catégorie A)
    • 1 Casette → coefficient 1.0 (basé sur 1 équipement de catégorie B)

Impact : Différence de ~350€ sur le prix final pour ce type de configuration.

Implémentation

// 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);
}

Exemple de calcul

Configuration : 2 PAC Air/Eau + 1 Casette

ÉquipementCatégorieNb dans catégorieCoef.Calcul brutHT arrondi
PAC 1Air/Eau (60)20.927388 × 2h × 0.92 × 1.0 = 13 593.9213 594
PAC 2Air/Eau (60)20.927388 × 2h × 0.92 × 1.0 = 13 593.9213 594
CasetteAir/Air (58)11.07388 × 1h × 1.0 × 1.0 = 7 388.007 388
Total34 576

Calcul du tarif de déplacement

Logique de calcul

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

Implémentation

// 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);
}

Exemple de calcul

Bordeaux Métropole (50€) - Offre ponctuelle

ÉtapeCalculRésultat
Prix base5000 centimes50€
Coefficient déplacement5000 × 1.26000 centimes (60€)
Nombre de visitesround(6000 × 1)6000 centimes
Coefficient ponctuelround(6000 × 1.0)6000 centimes HT
TVA particulier (10%)round(6000 × 0.10)600 centimes
Prix TTC6000 + 6006600 centimes (66€)

Paramètres configurables

Les paramètres suivants sont stockés en base de données et configurables via le back-office :

CodeLabelValeur défautDescription
punctual_shift_coefficientCoefficient ponctuel déplacement1.2Majoration du tarif de déplacement pour les offres ponctuelles
punctual_coefficientCoefficient ponctuel1.0Coefficient global pour les offres ponctuelles
pro_vatTVA Pro0.2TVA pour clients professionnels (20%)
individual_vatTVA Particulier0.1TVA pour clients particuliers (10%)

Gestion des paramètres manquants

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.

Tests unitaires

Structure des tests

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]

Scénarios testés - Déplacement (Shift)

1. Offre ponctuelle avec tous les paramètres

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€
}

2. Offre ponctuelle avec visites multiples

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

3. Offre récurrente sans coefficient

Vérifie que le coefficient n'est PAS appliqué pour les offres récurrentes.

Calcul : 5000 × 1 visite = 5000 centimes (50€) HT

4. Paramètres manquants (robustesse)

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
}

5. Client professionnel

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

6. Différentes zones tarifaires

Teste le calcul pour plusieurs zones géographiques :

  • Gradignan : 3500 → 4200 centimes (35€ → 42€)
  • Bordeaux : 8500 → 10200 centimes (85€ → 102€)
  • Bordeaux Métropole : 5000 → 6000 centimes (50€ → 60€)

Scénarios testés - Main d'œuvre (Workforce)

1. Équipements de la même catégorie

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
}

2. Équipements de catégories différentes ⭐ Test critique

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.

3. Un seul équipement

Vérifie le cas de base avec coefficient 1.0.

4. Coefficient d'offre différent

Vérifie que le coefficient de l'offre de maintenance (Essentiel: 1.0, Confort: 1.2, Premium: 1.7) est correctement appliqué.

Exécution des tests

# 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 :

  • 6 tests pour calculateShift (déplacement)
  • 4 tests pour calculateWorkforce (main d'œuvre)

Technique : Accès aux méthodes privées

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.

Ajout du paramètre en production

Via les fixtures (développement)

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');

Via le back-office (production)

  1. Se connecter au back-office EasyAdmin
  2. Aller dans Réglages → Paramètres
  3. Cliquer sur Ajouter un paramètre
  4. Remplir :
    • Label : Coefficient ponctuel déplacement
    • Code : punctual_shift_coefficient
    • Valeur : 1.2
  5. Enregistrer

Bonnes pratiques

  • Toujours tester avec des prix en centimes : Le système stocke tous les montants en centimes pour éviter les problèmes d'arrondi
  • Arrondir le HT avant la TVA : Utiliser Math::roundAwayFromZero($rawHt) puis calculer $tva = Math::roundAwayFromZero($priceHt * $vat) — ne jamais faire $priceHt * (1 + $vat) directement sur un HT non arrondi
  • Utiliser Math::roundAwayFromZero() : Équivalent PHP 8.4 de MidpointRounding.AwayFromZero en C# — ne pas utiliser round() natif sans préciser le mode
  • Utiliser des valeurs par défaut : Préférer ?? à des vérifications nulles pour les paramètres configurables
  • Documenter les calculs : Ajouter des commentaires expliquant la logique des coefficients
  • Tester la robustesse : Vérifier le comportement avec des paramètres manquants ou invalides
  • Isoler les tests unitaires : Utiliser des mocks pour toutes les dépendances externes
  • Typer les mocks pour PHPStan : Annoter les propriétés de mock avec @var Type&MockObject pour éviter les erreurs d'analyse statique

Liens utiles

Résumé

  • Déplacement : Le coefficient punctual_shift_coefficient (1.2) s'applique au prix de base pour les offres ponctuelles
  • Main d'œuvre : Le coefficientRate est déterminé par catégorie d'équipement et nombre d'équipements dans cette catégorie
  • Arrondi TVA : Chaque HT est arrondi au centime avant de calculer la TVA séparément — TTC = HT + round(HT × tva) — pour cohérence avec C# (MidpointRounding.AwayFromZero)
  • Les paramètres ont des valeurs par défaut pour garantir la robustesse
  • 10 tests unitaires valident tous les scénarios (6 shift + 4 workforce)
  • Les prix sont stockés en centimes dans tout le système
  • La configuration se fait via le back-office ou les fixtures

Historique des corrections

Mars 2026 - Fix de l'arrondi TVA (cohérence avec C#)

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) :

  1. Arrondir le HT au centime avec Math::roundAwayFromZero() après chaque multiplication par un coefficient
  2. Calculer la TVA séparément : $tva = Math::roundAwayFromZero($priceHt * $vat)
  3. $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).

Février 2026 - Fix du calcul de main d'œuvre par catégorie

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 :

  • testCalculateWorkforceWithMultipleEquipmentsOfSameCategory
  • testCalculateWorkforceWithEquipmentsFromDifferentCategories (validation du fix)
  • testCalculateWorkforceWithSingleEquipment
  • testCalculateWorkforceWithDifferentMaintenanceOfferCoefficient

Voir le Changelog complet pour l'historique détaillé de toutes les modifications du projet.