From 7bcf65d25b31ae12b9a9fec87bb338ec6ff32411 Mon Sep 17 00:00:00 2001 From: bach Date: Tue, 8 Sep 2026 13:37:39 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20field=5Fecart=20(Montant=20HT=20-=20so?= =?UTF-8?q?mme=20r=C3=A9partition),=20stock=C3=A9=20=C3=A0=20la=20sauvegar?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 du chantier filtrage serveur de /lignes : l'écart devient une vraie valeur stockée plutôt que recalculée à la volée en résolvant les paragraphes de répartition, pour que le futur filtre "Écarts uniquement" côté serveur puisse filtrer directement dessus. Contrairement au Montant TTC (protégé par un garde-fou pour ne jamais altérer un TTC historique), l'écart n'a pas de valeur passée à protéger -- il reflète l'état *actuel* de la répartition, donc recalculé sans condition à chaque sauvegarde. figli_compta_ledger_ node_presave() calculait déjà cette somme pour la validation répartition == HT ; le stockage était quasi gratuit à ajouter au même endroit. Migration (update hooks 8009/8010) : rétro-calcul mécanique pour les 1556 lignes existantes (tous types, contrairement aux migrations TVA/ cotisation qui ne concernaient que les entrées client) -- aucune ambiguïté à arbitrer, juste HT moins répartition. Vérifié indépendamment ligne par ligne après coup : 0 écart entre la valeur recalculée à la main et celle stockée par la migration. Nouveau flag d'état 'figli_compta_ledger.skip_revision', utilisé uniquement par cette migration : backfiller un champ purement calculé sur 1556 lignes déjà migrées n'est pas un changement éditorial qui justifie 1556 nouvelles révisions -- vérifié qu'aucune révision supplémentaire n'a été créée. Reste indépendant de skip_validation (déjà utilisé par tous les autres scripts de migration de ce module, et qui doit continuer à créer une révision). Restructuration de figli_compta_ledger_node_presave() : le calcul de l'écart et son stockage se font maintenant même sous skip_validation (seul le lancement de l'exception reste conditionnel) -- vérifié que l'exception se déclenche toujours normalement sur une vraie répartition incohérente, et que Montant TTC/Cotisation restent inchangés sur une sauvegarde qui ne touche ni HT ni TVA. --- ...m_display.node.ligne_comptable.default.yml | 2 + ...w_display.node.ligne_comptable.default.yml | 12 ++ ...field.node.ligne_comptable.field_ecart.yml | 23 +++ .../sync/field.storage.node.field_ecart.yml | 20 +++ .../figli_compta_ledger.install | 84 +++++++++ .../figli_compta_ledger.module | 159 ++++++++++-------- 6 files changed, 234 insertions(+), 66 deletions(-) create mode 100644 config/sync/field.field.node.ligne_comptable.field_ecart.yml create mode 100644 config/sync/field.storage.node.field_ecart.yml diff --git a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml index 6d0bf25..87a1707 100644 --- a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml @@ -7,6 +7,7 @@ dependencies: - field.field.node.ligne_comptable.field_cotisation_active - field.field.node.ligne_comptable.field_cotisation_urssaf - field.field.node.ligne_comptable.field_date_ligne + - field.field.node.ligne_comptable.field_ecart - field.field.node.ligne_comptable.field_entree_liee - field.field.node.ligne_comptable.field_flag - field.field.node.ligne_comptable.field_montant_ht @@ -175,5 +176,6 @@ content: placeholder: '' third_party_settings: { } hidden: + field_ecart: true promote: true sticky: true diff --git a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml index 18556fd..33b30b5 100644 --- a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml @@ -7,6 +7,7 @@ dependencies: - field.field.node.ligne_comptable.field_cotisation_active - field.field.node.ligne_comptable.field_cotisation_urssaf - field.field.node.ligne_comptable.field_date_ligne + - field.field.node.ligne_comptable.field_ecart - field.field.node.ligne_comptable.field_entree_liee - field.field.node.ligne_comptable.field_flag - field.field.node.ligne_comptable.field_montant_ht @@ -65,6 +66,17 @@ content: third_party_settings: { } weight: 0 region: content + field_ecart: + type: number_decimal + label: above + settings: + thousand_separator: '' + decimal_separator: . + scale: 2 + prefix_suffix: true + third_party_settings: { } + weight: 13 + region: content field_entree_liee: type: entity_reference_label label: above diff --git a/config/sync/field.field.node.ligne_comptable.field_ecart.yml b/config/sync/field.field.node.ligne_comptable.field_ecart.yml new file mode 100644 index 0000000..5dcb179 --- /dev/null +++ b/config/sync/field.field.node.ligne_comptable.field_ecart.yml @@ -0,0 +1,23 @@ +uuid: 79bd7ee6-1e84-4edd-8065-775929c45fcb +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_ecart + - node.type.ligne_comptable +id: node.ligne_comptable.field_ecart +field_name: field_ecart +entity_type: node +bundle: ligne_comptable +label: Écart +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + min: null + max: null + prefix: '' + suffix: '' +field_type: decimal diff --git a/config/sync/field.storage.node.field_ecart.yml b/config/sync/field.storage.node.field_ecart.yml new file mode 100644 index 0000000..3d2de89 --- /dev/null +++ b/config/sync/field.storage.node.field_ecart.yml @@ -0,0 +1,20 @@ +uuid: 95ae1d5b-db18-4ebf-93a4-34d6522a32d6 +langcode: en +status: true +dependencies: + module: + - node +id: node.field_ecart +field_name: field_ecart +entity_type: node +type: decimal +settings: + precision: 12 + scale: 2 +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install index 1dd6f1e..2c3991d 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install @@ -226,6 +226,11 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() { _figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean'); _figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]); _figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]); + // Montant HT - somme(répartition), kept in sync at every save (see + // figli_compta_ledger_node_presave()) so /lignes' "Écarts uniquement" + // filter and its future server-side equivalent can filter on a real + // stored value instead of resolving répartition paragraphs per row. + _figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]); _figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long'); // Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely @@ -723,3 +728,82 @@ function figli_compta_ledger_update_8008() { return "field_cotisation_urssaf corrigé (delta seul, pas HT + delta) pour $fixed lignes."; } + +/** + * Adds field_ecart (Montant HT - somme répartition) to ligne_comptable -- + * kept in sync at every save from now on (see + * figli_compta_ledger_node_presave()), so the server-side filtering work + * planned for /lignes can filter on a real stored value instead of + * resolving répartition paragraphs per row on every request. Existing + * content backfilled separately, see figli_compta_ledger_update_8010(). + */ +function figli_compta_ledger_update_8009() { + // No form widget added -- purely a diagnostic/filtering value, already + // fully represented in the edit form by Montant HT + the Répartition + // paragraphs it's derived from, and already shown on /lignes' own + // Écart column. Nothing new for an associate to look at here; simply + // never calling setComponent() for it on the form display is enough + // to keep it off that form. + _figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]); + + $view_display = EntityViewDisplay::load('node.ligne_comptable.default'); + if ($view_display && !$view_display->getComponent('field_ecart')) { + $view_display->setComponent('field_ecart', ['type' => 'number_decimal', 'weight' => 13])->save(); + } +} + +/** + * Backfills field_ecart for every existing ligne_comptable (all types, + * unlike the TVA/cotisation migrations which only ever touched "Entrée + * client" lines -- répartition applies to every line regardless of + * type). Mechanical, not a judgment call like + * figli_compta_ledger_update_8007()'s TVA correction: Montant HT and the + * répartition amounts are both already-correct historical values, this + * just computes their difference and stores it, exactly what + * figli_compta_ledger_node_presave() will do going forward. + * + * Runs under two state flags, not just the usual skip_validation: + * skip_validation avoids tripping the répartition-sum exception on the + * ~18 lines with a known, deliberately-preserved historical mismatch + * (same as every other bulk script in this module); skip_revision + * additionally suppresses the forced-revision block in + * figli_compta_ledger_node_presave() -- backfilling a purely computed + * diagnostic field onto ~1500 already-migrated lines isn't an editorial + * change worth 1500 new revisions cluttering the audit trail that + * mechanism exists to protect. Montant TTC is never touched either way + * (skip_validation already keeps it out of reach, see + * figli_compta_ledger_node_presave()). + */ +function figli_compta_ledger_update_8010() { + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $nids = $storage->getQuery() + ->accessCheck(FALSE) + ->condition('type', 'ligne_comptable') + ->execute(); + + $filled = 0; + $skipped = 0; + \Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE); + \Drupal::state()->set('figli_compta_ledger.skip_revision', TRUE); + foreach ($storage->loadMultiple($nids) as $node) { + if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty() + || !$node->hasField('field_repartition')) { + $skipped++; + continue; + } + $ht = (float) $node->get('field_montant_ht')->value; + $somme = 0.0; + foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) { + if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) { + $somme += (float) $paragraph->get('field_montant')->value; + } + } + $node->set('field_ecart', round($ht - $somme, 2)); + $node->save(); + $filled++; + } + \Drupal::state()->delete('figli_compta_ledger.skip_validation'); + \Drupal::state()->delete('figli_compta_ledger.skip_revision'); + + return "Écart calculé pour $filled lignes (aucune nouvelle révision créée), $skipped laissées vides (pas de Montant HT)."; +} diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module index 77d09c3..ce1f434 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -330,7 +330,11 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa * being silently fixed. Set the 'figli_compta_ledger.skip_validation' state * flag around such a bulk import to bypass this check *and* the Montant TTC * auto-computation below; new lines entered by associates through the form - * are never exempted from either. + * are never exempted from either. field_ecart is the one thing still kept + * in sync even under skip_validation (see below) -- separately, a second + * flag ('figli_compta_ledger.skip_revision') additionally suppresses the + * forced-revision block, used only by figli_compta_ledger_update_8010()'s + * field_ecart backfill. */ function figli_compta_ledger_node_presave(NodeInterface $node) { if ($node->bundle() !== 'ligne_comptable') { @@ -341,81 +345,89 @@ function figli_compta_ledger_node_presave(NodeInterface $node) { // acting user as its author -- this is the audit trail for a shared // ledger, not the répartition-sum check below, so it's never exempted // (not even during a skip_validation import: migrated data still needs - // an honest revision history). - $node->setNewRevision(TRUE); - $node->setRevisionLogMessage(''); - $node->setRevisionUserId(\Drupal::currentUser()->id()); - // setNewRevision() alone doesn't refresh this -- it stays whatever it - // was on the entity as loaded (the previous revision's timestamp), - // silently mislabeling every edit with its predecessor's save time. - $node->setRevisionCreationTime(\Drupal::time()->getRequestTime()); + // an honest revision history). The one deliberate exception: + // figli_compta_ledger_update_8010()'s one-off field_ecart backfill sets + // 'figli_compta_ledger.skip_revision' -- a purely computed diagnostic + // value being backfilled onto ~1500 already-migrated lines isn't an + // editorial change worth 1500 new revisions cluttering the very audit + // trail this exists to protect. + if (!\Drupal::state()->get('figli_compta_ledger.skip_revision', FALSE)) { + $node->setNewRevision(TRUE); + $node->setRevisionLogMessage(''); + $node->setRevisionUserId(\Drupal::currentUser()->id()); + // setNewRevision() alone doesn't refresh this -- it stays whatever it + // was on the entity as loaded (the previous revision's timestamp), + // silently mislabeling every edit with its predecessor's save time. + $node->setRevisionCreationTime(\Drupal::time()->getRequestTime()); + } if (!$node->hasField('field_montant_ht')) { return; } - if (\Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE)) { - return; - } + $skip_validation = \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE); $montant_ht = (float) $node->get('field_montant_ht')->value; - $tva = $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : 0.0; - $type = $node->hasField('field_type_ligne') ? $node->get('field_type_ligne')->value : NULL; - $cotisation_active = $node->hasField('field_cotisation_active') && !$node->get('field_cotisation_active')->isEmpty() - ? (bool) $node->get('field_cotisation_active')->value : FALSE; - // Only "Entrée client" lines ever go through a client devis -- the - // only place the cotisation diffuseur is invoiced at all (see - // figli_compta_ledger_update_8006()'s docblock) -- so the checkbox is - // ignored for every other type even if it somehow ended up checked - // (e.g. a line's type changed after the fact). - $cotisation_applies = $type === 'entree' && $cotisation_active; - // Montant TTC is no longer entered by hand -- it's a pure function of - // Montant HT, TVA, and (entrée client only) the cotisation diffuseur - // URSSAF cascade (see figli_compta_ledger_update_8004()/_8006()). Only - // recompute it when HT, TVA, type, or the cotisation toggle actually - // changed, though, rather than on every save unconditionally: the TVA - // backfilled onto pre-migration lines (figli_compta_ledger_update_8005()/ - // _8007()) is a best-effort reconstruction and doesn't reproduce every - // historical Montant TTC to the exact centime, so blindly recomputing - // on an unrelated edit (fixing a typo in Libellé, say) would silently - // nudge an untouched historical value -- exactly what this module's - // "never correct historical data" rule exists to prevent. A genuinely - // new/changed HT, TVA, type, or cotisation toggle has no such history - // to protect. - if ($node->hasField('field_montant_ttc')) { - $inputs_changed = $node->isNew(); - if (!$inputs_changed && isset($node->original)) { - $original = $node->original; - $orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty() - ? (float) $original->get('field_montant_ht')->value : NULL; - $orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty() - ? (float) $original->get('field_tva')->value : 0.0; - $orig_type = $original->hasField('field_type_ligne') ? $original->get('field_type_ligne')->value : NULL; - $orig_cotisation_active = $original->hasField('field_cotisation_active') && !$original->get('field_cotisation_active')->isEmpty() - ? (bool) $original->get('field_cotisation_active')->value : FALSE; - $inputs_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005 - || $orig_type !== $type || $orig_cotisation_active !== $cotisation_active; - } - if ($inputs_changed) { - if ($cotisation_applies) { - // field_cotisation_urssaf stores just the 1,1% itself (the - // delta), not Montant HT + 1,1% -- that's what "1,1%" as a - // column/field label means. TVA still applies to the augmented - // base though, so $base (used for Montant TTC just below) stays - // HT + delta; only what gets *written* to the field changes. - $delta = round($montant_ht * 0.011, 2); - $base = $montant_ht + $delta; - if ($node->hasField('field_cotisation_urssaf')) { - $node->set('field_cotisation_urssaf', $delta); - } + if (!$skip_validation) { + $tva = $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : 0.0; + $type = $node->hasField('field_type_ligne') ? $node->get('field_type_ligne')->value : NULL; + $cotisation_active = $node->hasField('field_cotisation_active') && !$node->get('field_cotisation_active')->isEmpty() + ? (bool) $node->get('field_cotisation_active')->value : FALSE; + // Only "Entrée client" lines ever go through a client devis -- the + // only place the cotisation diffuseur is invoiced at all (see + // figli_compta_ledger_update_8006()'s docblock) -- so the checkbox is + // ignored for every other type even if it somehow ended up checked + // (e.g. a line's type changed after the fact). + $cotisation_applies = $type === 'entree' && $cotisation_active; + + // Montant TTC is no longer entered by hand -- it's a pure function of + // Montant HT, TVA, and (entrée client only) the cotisation diffuseur + // URSSAF cascade (see figli_compta_ledger_update_8004()/_8006()). Only + // recompute it when HT, TVA, type, or the cotisation toggle actually + // changed, though, rather than on every save unconditionally: the TVA + // backfilled onto pre-migration lines (figli_compta_ledger_update_8005()/ + // _8007()) is a best-effort reconstruction and doesn't reproduce every + // historical Montant TTC to the exact centime, so blindly recomputing + // on an unrelated edit (fixing a typo in Libellé, say) would silently + // nudge an untouched historical value -- exactly what this module's + // "never correct historical data" rule exists to prevent. A genuinely + // new/changed HT, TVA, type, or cotisation toggle has no such history + // to protect. + if ($node->hasField('field_montant_ttc')) { + $inputs_changed = $node->isNew(); + if (!$inputs_changed && isset($node->original)) { + $original = $node->original; + $orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty() + ? (float) $original->get('field_montant_ht')->value : NULL; + $orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty() + ? (float) $original->get('field_tva')->value : 0.0; + $orig_type = $original->hasField('field_type_ligne') ? $original->get('field_type_ligne')->value : NULL; + $orig_cotisation_active = $original->hasField('field_cotisation_active') && !$original->get('field_cotisation_active')->isEmpty() + ? (bool) $original->get('field_cotisation_active')->value : FALSE; + $inputs_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005 + || $orig_type !== $type || $orig_cotisation_active !== $cotisation_active; } - else { - $base = $montant_ht; - if ($node->hasField('field_cotisation_urssaf')) { - $node->set('field_cotisation_urssaf', NULL); + if ($inputs_changed) { + if ($cotisation_applies) { + // field_cotisation_urssaf stores just the 1,1% itself (the + // delta), not Montant HT + 1,1% -- that's what "1,1%" as a + // column/field label means. TVA still applies to the augmented + // base though, so $base (used for Montant TTC just below) stays + // HT + delta; only what gets *written* to the field changes. + $delta = round($montant_ht * 0.011, 2); + $base = $montant_ht + $delta; + if ($node->hasField('field_cotisation_urssaf')) { + $node->set('field_cotisation_urssaf', $delta); + } } + else { + $base = $montant_ht; + if ($node->hasField('field_cotisation_urssaf')) { + $node->set('field_cotisation_urssaf', NULL); + } + } + $node->set('field_montant_ttc', round($base * (1 + $tva / 100), 2)); } - $node->set('field_montant_ttc', round($base * (1 + $tva / 100), 2)); } } @@ -431,6 +443,21 @@ function figli_compta_ledger_node_presave(NodeInterface $node) { } $ecart = round($montant_ht - $somme, 2); + + // field_ecart mirrors this sum -- kept in sync unconditionally, + // regardless of skip_validation (see figli_compta_ledger_update_8009()'s + // docblock): unlike Montant TTC, there's no historical value to protect + // here, the field's whole purpose is to reflect the *current* + // répartition state, not a preserved snapshot -- and it's what /lignes' + // "Écarts uniquement" filter will eventually filter on server-side. + if ($node->hasField('field_ecart')) { + $node->set('field_ecart', $ecart); + } + + if ($skip_validation) { + return; + } + if (abs($ecart) > 0.01) { throw new EntityStorageException(sprintf( "Répartition incohérente : la somme des comptes (%.2f €) ne correspond pas au montant HT (%.2f €). Écart : %.2f €. Corrigez la répartition avant d'enregistrer.",