Ajoute field_ecart (Montant HT - somme répartition), stocké à la sauvegarde

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.
This commit is contained in:
2026-09-08 13:37:39 +02:00
parent 00956825fa
commit 7bcf65d25b
6 changed files with 234 additions and 66 deletions
@@ -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).";
}
@@ -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.",