<?php
/**
 * Conversion d'une structure JSON de facture vers un XML CII
 * (Cross Industry Invoice) conforme au profil Factur-X EN16931.
 *
 * Version compatible PHP 5.6 : pas de typage scalaire, pas de "??",
 * pas de "match", pas de "private const", pas de "list() []" court.
 *
 * Usage :
 *   php json_to_facturx_cii_php56.php facture.json facture_cii.xml
 */

// -----------------------------------------------------------------------
// Utilitaire : équivalent de l'opérateur "??" (null coalescing, PHP 7.0+),
// indisponible en PHP 5.6.
// -----------------------------------------------------------------------

function valeurOuDefaut($valeur, $defaut = null)
{
    return isset($valeur) ? $valeur : $defaut;
}

// -----------------------------------------------------------------------
// 1. Chargement du JSON
// -----------------------------------------------------------------------

function chargerJson($chemin)
{
    if (!file_exists($chemin)) {
        throw new RuntimeException("Fichier introuvable : {$chemin}");
    }

    $contenu = file_get_contents($chemin);
    $data = json_decode($contenu, true);

    // JSON_THROW_ON_ERROR n'existe qu'à partir de PHP 7.3 : on vérifie donc
    // l'erreur manuellement via json_last_error().
    if (json_last_error() !== JSON_ERROR_NONE) {
        throw new RuntimeException('JSON invalide : ' . json_last_error_msg());
    }

    return $data;
}

// -----------------------------------------------------------------------
// 2. Construction du XML CII
// -----------------------------------------------------------------------

class FacturXCiiBuilder
{
    /** @var DOMDocument */
    private $doc;

    /** @var DOMElement */
    private $root;

    // PHP 5.6 n'autorise pas les modificateurs de visibilité sur les
    // constantes de classe (ajoutés en PHP 7.1) : on utilise "const" seul,
    // ce qui les rend publiques (sans conséquence pratique ici).
    const NS_RSM = 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100';
    const NS_RAM = 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100';
    const NS_UDT = 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100';
    const NS_QDT = 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100';

    public function __construct()
    {
        $this->doc = new DOMDocument('1.0', 'UTF-8');
        $this->doc->formatOutput = true;

        $this->root = $this->doc->createElementNS(self::NS_RSM, 'rsm:CrossIndustryInvoice');
        $this->root->setAttributeNS(
            'http://www.w3.org/2000/xmlns/',
            'xmlns:ram',
            self::NS_RAM
        );
        $this->root->setAttributeNS(
            'http://www.w3.org/2000/xmlns/',
            'xmlns:udt',
            self::NS_UDT
        );
        $this->root->setAttributeNS(
            'http://www.w3.org/2000/xmlns/',
            'xmlns:qdt',
            self::NS_QDT
        );

        $this->doc->appendChild($this->root);
    }

    public function construire(array $facture)
    {
        $this->validerMelangeCategorieHorsChamp($facture['lignes'], $facture['facture']['profil_facturx']);

        $this->ajouterExchangedDocumentContext(
            $facture['facture']['profil_facturx'],
            valeurOuDefaut($facture['facture']['mode_facturation'], null)
        );
        $mentionsFinales = $this->construireMentionsLegales(
            $facture['conditions_paiement'],
            isset($facture['mentions_legales']) ? $facture['mentions_legales'] : array()
        );
        $this->ajouterExchangedDocument($facture['facture'], $mentionsFinales);

        $transaction = $this->creerElement('rsm:SupplyChainTradeTransaction');
        $this->root->appendChild($transaction);

        foreach ($facture['lignes'] as $ligne) {
            $transaction->appendChild($this->creerLigne($ligne));
        }

        $transaction->appendChild($this->creerTradeAgreement($facture['vendeur'], $facture['acheteur'], $facture['facture']));
        $transaction->appendChild($this->creerTradeDelivery($facture['facture']['date_emission']));
        $transaction->appendChild($this->creerTradeSettlement($facture));

        return $this->doc;
    }

    // ---------------------------------------------------------------
    // En-tête : contexte et métadonnées du document
    // ---------------------------------------------------------------

    /**
     * BR-O-11 à BR-O-14 : dès qu'une ligne porte la catégorie 'O' (hors
     * champ de TVA), AUCUNE autre catégorie ne peut apparaître ailleurs sur
     * la même facture (ni en ligne, ni en ventilation, ni en remise/charge
     * document). Une facture mêlant du hors champ et du taux standard (par
     * exemple) est donc structurellement invalide en profil EN16931 -- pas
     * seulement une question de cohérence des montants.
     *
     * La spécification française (AFNOR XP Z12-012) lève explicitement ces
     * 4 règles pour le profil EXTENDED-CTC-FR, précisément pour permettre ce
     * cas d'usage. On n'applique donc cette validation stricte que pour les
     * autres profils (EN16931 et en-deçà).
     */
    private function validerMelangeCategorieHorsChamp(array $lignes, $profilFacturx)
    {
        if ($profilFacturx === 'EXTENDED-CTC-FR') {
            return;
        }

        $categories = array();
        foreach ($lignes as $ligne) {
            if (!empty($ligne['categorie_tva'])) {
                $categories[$ligne['categorie_tva']] = true;
            }
        }

        if (isset($categories['O']) && count($categories) > 1) {
            $autres = implode(', ', array_keys(array_diff_key($categories, array('O' => true))));
            throw new InvalidArgumentException(
                "Facture invalide (BR-O-11/BR-O-12) : la catégorie 'O' (hors champ de TVA) " .
                "ne peut pas coexister avec d'autres catégories sur la même facture en profil " .
                "EN16931 (catégories trouvées en plus de 'O' : {$autres}). Séparez cette " .
                "facture en deux documents distincts, ou passez en profil EXTENDED-CTC-FR si " .
                "ce mélange est réellement nécessaire."
            );
        }
    }

    private function ajouterExchangedDocumentContext($profil, $modeFacturation = null)
    {
        // "match" (PHP 8.0+) remplacé par un switch classique.
        switch ($profil) {
            case 'MINIMUM':
                $urnProfil = 'urn:factur-x.eu:1p0:minimum';
                break;
            case 'BASIC':
                $urnProfil = 'urn:factur-x.eu:1p0:basicwl';
                break;
            case 'EXTENDED':
                $urnProfil = 'urn:factur-x.eu:1p0:extended';
                break;
            case 'EN16931':
            default:
                $urnProfil = 'urn:cen.eu:en16931:2017';
                break;
        }

        $context = $this->creerElement('rsm:ExchangedDocumentContext');

        // BT-23 : mode de facturation, obligatoire pour la conformité BR-FR-08
        // (valeurs autorisées : B1, S1, M1, B2, S2, M2, S3, B4, S4, M4, S5, S6,
        // B7, S7, B8, S8, M8, B9, S9, M9 -- à adapter selon le contrat conclu
        // avec votre PDP/PPF).
        if ($modeFacturation !== null) {
            $processus = $this->creerElement('ram:BusinessProcessSpecifiedDocumentContextParameter');
            $processus->appendChild($this->creerElementTexte('ram:ID', $modeFacturation));
            $context->appendChild($processus);
        }

        $guideline = $this->creerElement('ram:GuidelineSpecifiedDocumentContextParameter');
        $guideline->appendChild($this->creerElementTexte('ram:ID', $urnProfil));
        $context->appendChild($guideline);

        $this->root->appendChild($context);
    }

    /**
     * Construit les mentions légales PMT (indemnité forfaitaire), PMD
     * (pénalités de retard) et AAB (escompte) directement à partir des
     * champs structurés de conditions_paiement, plutôt que d'exiger que le
     * texte exact soit pré-rédigé et recopié dans mentions_legales : cela
     * évite qu'un même montant (ex. 40,00 EUR) existe à deux endroits du
     * JSON (le champ structuré ET le texte libre) qui pourraient diverger
     * sans qu'aucune validation ne le détecte.
     *
     * Toute mention personnalisée fournie dans mentions_legales (ex. "AAI"
     * pour un groupe TVA) est conservée telle quelle. Si mentions_legales
     * contient malgré tout un code PMT/PMD/AAB fourni manuellement, il est
     * ignoré au profit de la version générée -- ces trois codes sont
     * désormais toujours calculés, jamais recopiés depuis le JSON.
     */
    private function construireMentionsLegales(array $conditionsPaiement, array $mentionsPersonnalisees)
    {
        $mentions = array();

        $penalites = isset($conditionsPaiement['penalites_retard']) ? $conditionsPaiement['penalites_retard'] : null;
        if (!empty($penalites['indemnite_forfaitaire_recouvrement'])) {
            $mentions[] = array(
                'code' => 'PMT',
                'texte' => sprintf(
                    "En cas de retard de paiement, une indemnité forfaitaire de %s EUR pour frais de recouvrement sera exigible (art. L441-10 et D441-5 du Code de commerce).",
                    $this->formaterNombreFr($penalites['indemnite_forfaitaire_recouvrement'], 2)
                ),
            );
        }
        if (!empty($penalites['taux'])) {
            $mentions[] = array(
                'code' => 'PMD',
                'texte' => sprintf(
                    "Tout retard de paiement entraînera l'application d'une pénalité au taux de %s %% l'an, sans qu'un rappel soit nécessaire (art. L441-10 du Code de commerce).",
                    $this->formaterNombreFr($penalites['taux'], 1)
                ),
            );
        }

        // BT-20/AAB : l'absence d'escompte doit être explicitement indiquée
        // (une facture B2B française sans mention AAB est incomplète), d'où
        // la génération systématique de cette mention, avec ou sans escompte.
        $escompte = isset($conditionsPaiement['escompte_paiement_anticipe']) ? $conditionsPaiement['escompte_paiement_anticipe'] : null;
        if (!empty($escompte) && !empty($escompte['taux'])) {
            $mentions[] = array(
                'code' => 'AAB',
                'texte' => sprintf(
                    "Escompte de %s %% accordé pour paiement sous %s jours.",
                    $this->formaterNombreFr($escompte['taux'], 1),
                    valeurOuDefaut($escompte['delai_jours'], '')
                ),
            );
        } else {
            $mentions[] = array('code' => 'AAB', 'texte' => "Pas d'escompte pour paiement anticipé.");
        }

        // Mentions personnalisées : tout code autre que PMT/PMD/AAB passe
        // tel quel (ex. AAI pour un groupe TVA/assujetti unique).
        foreach ($mentionsPersonnalisees as $mention) {
            if (!in_array($mention['code'], array('PMT', 'PMD', 'AAB'), true)) {
                $mentions[] = $mention;
            }
        }

        return $mentions;
    }

    private function ajouterExchangedDocument(array $enTete, array $mentionsLegales)
    {
        $document = $this->creerElement('rsm:ExchangedDocument');

        $document->appendChild($this->creerElementTexte('ram:ID', $enTete['numero']));
        $document->appendChild($this->creerElementTexte('ram:TypeCode', $enTete['type_document']));

        $dateEmission = $this->creerElement('ram:IssueDateTime');
        $dateEmission->appendChild($this->creerDateUdt($enTete['date_emission']));
        $document->appendChild($dateEmission);

        // BR-FR-05/BT-22 : les mentions relatives aux frais de recouvrement
        // (PMT), pénalités de retard (PMD) et escompte (AAB) sont obligatoires
        // dans les notes du document, même pour indiquer une absence d'escompte.
        foreach ($mentionsLegales as $mention) {
            $note = $this->creerElement('ram:IncludedNote');
            $note->appendChild($this->creerElementTexte('ram:Content', $mention['texte']));
            $note->appendChild($this->creerElementTexte('ram:SubjectCode', $mention['code']));
            $document->appendChild($note);
        }

        $this->root->appendChild($document);
    }

    // ---------------------------------------------------------------
    // Lignes de facture
    // ---------------------------------------------------------------

    private function creerLigne(array $ligne)
    {
        $lineItem = $this->creerElement('ram:IncludedSupplyChainTradeLineItem');

        $lineDoc = $this->creerElement('ram:AssociatedDocumentLineDocument');
        $lineDoc->appendChild($this->creerElementTexte('ram:LineID', (string) $ligne['id']));
        $lineItem->appendChild($lineDoc);

        $produit = $this->creerElement('ram:SpecifiedTradeProduct');

        // BT-155 : identifiant article du vendeur, optionnel. Doit être
        // positionné AVANT Name dans la séquence XSD de SpecifiedTradeProduct
        // (GlobalID, SellerAssignedID, BuyerAssignedID, Name, Description...).
        if (!empty($ligne['codeArticle'])) {
            $produit->appendChild($this->creerElementTexte('ram:SellerAssignedID', $ligne['codeArticle']));
        }

        // BT-153 : nom de l'article, obligatoire. Le champ JSON s'appelait
        // auparavant 'description', ce qui prêtait à confusion avec BT-154
        // (Description de l'article, un champ distinct, non utilisé ici) --
        // renommé en 'nomArticle' pour refléter fidèlement sa correspondance
        // réelle avec ram:Name. Validation explicite plutôt qu'un avertissement
        // silencieux suivi d'un <ram:Name></ram:Name> vide, pour bien signaler
        // aux JSON pas encore migrés qu'il s'agit d'un renommage, pas d'un bug.
        if (empty($ligne['nomArticle'])) {
            throw new InvalidArgumentException(
                "nomArticle manquant pour la ligne {$ligne['id']} : obligatoire (BT-153). " .
                "Ce champ s'appelait 'description' avant migration -- vérifiez que votre JSON " .
                "source a bien été mis à jour."
            );
        }
        $produit->appendChild($this->creerElementTexte('ram:Name', $ligne['nomArticle']));
        $lineItem->appendChild($produit);

        $accordLigne = $this->creerElement('ram:SpecifiedLineTradeAgreement');
        $prixNet = $this->creerElement('ram:NetPriceProductTradePrice');
        $prixNet->appendChild($this->creerElementTexte(
            'ram:ChargeAmount',
            $this->formaterMontant($ligne['prix_unitaire_ht'])
        ));
        $accordLigne->appendChild($prixNet);
        $lineItem->appendChild($accordLigne);

        $livraisonLigne = $this->creerElement('ram:SpecifiedLineTradeDelivery');
        $quantite = $this->creerElementTexte('ram:BilledQuantity', $this->formaterMontant($ligne['quantite']));
        $quantite->setAttribute('unitCode', $ligne['unite']);
        $livraisonLigne->appendChild($quantite);
        $lineItem->appendChild($livraisonLigne);

        $reglementLigne = $this->creerElement('ram:SpecifiedLineTradeSettlement');

        $taxeLigne = $this->creerElement('ram:ApplicableTradeTax');
        $taxeLigne->appendChild($this->creerElementTexte('ram:TypeCode', 'VAT'));

        // BT-151 : catégorie de TVA de la ligne, obligatoire. On ne défaut
        // plus silencieusement vers 'S' (Standard) : un défaut silencieux
        // sur ce champ a déjà produit une facture invalide (catégorie
        // Standard sans n° de TVA vendeur ni taux, cf. BR-S-02/BR-S-05).
        // Mieux vaut un échec net et explicite ici qu'un XML incohérent.
        if (empty($ligne['categorie_tva'])) {
            throw new InvalidArgumentException(
                "categorie_tva manquante pour la ligne {$ligne['id']} : " .
                "obligatoire (BT-151), aucune valeur par défaut n'est appliquée. " .
                "Valeurs usuelles : S (standard), E (exonéré), O (hors champ de TVA), " .
                "K (autoliquidation UE), G (export hors UE), Z (taux zéro)."
            );
        }
        $categorieLigne = $ligne['categorie_tva'];
        $taxeLigne->appendChild($this->creerElementTexte('ram:CategoryCode', $categorieLigne));

        // BR-O-05 : une ligne "Hors champ de TVA" (catégorie O) ne doit
        // contenir AUCUN taux, pas même 0. Pour les autres catégories, le
        // taux reste obligatoire (0 pour Exonéré/Zéro/Autoliquidation/Export).
        if ($categorieLigne !== 'O') {
            $taxeLigne->appendChild($this->creerElementTexte(
                'ram:RateApplicablePercent',
                $this->formaterMontant(valeurOuDefaut($ligne['taux_tva'], 0))
            ));
        }
        $reglementLigne->appendChild($taxeLigne);

        // BG-26 : période de facturation de la ligne (BT-134/BT-135), utile
        // quand les lignes d'une même facture couvrent des périodes
        // différentes. Doit être positionnée après ApplicableTradeTax et
        // avant SpecifiedTradeAllowanceCharge (BG-27) dans la séquence XSD.
        if (!empty($ligne['periode']['debut']) || !empty($ligne['periode']['fin'])) {
            $reglementLigne->appendChild($this->creerPeriodeFacturation(
                $ligne['periode'],
                'ram:BillingSpecifiedPeriod'
            ));
        }

        // BG-27 : remise au niveau de la ligne (BT-136 à BT-140). Doit être
        // positionné après ApplicableTradeTax et avant
        // SpecifiedTradeSettlementLineMonetarySummation dans la séquence XSD
        // de SpecifiedLineTradeSettlement.
        if (!empty($ligne['remise_ligne'])) {
            $reglementLigne->appendChild($this->creerRemiseLigne($ligne));
        }

        $totauxLigne = $this->creerElement('ram:SpecifiedTradeSettlementLineMonetarySummation');
        $totauxLigne->appendChild($this->creerElementTexte(
            'ram:LineTotalAmount',
            $this->formaterMontant($ligne['montant_ht'])
        ));
        $reglementLigne->appendChild($totauxLigne);

        $lineItem->appendChild($reglementLigne);

        return $lineItem;
    }

    private function creerPeriodeFacturation(array $periode, $balise)
    {
        $bloc = $this->creerElement($balise);

        if (!empty($periode['debut'])) {
            $debut = $this->creerElement('ram:StartDateTime');
            $debut->appendChild($this->creerDateUdt($periode['debut']));
            $bloc->appendChild($debut);
        }

        if (!empty($periode['fin'])) {
            $fin = $this->creerElement('ram:EndDateTime');
            $fin->appendChild($this->creerDateUdt($periode['fin']));
            $bloc->appendChild($fin);
        }

        return $bloc;
    }

    private function creerRemiseLigne(array $ligne)
    {
        $remise = $this->creerElement('ram:SpecifiedTradeAllowanceCharge');

        // ram:ChargeIndicator = false -> il s'agit d'une remise (allowance),
        // pas d'une majoration (charge). Le bloc SpecifiedTradeAllowanceCharge
        // sert aux deux cas (BG-27 remises / BG-28 majorations) selon cet
        // indicateur.
        $indicateur = $this->creerElement('ram:ChargeIndicator');
        $indicateur->appendChild($this->doc->createElement('udt:Indicator', 'false'));
        $remise->appendChild($indicateur);

        // BT-138 : pourcentage de remise, optionnel. N'est fourni que si le
        // JSON source l'indique explicitement (le JSON actuel ne transmet
        // qu'un montant, pas de taux).
        if (!empty($ligne['remise_ligne_pourcentage'])) {
            $remise->appendChild($this->creerElementTexte(
                'ram:CalculationPercent',
                $this->formaterMontant($ligne['remise_ligne_pourcentage'])
            ));
        }

        // BT-137 : assiette de la remise, optionnelle mais utile pour
        // permettre au lecteur de retrouver le prix brut ligne (avant
        // remise) = prix unitaire HT x quantité.
        $baseHt = $ligne['prix_unitaire_ht'] * $ligne['quantite'];
        $remise->appendChild($this->creerElementTexte(
            'ram:BasisAmount',
            $this->formaterMontant($baseHt)
        ));

        // BT-136 : montant de la remise, obligatoire dès lors que BG-27 est
        // présent (BR-42 : le SpecifiedTradeAllowanceCharge doit contenir
        // ActualAmount).
        $remise->appendChild($this->creerElementTexte(
            'ram:ActualAmount',
            $this->formaterMontant($ligne['remise_ligne'])
        ));

        // BT-139/BT-140 : au moins l'un des deux est obligatoire (BR-CO-23).
        // On utilise le motif fourni dans le JSON, ou un libellé générique à
        // défaut, plutôt que d'omettre le champ et risquer un rejet.
        $motifRemise = isset($ligne['remise_ligne_motif']) ? $ligne['remise_ligne_motif'] : 'Remise commerciale';
        $remise->appendChild($this->creerElementTexte('ram:Reason', $motifRemise));

        return $remise;
    }

    // ---------------------------------------------------------------
    // Accord commercial : vendeur / acheteur
    // ---------------------------------------------------------------

    private function creerTradeAgreement(array $vendeur, array $acheteur, array $entete)
    {
        $accord = $this->creerElement('ram:ApplicableHeaderTradeAgreement');

        // BT-10 : référence imposée par l'acheteur (n° de service exécutant,
        // engagement juridique...). Doit être le tout premier enfant
        // d'ApplicableHeaderTradeAgreement, avant SellerTradeParty.
        // Portée par facture.reference_acheteur (bloc document, pas partie).
        if (!empty($entete['reference_acheteur'])) {
            $accord->appendChild($this->creerElementTexte(
                'ram:BuyerReference',
                $entete['reference_acheteur']
            ));
        }

        $accord->appendChild($this->creerPartie('ram:SellerTradeParty', $vendeur));
        $accord->appendChild($this->creerPartie('ram:BuyerTradeParty', $acheteur));

        // BG-11 : représentant fiscal du vendeur (BT-62 nom, BT-63 n° de
        // TVA). Coexiste normalement avec le n° de TVA propre du vendeur
        // (SpecifiedTaxRegistration dans SellerTradeParty) -- ce n'est pas
        // une alternative exclusive, la règle BR-S-02 accepte les deux à la
        // fois. Doit être positionné après BuyerTradeParty et avant
        // SellerOrderReferencedDocument dans la séquence XSD. On réutilise
        // creerPartie(), qui gère déjà chaque sous-champ de façon
        // conditionnelle (ici seuls 'nom' et 'numero_tva_intra' sont
        // attendus, sans adresse ni SIREN).
        if (!empty($vendeur['representant_fiscal']['nom'])) {
            $accord->appendChild($this->creerPartie(
                'ram:SellerTaxRepresentativeTradeParty',
                $vendeur['representant_fiscal']
            ));
        }

        // BT-14 : référence de la commande de vente, propre au vendeur (son
        // numéro d'ordre de vente interne). Doit précéder
        // BuyerOrderReferencedDocument (BT-13) dans la séquence XSD.
        // Portée par facture.reference_commande_vendeur.
        if (!empty($entete['reference_commande_vendeur'])) {
            $refVente = $this->creerElement('ram:SellerOrderReferencedDocument');
            $refVente->appendChild($this->creerElementTexte(
                'ram:IssuerAssignedID',
                $entete['reference_commande_vendeur']
            ));
            $accord->appendChild($refVente);
        }

        // BT-13 : référence de la commande transmise par l'acheteur.
        // Portée par facture.reference_commande_acheteur.
        if (!empty($entete['reference_commande_acheteur'])) {
            $refCommande = $this->creerElement('ram:BuyerOrderReferencedDocument');
            $refCommande->appendChild($this->creerElementTexte(
                'ram:IssuerAssignedID',
                $entete['reference_commande_acheteur']
            ));
            $accord->appendChild($refCommande);
        }

        return $accord;
    }

    private function creerPartie($balise, array $partie)
    {
        $element = $this->creerElement($balise);

        $element->appendChild($this->creerElementTexte('ram:Name', $partie['nom']));

        // BT-33 : informations juridiques additionnelles sur le vendeur
        // (n'existe pas côté acheteur dans EN16931 -- uniquement vendeur).
        // Doit être positionné juste après Name et avant
        // SpecifiedLegalOrganization dans la séquence XSD.
        if (!empty($partie['forme_juridique'])) {
            $element->appendChild($this->creerElementTexte('ram:Description', $partie['forme_juridique']));
        }

        // BT-34 (vendeur) / BT-49 (acheteur) : identifiant électronique de
        // routage, obligatoire (BR-FR-12/13) pour l'acheminement via
        // PDP/PPF. En France, on utilise en général le SIRET avec le schemeID
        // "0009" (annuaire Chorus Pro / annuaire de facturation électronique).
        //
        // BT-27/BT-33/BT-28/BT-41/BT-42/BT-56/BT-57 : l'ordre des éléments
        // ci-dessous n'est pas arbitraire : le schéma XSD CII impose une
        // séquence stricte (xs:sequence) pour TradePartyType :
        // Name -> Description (BT-33) -> SpecifiedLegalOrganization
        //      (avec TradingBusinessName) -> DefinedTradeContact
        //      -> PostalTradeAddress -> URIUniversalCommunication
        //      -> SpecifiedTaxRegistration.
        // Tout élément mal ordonné rend le XML invalide contre le XSD, même
        // si chaque élément pris individuellement est correct.
        if (!empty($partie['siren']) || !empty($partie['nom_commercial'])) {
            $idLegale = $this->creerElement('ram:SpecifiedLegalOrganization');
            if (!empty($partie['siren'])) {
                $idSiren = $this->creerElementTexte('ram:ID', $partie['siren']);
                $idSiren->setAttribute('schemeID', '0002'); // 0002 = SIREN (registre INSEE)
                $idLegale->appendChild($idSiren);
            }
            // BT-28 : nom commercial du vendeur, si différent de la raison
            // sociale légale (ex. enseigne commerciale). N'existe pas pour
            // l'acheteur dans EN16931 -- seul le vendeur a ce champ.
            if (!empty($partie['nom_commercial'])) {
                $idLegale->appendChild($this->creerElementTexte(
                    'ram:TradingBusinessName',
                    $partie['nom_commercial']
                ));
            }
            $element->appendChild($idLegale);
        }

        // BT-41/BT-42/BT-43 (vendeur) ou BT-56/BT-57/BT-58 (acheteur) :
        // personne de contact, téléphone et email. L'email vit maintenant
        // dans le même bloc contact plutôt que d'être un champ top-level
        // (vendeur.email / acheteur.email) qui n'était utilisé nulle part.
        $contactInfos = isset($partie['contact']) ? $partie['contact'] : array();
        if (!empty($contactInfos['nom']) || !empty($contactInfos['telephone']) || !empty($contactInfos['email'])) {
            $contact = $this->creerElement('ram:DefinedTradeContact');
            if (!empty($contactInfos['nom'])) {
                $contact->appendChild($this->creerElementTexte('ram:PersonName', $contactInfos['nom']));
            }
            if (!empty($contactInfos['telephone'])) {
                $telephone = $this->creerElement('ram:TelephoneUniversalCommunication');
                $telephone->appendChild($this->creerElementTexte(
                    'ram:CompleteNumber',
                    $contactInfos['telephone']
                ));
                $contact->appendChild($telephone);
            }
            if (!empty($contactInfos['email'])) {
                $email = $this->creerElement('ram:EmailURIUniversalCommunication');
                $email->appendChild($this->creerElementTexte('ram:URIID', $contactInfos['email']));
                $contact->appendChild($email);
            }
            $element->appendChild($contact);
        }

        if (!empty($partie['adresse'])) {
            $adresse = $this->creerElement('ram:PostalTradeAddress');
            $a = $partie['adresse'];
            $adresse->appendChild($this->creerElementTexte('ram:PostcodeCode', $a['code_postal']));
            $adresse->appendChild($this->creerElementTexte('ram:LineOne', $a['ligne1']));
            $adresse->appendChild($this->creerElementTexte('ram:CityName', $a['ville']));
            $adresse->appendChild($this->creerElementTexte('ram:CountryID', $a['pays']));
            $element->appendChild($adresse);
        }

        if (!empty($partie['identifiant_routage'])) {
            $routage = $partie['identifiant_routage'];
            $adresseElectronique = $this->creerElement('ram:URIUniversalCommunication');
            $idRoutage = $this->creerElementTexte('ram:URIID', $routage['valeur']);
            $idRoutage->setAttribute('schemeID', $routage['schemeID']);
            $adresseElectronique->appendChild($idRoutage);
            $element->appendChild($adresseElectronique);
        }

        if (!empty($partie['numero_tva_intra'])) {
            $tva = $this->creerElement('ram:SpecifiedTaxRegistration');
            $idTva = $this->creerElementTexte('ram:ID', $partie['numero_tva_intra']);
            $idTva->setAttribute('schemeID', 'VA');
            $tva->appendChild($idTva);
            $element->appendChild($tva);
        }

        return $element;
    }

    // ---------------------------------------------------------------
    // Livraison (bloc minimal requis même sans détail logistique)
    // ---------------------------------------------------------------

    private function creerTradeDelivery($dateLivraison)
    {
        // La norme n'impose pas cet élément vide : mieux vaut y placer une
        // date de livraison/exécution réelle. À défaut d'information dédiée
        // dans le JSON, on utilise ici la date d'émission de la facture.
        $livraison = $this->creerElement('ram:ApplicableHeaderTradeDelivery');

        $evenement = $this->creerElement('ram:ActualDeliverySupplyChainEvent');
        $dateOccurrence = $this->creerElement('ram:OccurrenceDateTime');
        $dateOccurrence->appendChild($this->creerDateUdt($dateLivraison));
        $evenement->appendChild($dateOccurrence);
        $livraison->appendChild($evenement);

        return $livraison;
    }

    // ---------------------------------------------------------------
    // Règlement : paiement, TVA, totaux
    // ---------------------------------------------------------------

    private function creerTradeSettlement(array $facture)
    {
        $reglement = $this->creerElement('ram:ApplicableHeaderTradeSettlement');

        // BT-83 : libellé de virement / motif de paiement, doit être le tout
        // premier enfant d'ApplicableHeaderTradeSettlement (avant même
        // InvoiceCurrencyCode) dans la séquence XSD.
        if (!empty($facture['conditions_paiement']['libelle_virement'])) {
            $reglement->appendChild($this->creerElementTexte(
                'ram:PaymentReference',
                $facture['conditions_paiement']['libelle_virement']
            ));
        }

        $reglement->appendChild($this->creerElementTexte(
            'ram:InvoiceCurrencyCode',
            $facture['facture']['devise']
        ));

        // BG-10 : bénéficiaire du paiement, uniquement quand il diffère du
        // vendeur (ex. affacturage). BT-59 (nom) est obligatoire dès que ce
        // bloc est présent (BR-17) ; BT-61 (SIREN) est optionnel. Doit être
        // positionné après InvoiceCurrencyCode et avant
        // SpecifiedTradeSettlementPaymentMeans dans la séquence XSD.
        if (!empty($facture['beneficiaire']['nom_beneficiaire'])) {
            $beneficiaire = $this->creerElement('ram:PayeeTradeParty');
            $beneficiaire->appendChild($this->creerElementTexte(
                'ram:Name',
                $facture['beneficiaire']['nom_beneficiaire']
            ));
            if (!empty($facture['beneficiaire']['siren_beneficiaire'])) {
                $idLegaleBeneficiaire = $this->creerElement('ram:SpecifiedLegalOrganization');
                $idSirenBeneficiaire = $this->creerElementTexte(
                    'ram:ID',
                    $facture['beneficiaire']['siren_beneficiaire']
                );
                $idSirenBeneficiaire->setAttribute('schemeID', '0002');
                $idLegaleBeneficiaire->appendChild($idSirenBeneficiaire);
                $beneficiaire->appendChild($idLegaleBeneficiaire);
            }
            $reglement->appendChild($beneficiaire);
        }

        // Coordonnées bancaires du vendeur
        if (!empty($facture['vendeur']['iban'])) {
            $moyenPaiement = $this->creerElement('ram:SpecifiedTradeSettlementPaymentMeans');
            $moyenPaiement->appendChild($this->creerElementTexte(
                'ram:TypeCode',
                valeurOuDefaut($facture['conditions_paiement']['mode_paiement'], '58')
            ));

            $compte = $this->creerElement('ram:PayeePartyCreditorFinancialAccount');
            $compte->appendChild($this->creerElementTexte('ram:IBANID', $facture['vendeur']['iban']));
            // BT-85 : nom du titulaire du compte, si différent de la
            // raison sociale du vendeur (ex. compte détenu par un tiers
            // encaisseur). Doit suivre IBANID dans la séquence XSD.
            if (!empty($facture['vendeur']['nom_titulaire_compte'])) {
                $compte->appendChild($this->creerElementTexte(
                    'ram:AccountName',
                    $facture['vendeur']['nom_titulaire_compte']
                ));
            }
            $moyenPaiement->appendChild($compte);

            if (!empty($facture['vendeur']['bic'])) {
                $etablissement = $this->creerElement('ram:PayeeSpecifiedCreditorFinancialInstitution');
                $etablissement->appendChild($this->creerElementTexte('ram:BICID', $facture['vendeur']['bic']));
                $moyenPaiement->appendChild($etablissement);
            }

            $reglement->appendChild($moyenPaiement);
        }

        // Ventilation de la TVA par taux/catégorie
        //
        // BR-O-08/BR-S-08 (et équivalents E/K/G/AE) : le montant de base
        // (BT-116) de chaque ventilation DOIT être égal à la somme des
        // lignes de cette catégorie (même taux le cas échéant) -- pas une
        // valeur libre. On le calcule donc nous-mêmes à partir des lignes
        // plutôt que de faire confiance au champ base_ht du JSON, qui s'est
        // déjà révélé incohérent avec les lignes réelles sur un cas concret.
        // Si le JSON fournit quand même base_ht/montant_tva, on vérifie la
        // cohérence plutôt que de l'ignorer silencieusement.
        //
        // Catégories nécessitant un motif d'exonération obligatoire
        // (BT-120/BT-121) : toutes sauf Standard (S) et Taux zéro (Z).
        $CATEGORIES_AVEC_MOTIF_OBLIGATOIRE = array('O', 'E', 'AE', 'K', 'G');

        foreach ($facture['ventilation_tva'] as $ventilation) {
            // BT-118 : même règle que pour les lignes -- pas de défaut
            // silencieux, un CategoryCode vide est une erreur de validation
            // garantie (BR-CL-18/BR-FR-15).
            if (empty($ventilation['categorie_tva'])) {
                throw new InvalidArgumentException(
                    "categorie_tva manquante dans ventilation_tva : obligatoire (BT-118)."
                );
            }
            $categorieVentilation = $ventilation['categorie_tva'];

            // Base calculée = somme des lignes de même catégorie (et même
            // taux pour les catégories où le taux s'applique).
            $baseCalculee = 0.0;
            foreach ($facture['lignes'] as $ligne) {
                $memeCategorieLigne = valeurOuDefaut($ligne['categorie_tva'], null) === $categorieVentilation;
                $memeTauxLigne = $categorieVentilation === 'O'
                    || (float) valeurOuDefaut($ligne['taux_tva'], 0) === (float) valeurOuDefaut($ventilation['taux'], 0);
                if ($memeCategorieLigne && $memeTauxLigne) {
                    $baseCalculee += (float) $ligne['montant_ht'];
                }
            }
            $baseCalculee = round($baseCalculee, 2);

            if (isset($ventilation['base_ht']) && $ventilation['base_ht'] !== null) {
                $ecart = abs((float) $ventilation['base_ht'] - $baseCalculee);
                if ($ecart > 0.01) {
                    throw new InvalidArgumentException(
                        "Incohérence ventilation_tva (catégorie {$categorieVentilation}) : " .
                        "base_ht fourni ({$ventilation['base_ht']}) != somme des lignes " .
                        "correspondantes ({$baseCalculee}). Vérifiez le JSON source " .
                        "(totaux et lignes doivent être cohérents)."
                    );
                }
            }

            $tauxVentilation = (float) valeurOuDefaut($ventilation['taux'], 0);
            $montantTvaCalcule = $categorieVentilation === 'O'
                ? 0.0
                : round($baseCalculee * $tauxVentilation / 100, 2);

            if (isset($ventilation['montant_tva']) && $ventilation['montant_tva'] !== null) {
                $ecart = abs((float) $ventilation['montant_tva'] - $montantTvaCalcule);
                if ($ecart > 0.01) {
                    throw new InvalidArgumentException(
                        "Incohérence ventilation_tva (catégorie {$categorieVentilation}) : " .
                        "montant_tva fourni ({$ventilation['montant_tva']}) != base x taux " .
                        "calculé ({$montantTvaCalcule})."
                    );
                }
            }

            // BT-120/BT-121 : motif d'exonération, obligatoire pour toute
            // catégorie autre que Standard/Taux zéro (BR-O-10, BR-E-10 et
            // équivalents). On exige un motif explicite dans le JSON plutôt
            // que d'inventer un texte générique qui pourrait être juridiquement
            // inexact selon le cas réel (bailleur non assujetti, export,
            // autoliquidation...). Calculé ici, avant la construction du
            // noeud, car ExemptionReason et ExemptionReasonCode ne sont pas
            // adjacents dans la séquence XSD (voir plus bas).
            $motifTexte = null;
            $motifCode = null;
            if (in_array($categorieVentilation, $CATEGORIES_AVEC_MOTIF_OBLIGATOIRE, true)) {
                $motifTexte = isset($ventilation['motif_exoneration']) ? $ventilation['motif_exoneration'] : null;
                $motifCode = isset($ventilation['code_motif_exoneration']) ? $ventilation['code_motif_exoneration'] : null;
                if (empty($motifTexte) && empty($motifCode)) {
                    throw new InvalidArgumentException(
                        "Motif d'exonération manquant pour ventilation_tva catégorie " .
                        "'{$categorieVentilation}' : obligatoire (BT-120 texte et/ou BT-121 " .
                        "code VATEX). Ex. pour 'O' : \"Bureaux loués nus, opération hors du " .
                        "champ d'application de la TVA (art. 261 D 2° du CGI)\"."
                    );
                }
            }

            // BR-S-10 et équivalents : catégorie standard/taux zéro NE DOIT
            // PAS porter de motif -- cohérent avec le fait qu'on ne le
            // calcule que pour les catégories qui l'exigent ci-dessus.
            //
            // Ordre XSD de ram:ApplicableTradeTax (confirmé par l'annexe
            // technique officielle Factur-X) : CalculatedAmount, TypeCode,
            // ExemptionReason, BasisAmount, CategoryCode,
            // ExemptionReasonCode, RateApplicablePercent. ExemptionReason et
            // ExemptionReasonCode ne sont PAS adjacents : le premier précède
            // BasisAmount, le second suit CategoryCode. Un ordre différent
            // rend le XML invalide contre le XSD (déjà observé comme source
            // de rejet chez d'autres implémentations Factur-X/CII).
            $taxe = $this->creerElement('ram:ApplicableTradeTax');
            $taxe->appendChild($this->creerElementTexte(
                'ram:CalculatedAmount',
                $this->formaterMontant($montantTvaCalcule)
            ));
            $taxe->appendChild($this->creerElementTexte('ram:TypeCode', 'VAT'));

            if (!empty($motifTexte)) {
                $taxe->appendChild($this->creerElementTexte('ram:ExemptionReason', $motifTexte));
            }

            $taxe->appendChild($this->creerElementTexte(
                'ram:BasisAmount',
                $this->formaterMontant($baseCalculee)
            ));
            $taxe->appendChild($this->creerElementTexte('ram:CategoryCode', $categorieVentilation));

            if (!empty($motifCode)) {
                $taxe->appendChild($this->creerElementTexte('ram:ExemptionReasonCode', $motifCode));
            }

            // BT-119 : comme au niveau ligne, le taux n'est pas fourni du
            // tout pour la catégorie "Hors champ de TVA" (O).
            if ($categorieVentilation !== 'O') {
                $taxe->appendChild($this->creerElementTexte(
                    'ram:RateApplicablePercent',
                    $this->formaterMontant($tauxVentilation)
                ));
            }

            $reglement->appendChild($taxe);
        }

        // BG-14 : période de facturation de toute la facture (BT-73/BT-74).
        // Doit être positionnée après ApplicableTradeTax (ventilation TVA)
        // et avant SpecifiedTradePaymentTerms dans la séquence XSD.
        if (!empty($facture['facture']['periode_facturation']['debut'])
            || !empty($facture['facture']['periode_facturation']['fin'])
        ) {
            $reglement->appendChild($this->creerPeriodeFacturation(
                $facture['facture']['periode_facturation'],
                'ram:BillingSpecifiedPeriod'
            ));
        }

        // Conditions de paiement et échéance
        $conditions = $this->creerElement('ram:SpecifiedTradePaymentTerms');
        if (!empty($facture['conditions_paiement']['description'])) {
            $conditions->appendChild($this->creerElementTexte(
                'ram:Description',
                $facture['conditions_paiement']['description']
            ));
        }
        if (!empty($facture['conditions_paiement']['date_echeance'])) {
            $echeance = $this->creerElement('ram:DueDateDateTime');
            $echeance->appendChild($this->creerDateUdt($facture['conditions_paiement']['date_echeance']));
            $conditions->appendChild($echeance);
        }
        $reglement->appendChild($conditions);

        // Totaux monétaires du document
        $totaux = $facture['totaux'];

        // BR-CO-10 : la somme des montants HT de ligne (BT-131) doit égaler
        // le total HT du document (BT-106). On calcule cette somme nous-mêmes
        // à partir des lignes et on vérifie la cohérence avec totaux.total_ht
        // plutôt que de faire confiance à ce dernier -- un cas réel a montré
        // un total_ht du JSON incohérent avec la somme effective des lignes.
        $totalHtLignesCalcule = 0.0;
        foreach ($facture['lignes'] as $ligne) {
            $totalHtLignesCalcule += (float) $ligne['montant_ht'];
        }
        $totalHtLignesCalcule = round($totalHtLignesCalcule, 2);

        if (isset($totaux['total_ht']) && $totaux['total_ht'] !== null) {
            $ecart = abs((float) $totaux['total_ht'] - $totalHtLignesCalcule);
            if ($ecart > 0.01) {
                throw new InvalidArgumentException(
                    "Incohérence totaux : total_ht ({$totaux['total_ht']}) != somme des " .
                    "montant_ht des lignes ({$totalHtLignesCalcule}). Vérifiez le JSON source."
                );
            }
        }

        $sommation = $this->creerElement('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
        $sommation->appendChild($this->creerElementTexte('ram:LineTotalAmount', $this->formaterMontant($totalHtLignesCalcule)));
        $sommation->appendChild($this->creerElementTexte('ram:TaxBasisTotalAmount', $this->formaterMontant($totalHtLignesCalcule)));

        // BR-CO-15 : total TTC = total HT + total TVA. On calcule
        // GrandTotalAmount à partir de ces deux valeurs plutôt que de faire
        // confiance à totaux['total_ttc'] du JSON : un total_ttc absent/faux
        // dans la source (ex. null pour une facture hors champ de TVA)
        // produisait auparavant un XML incohérent (GrandTotalAmount à 0.00
        // alors que la base HT ne l'était pas), rejeté par BR-CO-15/BR-CO-16.
        // Si le JSON fournit tout de même un total_ttc, on vérifie qu'il est
        // cohérent avec HT+TVA plutôt que de l'ignorer silencieusement.
        $totalHt = $totalHtLignesCalcule;
        $totalTva = (float) valeurOuDefaut($totaux['total_tva'], 0);
        $totalTtcCalcule = round($totalHt + $totalTva, 2);

        if (isset($totaux['total_ttc']) && $totaux['total_ttc'] !== null) {
            $ecart = abs((float) $totaux['total_ttc'] - $totalTtcCalcule);
            if ($ecart > 0.01) {
                throw new InvalidArgumentException(
                    "Incohérence totaux : total_ttc ({$totaux['total_ttc']}) != " .
                    "total_ht + total_tva ({$totalTtcCalcule}). Vérifiez le JSON source."
                );
            }
        }

        $montantTva = $this->creerElementTexte('ram:TaxTotalAmount', $this->formaterMontant($totalTva));
        $montantTva->setAttribute('currencyID', $facture['facture']['devise']);
        $sommation->appendChild($montantTva);

        $sommation->appendChild($this->creerElementTexte('ram:GrandTotalAmount', $this->formaterMontant($totalTtcCalcule)));

        if (!empty($totaux['acompte_deja_verse'])) {
            $sommation->appendChild($this->creerElementTexte(
                'ram:TotalPrepaidAmount',
                $this->formaterMontant($totaux['acompte_deja_verse'])
            ));
        }

        // BR-CO-16 : montant dû = total TTC - acompte déjà versé. Calculé
        // pour la même raison que ci-dessus plutôt que de recopier
        // aveuglément montant_a_payer du JSON.
        $acompte = (float) valeurOuDefaut($totaux['acompte_deja_verse'], 0);
        $montantAPayerCalcule = round($totalTtcCalcule - $acompte, 2);

        if (isset($totaux['montant_a_payer']) && $totaux['montant_a_payer'] !== null) {
            $ecart = abs((float) $totaux['montant_a_payer'] - $montantAPayerCalcule);
            if ($ecart > 0.01) {
                throw new InvalidArgumentException(
                    "Incohérence totaux : montant_a_payer ({$totaux['montant_a_payer']}) != " .
                    "total_ttc - acompte_deja_verse ({$montantAPayerCalcule}). Vérifiez le JSON source."
                );
            }
        }

        $sommation->appendChild($this->creerElementTexte(
            'ram:DuePayableAmount',
            $this->formaterMontant($montantAPayerCalcule)
        ));

        $reglement->appendChild($sommation);

        // BT-25/BT-26 : référence à la facture d'origine, obligatoire pour
        // un avoir (TypeCode 381) ou toute facture rectificative. Doit être
        // positionné après SpecifiedTradeSettlementHeaderMonetarySummation
        // dans la séquence XSD de ApplicableHeaderTradeSettlement.
        if (!empty($facture['facture']['reference_facture_origine'])) {
            $reglement->appendChild($this->creerInvoiceReferencedDocument(
                $facture['facture']['reference_facture_origine']
            ));
        }

        return $reglement;
    }

    private function creerInvoiceReferencedDocument(array $referenceOrigine)
    {
        $refDoc = $this->creerElement('ram:InvoiceReferencedDocument');

        $refDoc->appendChild($this->creerElementTexte(
            'ram:IssuerAssignedID',
            $referenceOrigine['numero']
        ));

        // BT-26 est optionnel dans le standard mais fortement recommandé
        // (et souvent contrôlé par les PDP/PPF) pour lever toute ambiguïté
        // en cas de plusieurs factures portant un numéro proche.
        if (!empty($referenceOrigine['date'])) {
            $dateEmission = $this->creerElement('ram:FormattedIssueDateTime');
            $dateEmission->appendChild($this->creerDateQdt($referenceOrigine['date']));
            $refDoc->appendChild($dateEmission);
        }

        return $refDoc;
    }

    // ---------------------------------------------------------------
    // Utilitaires de construction DOM
    // ---------------------------------------------------------------

    private function creerElement($balise)
    {
        return $this->doc->createElement($balise);
    }

    private function creerElementTexte($balise, $valeur)
    {
        $element = $this->doc->createElement($balise);
        $element->appendChild($this->doc->createTextNode($valeur));
        return $element;
    }

    /**
     * Valide qu'une date ISO (AAAA-MM-JJ) est une date calendaire réelle,
     * et renvoie l'objet DateTime correspondant.
     *
     * DateTime::createFromFormat() ne suffit PAS seul : PHP est permissif
     * et fait un débordement arithmétique sur les composants hors plage
     * (mois 0, jour 0...) au lieu de rejeter la date. Par exemple,
     * "0000-00-00" est silencieusement accepté et transformé en une date de
     * 1899, sans aucune erreur -- ce qui produirait un XML syntaxiquement
     * valide mais sémantiquement absurde. On vérifie donc explicitement le
     * format ET la validité calendaire (checkdate) avant d'accepter la date.
     */
    private function validerDateIso($dateIso)
    {
        if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $dateIso, $m)) {
            throw new InvalidArgumentException("Date invalide (format attendu AAAA-MM-JJ) : \"{$dateIso}\"");
        }
        list(, $annee, $mois, $jour) = $m;
        if (!checkdate((int) $mois, (int) $jour, (int) $annee)) {
            throw new InvalidArgumentException("Date invalide (calendrier) : \"{$dateIso}\"");
        }

        $date = DateTime::createFromFormat('Y-m-d', $dateIso);
        // $date ne peut plus être false ici : le format et le calendrier
        // viennent d'être validés explicitement ci-dessus.
        return $date;
    }

    private function creerDateUdt($dateIso)
    {
        // Format attendu par CII : AAAAMMJJ avec attribut format="102"
        $date = $this->validerDateIso($dateIso);

        $noeud = $this->doc->createElement('udt:DateTimeString', $date->format('Ymd'));
        $noeud->setAttribute('format', '102');
        return $noeud;
    }

    private function creerDateQdt($dateIso)
    {
        // ram:FormattedIssueDateTime attend un noeud qdt:DateTimeString
        // (namespace QualifiedDataType), à ne pas confondre avec
        // udt:DateTimeString utilisé pour IssueDateTime/OccurrenceDateTime/
        // DueDateDateTime. Même format AAAAMMJJ, code "102".
        $date = $this->validerDateIso($dateIso);

        $noeud = $this->doc->createElementNS(self::NS_QDT, 'qdt:DateTimeString', $date->format('Ymd'));
        $noeud->setAttribute('format', '102');
        return $noeud;
    }

    private function formaterMontant($montant)
    {
        return number_format((float) $montant, 2, '.', '');
    }

    /**
     * Format "à la française" (virgule décimale) pour les textes destinés à
     * la lecture humaine (mentions légales) -- à ne jamais utiliser pour des
     * valeurs XML, qui doivent rester au format machine (formaterMontant).
     */
    private function formaterNombreFr($nombre, $decimales)
    {
        return number_format((float) $nombre, $decimales, ',', '');
    }
}

// -----------------------------------------------------------------------
// 3. Point d'entrée
// -----------------------------------------------------------------------

function main($argv)
{
    if (count($argv) < 3) {
        fwrite(STDERR, "Usage : php {$argv[0]} <facture.json> <sortie.xml>\n");
        exit(1);
    }

    // Syntaxe "list() []" courte indisponible en PHP 5.6 (ajoutée en 7.1) :
    // on utilise la forme longue de list().
    list($nomScript, $cheminJson, $cheminSortie) = $argv;

    $facture = chargerJson($cheminJson);

    $builder = new FacturXCiiBuilder();
    $document = $builder->construire($facture);

    $document->save($cheminSortie);

    echo "XML CII généré avec succès : {$cheminSortie}\n";
}

if (PHP_SAPI === 'cli') {
    main($argv);
}
