TVA en sélection des taux officiels français, TTC non éditable

Le champ TVA devient un select (0 %, 2,1 %, 5,5 %, 10 %, 20 %) plutôt
qu'une saisie libre, avec une option "Autre (préciser)" qui révèle le
champ décimal existant -- indispensable pour les lignes migrées, dont
le taux rétro-calculé (figli_compta_ledger_update_8005()) est souvent
un taux effectif non standard qu'il ne faut surtout pas forcer à
s'aligner sur l'une des 5 valeurs officielles.

Montant TTC passe de readonly à #disabled : non focusable, non
éditable même via les flèches d'un input number, et Form API rejette
toute valeur soumise malgré tout au profit de #default_value.

Le select est un élément de formulaire à part (pas un widget de champ)
pour éviter que WidgetBase::extractFormValues() ne s'étouffe sur une
clé étrangère mêlée aux valeurs de field_tva -- un #validate callback
recopie la valeur choisie dans field_tva au moment opportun (après
tous les #validate, avant la reconstruction de l'entité au submit).

L'affichage/masquage du champ "Autre" repose sur du JS simple plutôt
que sur #states : #states pose bien l'attribut data-drupal-states mais
ne bascule jamais la visibilité dans cette modale AJAX précise, pour
une raison non identifiée (un #states pourtant fonctionnel existe
juste au-dessus, sur field_entree_liee, qui surveille un vrai champ
Field API plutôt qu'un select ajouté à la main).
This commit is contained in:
2026-09-07 11:56:08 +02:00
parent b7490eb76a
commit 5aa00b2ffd
6 changed files with 205 additions and 41 deletions
@@ -58,7 +58,7 @@ content:
third_party_settings: { } third_party_settings: { }
field_flag: field_flag:
type: entity_reference_autocomplete_tags type: entity_reference_autocomplete_tags
weight: 10 weight: 11
region: content region: content
settings: settings:
match_operator: CONTAINS match_operator: CONTAINS
@@ -82,7 +82,7 @@ content:
third_party_settings: { } third_party_settings: { }
field_notes: field_notes:
type: string_textarea type: string_textarea
weight: 9 weight: 10
region: content region: content
settings: settings:
rows: 5 rows: 5
@@ -98,7 +98,7 @@ content:
third_party_settings: { } third_party_settings: { }
field_repartition: field_repartition:
type: paragraphs type: paragraphs
weight: 8 weight: 9
region: content region: content
settings: settings:
title: Répartition title: Répartition
@@ -116,7 +116,7 @@ content:
third_party_settings: { } third_party_settings: { }
field_tva: field_tva:
type: number type: number
weight: 6 weight: 8
region: content region: content
settings: settings:
placeholder: '' placeholder: ''
@@ -56,7 +56,7 @@ content:
settings: settings:
link: true link: true
third_party_settings: { } third_party_settings: { }
weight: 10 weight: 11
region: content region: content
field_montant_ht: field_montant_ht:
type: number_decimal type: number_decimal
@@ -85,7 +85,7 @@ content:
label: above label: above
settings: { } settings: { }
third_party_settings: { } third_party_settings: { }
weight: 9 weight: 10
region: content region: content
field_numero_facture: field_numero_facture:
type: string type: string
@@ -103,7 +103,7 @@ content:
view_mode: default view_mode: default
link: '' link: ''
third_party_settings: { } third_party_settings: { }
weight: 8 weight: 9
region: content region: content
field_tva: field_tva:
type: number_decimal type: number_decimal
@@ -114,7 +114,7 @@ content:
scale: 2 scale: 2
prefix_suffix: true prefix_suffix: true
third_party_settings: { } third_party_settings: { }
weight: 6 weight: 8
region: content region: content
field_type_ligne: field_type_ligne:
type: list_default type: list_default
@@ -88,9 +88,18 @@ html.gin--dark-mode .figli-ledger-form {
.figli-ledger-form > .field--name-field-montant-ht { .figli-ledger-form > .field--name-field-montant-ht {
grid-column: 1 / 2; grid-column: 1 / 2;
} }
.figli-ledger-form > .field--name-field-tva { /* The field_tva_rate select (added in figli_compta_ledger_form_alter(),
not a real field so it gets no field--name-* class) fills the middle
slot of the HT/TVA/TTC row instead -- see that function's #weight
comment for why this specific ordering (weight 6, between HT's 5 and
TTC's 7) matters, not just matching column numbers. field_tva itself
only appears -- full width, its own row -- when "Autre" is picked. */
.figli-ledger-form > .js-form-item-field-tva-rate {
grid-column: 2 / 3; grid-column: 2 / 3;
} }
.figli-ledger-form > .field--name-field-tva {
grid-column: 1 / -1;
}
.figli-ledger-form > .field--name-field-montant-ttc { .figli-ledger-form > .field--name-field-montant-ttc {
grid-column: 3 / 4; grid-column: 3 / 4;
} }
@@ -135,12 +144,15 @@ html.gin--dark-mode .figli-ledger-form {
outline: 2px solid var(--flform-accent); outline: 2px solid var(--flform-accent);
outline-offset: 1px; outline-offset: 1px;
} }
/* Montant TTC: readonly, computed from HT + TVA (see /* Montant TTC: #disabled, computed from HT + TVA (see
figli_compta_ledger_node_presave()) -- a faint fill instead of the figli_compta_ledger_node_presave()) -- a faint fill and full opacity
plain white/transparent of an editable field reads as "there but not (overriding the browser's default half-transparent disabled look,
for you to type in" without looking disabled/greyed-out. */ which would make the computed number harder to read) instead of the
.figli-ledger-form input[readonly].form-element { plain white/transparent of an editable field. */
.figli-ledger-form input:disabled.form-element {
background: var(--flform-bg-subtle); background: var(--flform-bg-subtle);
color: var(--flform-text);
opacity: 1;
cursor: default; cursor: default;
} }
.figli-ledger-form input[type="date"].form-element { .figli-ledger-form input[type="date"].form-element {
@@ -243,11 +243,18 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2]) ->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]) ->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5]) ->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
->setComponent('field_tva', ['type' => 'number', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7]) ->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 8, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']]) // field_tva sits *after* Montant TTC, not between HT and TTC --
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 9]) // figli_compta_ledger_form_alter() inserts a non-field
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 10]) // "field_tva_rate" select at weight 6 (a select of the official
// French VAT rates) to fill that visual slot instead; this real
// field only becomes visible (still at its own weight, on its own
// full-width row) when "Autre" is picked there. See
// css/ledger-form.css's grid-column rules for both.
->setComponent('field_tva', ['type' => 'number', 'weight' => 8])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 9, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 10])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 11])
->save(); ->save();
} }
@@ -263,11 +270,11 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2]) ->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3]) ->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5]) ->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 7]) ->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 7])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 8]) ->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 9]) ->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 9])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 10]) ->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 10])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 11])
->save(); ->save();
} }
} }
@@ -401,23 +408,28 @@ function figli_compta_ledger_update_8004() {
$form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]); $form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]);
$form_display->setComponent('field_entree_liee', ['type' => 'entity_reference_autocomplete', 'weight' => 4] + (array) $form_display->getComponent('field_entree_liee')); $form_display->setComponent('field_entree_liee', ['type' => 'entity_reference_autocomplete', 'weight' => 4] + (array) $form_display->getComponent('field_entree_liee'));
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht')); $form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 6] + (array) $form_display->getComponent('field_tva'));
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7] + (array) $form_display->getComponent('field_montant_ttc')); $form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7] + (array) $form_display->getComponent('field_montant_ttc'));
$form_display->setComponent('field_repartition', ['weight' => 8] + (array) $form_display->getComponent('field_repartition')); // Weight 6 (between HT and TTC) is now the non-field
$form_display->setComponent('field_notes', ['weight' => 9] + (array) $form_display->getComponent('field_notes')); // "field_tva_rate" select figli_compta_ledger_form_alter() adds --
$form_display->setComponent('field_flag', ['weight' => 10] + (array) $form_display->getComponent('field_flag')); // field_tva itself moves after Répartition/Notes/Flag's *old*
// positions, past the ones below, so it lands on its own row when
// "Autre" reveals it instead of disrupting the HT/rate/TTC row.
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_tva'));
$form_display->setComponent('field_repartition', ['weight' => 9] + (array) $form_display->getComponent('field_repartition'));
$form_display->setComponent('field_notes', ['weight' => 10] + (array) $form_display->getComponent('field_notes'));
$form_display->setComponent('field_flag', ['weight' => 11] + (array) $form_display->getComponent('field_flag'));
$form_display->save(); $form_display->save();
} }
$view_display = EntityViewDisplay::load('node.ligne_comptable.default'); $view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display) { if ($view_display) {
$view_display->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 6] + (array) $view_display->getComponent('field_tva')); $view_display->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8] + (array) $view_display->getComponent('field_tva'));
$view_display->setComponent('field_numero_facture', ['weight' => 3] + (array) $view_display->getComponent('field_numero_facture')); $view_display->setComponent('field_numero_facture', ['weight' => 3] + (array) $view_display->getComponent('field_numero_facture'));
$view_display->setComponent('field_montant_ht', ['weight' => 5] + (array) $view_display->getComponent('field_montant_ht')); $view_display->setComponent('field_montant_ht', ['weight' => 5] + (array) $view_display->getComponent('field_montant_ht'));
$view_display->setComponent('field_montant_ttc', ['weight' => 7] + (array) $view_display->getComponent('field_montant_ttc')); $view_display->setComponent('field_montant_ttc', ['weight' => 7] + (array) $view_display->getComponent('field_montant_ttc'));
$view_display->setComponent('field_repartition', ['weight' => 8] + (array) $view_display->getComponent('field_repartition')); $view_display->setComponent('field_repartition', ['weight' => 9] + (array) $view_display->getComponent('field_repartition'));
$view_display->setComponent('field_notes', ['weight' => 9] + (array) $view_display->getComponent('field_notes')); $view_display->setComponent('field_notes', ['weight' => 10] + (array) $view_display->getComponent('field_notes'));
$view_display->setComponent('field_flag', ['weight' => 10] + (array) $view_display->getComponent('field_flag')); $view_display->setComponent('field_flag', ['weight' => 11] + (array) $view_display->getComponent('field_flag'));
$view_display->save(); $view_display->save();
} }
} }
@@ -107,16 +107,86 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
} }
// Montant TTC is computed from Montant HT + TVA (see // Montant TTC is computed from Montant HT + TVA (see
// figli_compta_ledger_node_presave()) -- readonly rather than #disabled // figli_compta_ledger_node_presave()) -- genuinely not editable
// so js/ledger-form.js can still show a live preview as the associate // (#disabled, not just readonly: a disabled field can't be focused or
// types, and so the field still visually reads as "there but not for // tabbed into at all, and Form API discards whatever a tampered
// you to edit" instead of vanishing. The actual value saved is always // request submits for it in favor of #default_value, same protection
// recomputed server-side regardless of what this input holds. // already used for 'revision' above). js/ledger-form.js can still set
// its .value directly for the live preview -- #disabled only blocks
// user interaction, not script writes.
if (isset($form['field_montant_ttc']['widget'][0]['value'])) { if (isset($form['field_montant_ttc']['widget'][0]['value'])) {
$form['field_montant_ttc']['widget'][0]['value']['#attributes']['readonly'] = 'readonly'; $form['field_montant_ttc']['widget'][0]['value']['#disabled'] = TRUE;
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.'); $form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
} }
// TVA: a select of the official French rates covers the overwhelming
// majority of new lines with one click, instead of associates having
// to know/type "20" or "5.5" by heart. "Autre" reveals the real
// decimal field underneath for the rest -- crucially including every
// pre-migration line, whose backfilled rate (see
// figli_compta_ledger_update_8005()) is often a blended, non-standard
// percentage that wouldn't match any of these options, and must stay
// editable/visible as the exact figure it is rather than being forced
// to snap to the nearest official rate.
if (isset($form['field_tva']['widget'][0]['value'])) {
$taux_officiels = [
'0' => t('0 % (exonéré)'),
'2.1' => t('2,1 % (taux particulier)'),
'5.5' => t('5,5 % (taux réduit)'),
'10' => t('10 % (taux intermédiaire)'),
'20' => t('20 % (taux normal)'),
];
$tva_field = &$form['field_tva']['widget'][0]['value'];
$current = $tva_field['#default_value'] ?? NULL;
$rate_default = 'autre';
if ($current === NULL || $current === '') {
$rate_default = '0';
}
else {
foreach (array_keys($taux_officiels) as $rate) {
if (abs((float) $current - (float) $rate) < 0.0001) {
$rate_default = $rate;
break;
}
}
}
// A plain top-level element, deliberately *not* nested inside
// $form['field_tva'] -- Field API's WidgetBase::extractFormValues()
// reads the entire form_state value at that path as the field's own
// delta-indexed value array, and chokes on an unrelated sibling key
// mixed into it.
//
// Weight 6: the exact slot Montant HT (5)/Montant TTC (7) leave open
// between them in figli_compta_ledger_update_8004() -- field_tva
// itself has been moved to weight 8 (its own full-width row) so
// that this select forms a clean HT/rate/TTC three-column row
// whether or not "Autre" is currently revealing the real field_tva
// input further down. See css/ledger-form.css's grid-column rules
// for both -- the row layout there depends on this exact ordering,
// not just on matching #weight values.
$form['field_tva_rate'] = [
'#type' => 'select',
'#title' => t('TVA'),
'#options' => $taux_officiels + ['autre' => t('Autre (préciser)')],
'#default_value' => $rate_default,
'#weight' => 6,
];
$tva_field['#title'] = t('Taux exact (%)');
// Not #states: this element sits outside the field system (see the
// comment above) and, tested in this exact AJAX-loaded modal,
// #states' data-drupal-states attribute gets attached correctly but
// never actually toggles visibility here -- unclear why (a working
// #states dependency already exists a few fields up, on
// field_entree_liee, watching a real Field API select rather than
// this manually-added one), not worth chasing further when
// js/ledger-form.js already has to run custom JS on this form
// anyway for the HT/TVA -> TTC live preview. Plain JS toggle there
// instead -- see figliLedgerTvaRateToggle.
$form['#validate'][] = 'figli_compta_ledger_apply_tva_rate';
}
$form['#attached']['library'][] = 'figli_compta_ledger/ledger_form'; $form['#attached']['library'][] = 'figli_compta_ledger/ledger_form';
$request = \Drupal::request(); $request = \Drupal::request();
@@ -163,6 +233,28 @@ function figli_compta_ledger_validate_repartition(array &$form, FormStateInterfa
} }
} }
/**
* Form #validate callback: when a standard French VAT rate was picked from
* the field_tva_rate select (added in figli_compta_ledger_form_alter()),
* copies it into field_tva's own submitted value -- that's the field
* figli_compta_ledger_node_presave() actually reads to compute Montant TTC.
* Left untouched when "Autre" is selected: the real field_tva input,
* shown in that case by js/ledger-form.js's figliLedgerTvaRateToggle,
* already carries whatever the associate typed there.
*
* Runs late enough to matter despite EntityForm::validateForm() building an
* interim entity earlier in the same validate phase (for constraint
* checking only) -- submitForm() rebuilds the entity fresh from
* $form_state's values right before save(), by which point every
* #validate callback, this one included, has already run.
*/
function figli_compta_ledger_apply_tva_rate(array &$form, FormStateInterface $form_state) {
$rate = $form_state->getValue('field_tva_rate');
if ($rate !== NULL && $rate !== 'autre') {
$form_state->setValue(['field_tva', 0, 'value'], $rate);
}
}
/** /**
* #ajax callback for the node form submit button: close the modal on * #ajax callback for the node form submit button: close the modal on
* success, or re-render the form (with errors) in place on failure. * success, or re-render the form (with errors) in place on failure.
@@ -25,17 +25,29 @@
once('figli-ledger-tva-calc', '[name="field_montant_ht[0][value]"]', context).forEach(function (ht) { once('figli-ledger-tva-calc', '[name="field_montant_ht[0][value]"]', context).forEach(function (ht) {
var form = ht.closest('form'); var form = ht.closest('form');
var tva = form.querySelector('[name="field_tva[0][value]"]'); var tva = form.querySelector('[name="field_tva[0][value]"]');
var rate = form.querySelector('[name="field_tva_rate"]');
var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]'); var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]');
if (!tva || !ttc) { if (!tva || !ttc) {
return; return;
} }
// The rate select (when not "Autre") is the actual source of
// truth here -- figli_compta_ledger_apply_tva_rate() copies its
// value into field_tva server-side on submit, but that happens
// too late to matter for a live client-side preview.
function currentTva() {
if (rate && rate.value !== 'autre') {
return parseFloat(rate.value);
}
return parseFloat(tva.value);
}
function recompute() { function recompute() {
var htValue = parseFloat(ht.value); var htValue = parseFloat(ht.value);
if (isNaN(htValue)) { if (isNaN(htValue)) {
return; return;
} }
var tvaValue = parseFloat(tva.value); var tvaValue = currentTva();
if (isNaN(tvaValue)) { if (isNaN(tvaValue)) {
tvaValue = 0; tvaValue = 0;
} }
@@ -48,12 +60,48 @@
// the centime (see figli_compta_ledger_update_8005()'s // the centime (see figli_compta_ledger_update_8005()'s
// docblock) -- showing a recomputed number the instant the // docblock) -- showing a recomputed number the instant the
// modal opens, before the associate has touched anything, would // modal opens, before the associate has touched anything, would
// misrepresent what's actually saved. Only recompute once HT or // misrepresent what's actually saved. Only recompute once HT,
// TVA are actually edited, matching the same "only overwrite // TVA, or the rate select are actually edited, matching the
// Montant TTC when HT/TVA actually changed" guard in // same "only overwrite Montant TTC when HT/TVA actually
// figli_compta_ledger_node_presave(). // changed" guard in figli_compta_ledger_node_presave(). Harmless
// no-op on a genuinely new/blank line either way (HT is empty,
// recompute() bails out above).
ht.addEventListener('input', recompute); ht.addEventListener('input', recompute);
tva.addEventListener('input', recompute); tva.addEventListener('input', recompute);
if (rate) {
rate.addEventListener('change', recompute);
}
});
}
};
/**
* Shows/hides the real "Taux exact (%)" field_tva input based on the
* field_tva_rate select added in figli_compta_ledger_form_alter() --
* plain JS rather than #states: tested and confirmed #states' own
* data-drupal-states attribute gets attached correctly here but never
* actually toggles visibility, for reasons not tracked down (a
* working #states dependency already exists a few fields up, on
* field_entree_liee, watching a real Field API element rather than
* this manually-added select -- that one difference is the only lead
* so far). Not worth blocking on since this form already needs custom
* JS anyway for the TTC live preview above.
*/
Drupal.behaviors.figliLedgerTvaRateToggle = {
attach: function (context) {
once('figli-ledger-tva-rate-toggle', '[name="field_tva_rate"]', context).forEach(function (select) {
var form = select.closest('form');
var exact = form.querySelector('.field--name-field-tva');
if (!exact) {
return;
}
function toggle() {
exact.style.display = select.value === 'autre' ? '' : 'none';
}
select.addEventListener('change', toggle);
toggle();
}); });
} }
}; };