Add field_numero_facture (N° Facture), a Facture column, and backfill existing content

New plain string field on ligne_comptable, positioned after Client in
both form and view displays. Added as a "Facture" column in both the
main table and the entrée/sortie drill-down modal, right after Type.

Backfilled via figli_compta_ledger_update_8002() by extracting an
invoice number from field_notes (or title, same fallback the front-end
libellé already uses) wherever the pattern is unambiguous: literal F,
optional _/-, 2-8 digits, optional "-digits" continuations for a
compound reference (e.g. "F2549-50-51"), optional 0-3 trailing
uppercase letters (e.g. "F250427A"), with a hard boundary right after --
not immediately followed by more letters/digits/underscore. That last
part is what skips "F58_260506_FIGLI" or "F_2601_FIGLI_EPAU": no way to
tell whether the trailing "_xxx" belongs to the reference or is an
unrelated client/description code glued on, so those are left blank
rather than guessed, per explicit instruction to skip when unsure.

Verified the pattern against every existing ligne_comptable's
notes/title before writing the migration: 319 confident matches with no
false positives found on manual review of the full list, 1236 left
blank. Applied via drush updb, config exported.
This commit is contained in:
2026-09-05 22:37:34 +02:00
parent 852eda2056
commit b6540e56c7
7 changed files with 151 additions and 13 deletions
@@ -9,6 +9,7 @@ dependencies:
- field.field.node.ligne_comptable.field_montant_ht - field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc - field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes - field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition - field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_type_ligne - field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable - node.type.ligne_comptable
@@ -75,6 +76,14 @@ content:
rows: 5 rows: 5
placeholder: '' placeholder: ''
third_party_settings: { } third_party_settings: { }
field_numero_facture:
type: string_textfield
weight: 3
region: content
settings:
size: 60
placeholder: ''
third_party_settings: { }
field_repartition: field_repartition:
type: paragraphs type: paragraphs
weight: 5 weight: 5
@@ -9,6 +9,7 @@ dependencies:
- field.field.node.ligne_comptable.field_montant_ht - field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc - field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes - field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition - field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_type_ligne - field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable - node.type.ligne_comptable
@@ -76,6 +77,15 @@ content:
third_party_settings: { } third_party_settings: { }
weight: 6 weight: 6
region: content region: content
field_numero_facture:
type: string
label: above
settings:
link_to_entity: false
link_rel: canonical
third_party_settings: { }
weight: 3
region: content
field_repartition: field_repartition:
type: entity_reference_revisions_entity_view type: entity_reference_revisions_entity_view
label: above label: above
@@ -0,0 +1,19 @@
uuid: f090cb6f-56d8-44cf-b18c-9f81fd3fd518
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_numero_facture
- node.type.ligne_comptable
id: node.ligne_comptable.field_numero_facture
field_name: field_numero_facture
entity_type: node
bundle: ligne_comptable
label: 'N° Facture'
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings: { }
field_type: string
@@ -0,0 +1,21 @@
uuid: 6819b88a-a788-471f-bae0-a363bb9a1670
langcode: en
status: true
dependencies:
module:
- node
id: node.field_numero_facture
field_name: field_numero_facture
entity_type: node
type: string
settings:
max_length: 64
case_sensitive: false
is_ascii: false
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -196,6 +196,8 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
_figli_entity_ref_field('node', 'ligne_comptable', 'field_client', 'Client', 'taxonomy_term', 'client'); _figli_entity_ref_field('node', 'ligne_comptable', 'field_client', 'Client', 'taxonomy_term', 'client');
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
_figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE); _figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]); _figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long'); _figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
@@ -212,10 +214,11 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0]) ->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1]) ->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2]) ->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 3]) ->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 4]) ->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 5, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']]) ->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 5])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 6]) ->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 6, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 7])
->save(); ->save();
} }
@@ -229,10 +232,81 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0]) ->setComponent('field_date_ligne', ['type' => 'datetime_default', 'weight' => 0])
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1]) ->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2]) ->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 3]) ->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 4]) ->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 4])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 5]) ->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 6]) ->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 6])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 7])
->save(); ->save();
} }
} }
/**
* Adds field_numero_facture ( Facture) to ligne_comptable. Existing
* content is backfilled separately, see figli_compta_ledger_update_8002().
*/
function figli_compta_ledger_update_8001() {
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display && !$form_display->getComponent('field_numero_facture')) {
$form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display && !$view_display->getComponent('field_numero_facture')) {
$view_display->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])->save();
}
}
/**
* Backfills field_numero_facture for existing ligne_comptable content by
* extracting an invoice number from field_notes (or the title when notes
* is empty -- the same fallback the front-end libellé column uses)
* wherever one can be confidently identified, left blank otherwise
* (never guessed).
*
* Pattern: literal F, optional _/-, 2-8 digits, then any number of
* "-digits" continuations (a genuine part of the same reference, e.g.
* "F2549-50-51" or "F2025-06-002"), then 0-3 trailing uppercase letters
* (e.g. the "A" in "F250427A"), with a hard boundary right after -- not
* immediately followed by more letters/digits/underscore. That last part
* is what rejects "F58_260506_FIGLI" or "F_2601_FIGLI_EPAU": there's no
* way to tell whether the trailing "_xxx" is still part of the invoice
* reference or an unrelated client/description code glued on after it,
* so those are left blank rather than guessing. Verified against every
* existing ligne_comptable's notes/title before writing this migration:
* 319 confident matches, no false positives found on manual review, 247
* left blank as ambiguous compound tokens of exactly this shape.
*/
function figli_compta_ledger_update_8002() {
$pattern = '/(?<![A-Za-z0-9])F[_-]?\d{2,8}(?:-\d+)*[A-Z]{0,3}(?![A-Za-z0-9_])/';
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->execute();
$filled = 0;
$skipped = 0;
// Migrated historical data has known, deliberately-preserved
// répartition mismatches -- saving these nodes for an unrelated field
// would otherwise trip figli_compta_ledger_node_presave()'s
// sum(répartition) == montant_ht check.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
$text = $node->hasField('field_notes') && !$node->get('field_notes')->isEmpty()
? $node->get('field_notes')->value
: $node->label();
if (!$text || !preg_match($pattern, $text, $matches)) {
$skipped++;
continue;
}
$node->set('field_numero_facture', $matches[0]);
$node->save();
$filled++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "Numéro de facture rempli pour $filled lignes, $skipped laissées vides (aucun motif fiable trouvé).";
}
@@ -278,6 +278,7 @@
date: attrs.field_date_ligne, date: attrs.field_date_ligne,
type: attrs.field_type_ligne, type: attrs.field_type_ligne,
client: clientTerm ? clientTerm.attributes.name : null, client: clientTerm ? clientTerm.attributes.name : null,
facture: attrs.field_numero_facture || null,
libelle: attrs.field_notes || attrs.title, libelle: attrs.field_notes || attrs.title,
montant_ht: montantHt, montant_ht: montantHt,
montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null, montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null,
@@ -102,6 +102,7 @@
<th>Date</th> <th>Date</th>
<th>Client</th> <th>Client</th>
<th>Type</th> <th>Type</th>
<th>Facture</th>
<th>Libellé / Détail</th> <th>Libellé / Détail</th>
<th class="amount">Montant HT</th> <th class="amount">Montant HT</th>
<th class="amount">Montant TTC</th> <th class="amount">Montant TTC</th>
@@ -111,13 +112,13 @@
</thead> </thead>
<tbody> <tbody>
<tr ref="topSentinel" class="figli-sentinel-row"> <tr ref="topSentinel" class="figli-sentinel-row">
<td :colspan="6 + allComptes.length + 3"> <td :colspan="7 + allComptes.length + 3">
<span v-if="loadingOlder">Chargement des mois précédents…</span> <span v-if="loadingOlder">Chargement des mois précédents…</span>
</td> </td>
</tr> </tr>
<template v-for="item in groupedRows" :key="item.key"> <template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year"> <tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
<td :colspan="6 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td> <td :colspan="7 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr> </tr>
<tr v-else :class="{'figli-error-row': item.hasError}" :data-year="item.date ? item.date.slice(0, 4) : null"> <tr v-else :class="{'figli-error-row': item.hasError}" :data-year="item.date ? item.date.slice(0, 4) : null">
<td class="actions-col"> <td class="actions-col">
@@ -162,6 +163,7 @@
@click="startEditType(item)" @click="startEditType(item)"
>{{ typeLabel(item.type) }}</span> >{{ typeLabel(item.type) }}</span>
</td> </td>
<td>{{ item.facture }}</td>
<td class="figli-libelle"> <td class="figli-libelle">
{{ item.libelle }} {{ item.libelle }}
<span <span
@@ -191,7 +193,7 @@
</tr> </tr>
</template> </template>
<tr ref="bottomSentinel" class="figli-sentinel-row"> <tr ref="bottomSentinel" class="figli-sentinel-row">
<td :colspan="6 + allComptes.length + 3"> <td :colspan="7 + allComptes.length + 3">
<span v-if="loadingNewer">Chargement des mois suivants…</span> <span v-if="loadingNewer">Chargement des mois suivants…</span>
</td> </td>
</tr> </tr>
@@ -200,7 +202,7 @@
<tr class="figli-totals-row"> <tr class="figli-totals-row">
<td class="actions-col"></td> <td class="actions-col"></td>
<td class="actions-col"></td> <td class="actions-col"></td>
<td colspan="4"> <td colspan="5">
Solde {{ currentYear || '…' }} (créditeur / débiteur) Solde {{ currentYear || '…' }} (créditeur / débiteur)
<span v-if="currentYearLoading" class="figli-note">chargement…</span> <span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td> </td>
@@ -227,6 +229,7 @@
<th>Date</th> <th>Date</th>
<th>Client</th> <th>Client</th>
<th>Type</th> <th>Type</th>
<th>Facture</th>
<th>Libellé / Détail</th> <th>Libellé / Détail</th>
<th class="amount">Montant HT</th> <th class="amount">Montant HT</th>
<th class="amount">Montant TTC</th> <th class="amount">Montant TTC</th>
@@ -246,6 +249,7 @@
<td>{{ formatDate(item.date) }}</td> <td>{{ formatDate(item.date) }}</td>
<td>{{ item.client || '—' }}</td> <td>{{ item.client || '—' }}</td>
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td> <td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
<td>{{ item.facture }}</td>
<td class="figli-libelle"> <td class="figli-libelle">
{{ item.libelle }} {{ item.libelle }}
<span <span
@@ -268,7 +272,7 @@
<tfoot> <tfoot>
<tr class="figli-totals-row"> <tr class="figli-totals-row">
<td class="actions-col"></td> <td class="actions-col"></td>
<td colspan="4">Solde entrées et sorties liées (créditeur / débiteur)</td> <td colspan="5">Solde entrées et sorties liées (créditeur / débiteur)</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td> <td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td> <td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
<td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td> <td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>