diff --git a/.gitignore b/.gitignore index ba164c5bce8..4f66e33a1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ storage/ # /app/custom_views/* # !/app/custom_views/.keep +# Git worktrees +.worktrees/ + # Webpacker public/packs public/packs-test diff --git a/Gemfile b/Gemfile index dc53dfb0cc6..7c623c587d2 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,7 @@ gem 'deep_cloneable' # Enable deep clone of active record models gem 'delayed_cron_job', require: false # Cron jobs gem 'delayed_job_active_record' gem 'delayed_job_web' +gem 'dentaku' # Math and logic formula parser and evaluator gem 'devise' gem 'devise-i18n' gem 'devise-two-factor' diff --git a/Gemfile.lock b/Gemfile.lock index 2eb96510ce5..c94878b512a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -220,6 +220,9 @@ GEM delayed_job (> 2.0.3) rack-protection (>= 1.5.5) sinatra (>= 1.4.4) + dentaku (3.5.4) + bigdecimal + concurrent-ruby descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) devise (4.9.4) @@ -953,6 +956,7 @@ DEPENDENCIES delayed_cron_job delayed_job_active_record delayed_job_web + dentaku devise devise-i18n devise-two-factor diff --git a/app/components/editable_champ/formule_component.rb b/app/components/editable_champ/formule_component.rb new file mode 100644 index 00000000000..d9744853687 --- /dev/null +++ b/app/components/editable_champ/formule_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class EditableChamp::FormuleComponent < EditableChamp::EditableChampBaseComponent + delegate :type_de_champ, to: :@champ + + def dsfr_champ_container + :div + end + + def formatted_value + return '' if @champ.value.blank? + + render FormulaValueDisplayComponent.new(value: @champ.value) + end +end diff --git a/app/components/editable_champ/formule_component/formule_component.en.yml b/app/components/editable_champ/formule_component/formule_component.en.yml new file mode 100644 index 00000000000..bb62da3c058 --- /dev/null +++ b/app/components/editable_champ/formule_component/formule_component.en.yml @@ -0,0 +1,6 @@ +en: + views: + shared: + champs: + formule: + no_value: "No calculated value" \ No newline at end of file diff --git a/app/components/editable_champ/formule_component/formule_component.fr.yml b/app/components/editable_champ/formule_component/formule_component.fr.yml new file mode 100644 index 00000000000..7b82d9c5507 --- /dev/null +++ b/app/components/editable_champ/formule_component/formule_component.fr.yml @@ -0,0 +1,6 @@ +fr: + views: + shared: + champs: + formule: + no_value: "Aucune valeur calculée" \ No newline at end of file diff --git a/app/components/editable_champ/formule_component/formule_component.html.haml b/app/components/editable_champ/formule_component/formule_component.html.haml new file mode 100644 index 00000000000..e130d6cfc60 --- /dev/null +++ b/app/components/editable_champ/formule_component/formule_component.html.haml @@ -0,0 +1,6 @@ +.fr-text.fr-text--lg{ role: "status", "aria-live": "polite", "aria-labelledby": dom_id(@champ, :label) } + - if @champ.value.present? + = formatted_value + - else + %span.fr-text--mention-grey{ "aria-label": "Aucune valeur calculée disponible pour #{@champ.libelle}" } + = t('views.shared.champs.formule.no_value') diff --git a/app/components/formula_value_display_component.rb b/app/components/formula_value_display_component.rb new file mode 100644 index 00000000000..2b8d720f01b --- /dev/null +++ b/app/components/formula_value_display_component.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class FormulaValueDisplayComponent < ApplicationComponent + include ChampHelper + + def initialize(value:) + @value = value&.to_s&.strip + end + + def formatted_value + return '' if @value.blank? + + if url?(@value) + format_as_url(@value) + elsif number?(@value) + format_as_number(@value) + elsif date?(@value) + format_as_date(@value) + else + format_text_value(@value) + end + end + + private + + def url?(value) + value.match?(/\Ahttps?:\/\//) + end + + def date?(value) + Date.parse(value) + true + rescue ArgumentError, TypeError + false + end + + def number?(value) + value.match?(/\A-?\d+(\.\d+)?\z/) + end + + def format_as_url(value) + link_to(truncate(value, length: 60), value, + target: '_blank', + rel: 'noopener noreferrer', + class: 'fr-link fr-link--external', + 'aria-label': "Lien externe : #{value} (s'ouvre dans un nouvel onglet)") + end + + def format_as_date(value) + begin + parsed_date = Date.parse(value) + content_tag(:time, + l(parsed_date, format: :long), + datetime: parsed_date.iso8601, + class: 'fr-text') + rescue ArgumentError + format_text_value(value) + end + end + + def format_as_number(value) + if value.include?('.') + number_with_precision(value.to_f, precision: 2, strip_insignificant_zeros: true) + else + number_with_delimiter(value.to_i) + end + end +end diff --git a/app/components/formula_value_display_component/formula_value_display_component.html.haml b/app/components/formula_value_display_component/formula_value_display_component.html.haml new file mode 100644 index 00000000000..8ae37be9b7e --- /dev/null +++ b/app/components/formula_value_display_component/formula_value_display_component.html.haml @@ -0,0 +1 @@ += formatted_value diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index b0df4923297..05b5f0f8889 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -52,10 +52,35 @@ = form.label :expression_reguliere_error_message, for: dom_id(type_de_champ, :expression_reguliere_error_message) do = t('.expression_reguliere.labels.error_message') = form.text_field :expression_reguliere_error_message, class: "fr-input small-margin small", id: dom_id(type_de_champ, :expression_reguliere_error_message) + - if !type_de_champ.header_section? && !type_de_champ.titre_identite? .cell.fr-mt-1w = form.label :description, "Description du champ (optionnel)", for: dom_id(type_de_champ, :description) = form.text_area :description, class: 'fr-input small-margin small width-100 resize-y', rows: 3, id: dom_id(type_de_champ, :description) + + - if type_de_champ.formule? + - available_champs = upper_coordinates.filter_map { |coord| { label: coord.type_de_champ.libelle, stable_id: coord.type_de_champ.stable_id } if coord.type_de_champ.fillable? } + .cell.fr-mt-1w{ data: { controller: 'formula-editor', formula_editor_champs_value: available_champs.to_json } } + = form.label :formule_user_expression, for: dom_id(type_de_champ, :formule_user_expression) do + Expression de la formule + = form.text_area :formule_user_expression, + value: type_de_champ.formule_user_expression, + class: "fr-input small-margin small width-100 resize-y", + rows: 3, + id: dom_id(type_de_champ, :formule_user_expression), + placeholder: "Exemple: {Montant HT} * (1 + {Taux TVA})", + data: { formula_editor_target: 'userExpression', action: 'input->formula-editor#onInput' } + = form.hidden_field :formule_expression, + value: type_de_champ.formule_expression, + id: dom_id(type_de_champ, :formule_expression), + data: { formula_editor_target: 'stableExpression' } + %p.fr-hint-text + Utilisez les références aux champs avec la syntaxe {Nom du champ}. + Fonctions disponibles : SOMME, MOYENNE, SI, MIN, MAX, ABS, ARRONDI. + %p.fr-message{ data: { formula_editor_target: 'validationMessage' }, style: 'display: none;' } + - if type_de_champ.errors[:formule_expression].any? + %p.fr-message.fr-message--error + = type_de_champ.errors[:formule_expression].join(", ") - if type_de_champ.header_section? .cell.fr-mt-1w = render TypesDeChampEditor::HeaderSectionComponent.new(form: form, tdc: type_de_champ, upper_tdcs: filtered_upper_tdcs) diff --git a/app/controllers/administrateurs/types_de_champ_controller.rb b/app/controllers/administrateurs/types_de_champ_controller.rb index 67f95705faa..e109002fa79 100644 --- a/app/controllers/administrateurs/types_de_champ_controller.rb +++ b/app/controllers/administrateurs/types_de_champ_controller.rb @@ -167,6 +167,7 @@ def type_de_champ_update_params :expression_reguliere, :expression_reguliere_exemple_text, :expression_reguliere_error_message, + :formule_expression, :lexpol_modele, :lexpol_mapping, editable_options: [ diff --git a/app/controllers/concerns/turbo_champs_concern.rb b/app/controllers/concerns/turbo_champs_concern.rb index c132de46e5a..21815231b6a 100644 --- a/app/controllers/concerns/turbo_champs_concern.rb +++ b/app/controllers/concerns/turbo_champs_concern.rb @@ -6,8 +6,11 @@ module TurboChampsConcern private def champs_to_turbo_update(params, champs) - to_update = champs.filter { _1.public_id.in?(params.keys) } - .filter { _1.refresh_after_update? || _1.forked_with_changes? } + updated_champs = champs.filter { _1.public_id.in?(params.keys) } + to_update = updated_champs.filter { _1.refresh_after_update? || _1.forked_with_changes? } + + # Add dependent formula champs + to_update += updated_champs.flat_map(&:dependent_formula_champs).uniq to_show, to_hide = champs.filter(&:conditional?) .partition(&:visible?) diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index 7cc10ae44e8..e3b54e1dd54 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -138,7 +138,8 @@ def self.resolve_type(type_definition, object, ctx) Types::Champs::Descriptor::LexpolChampDescriptorType, Types::Champs::Descriptor::YesNoChampDescriptorType, Types::Champs::Descriptor::ExpressionReguliereChampDescriptorType, - Types::Champs::Descriptor::EngagementJuridiqueChampDescriptorType + Types::Champs::Descriptor::EngagementJuridiqueChampDescriptorType, + Types::Champs::Descriptor::FormuleChampDescriptorType def self.unauthorized_object(error) # Add a top-level error to the response instead of returning nil: diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 1f188de5e72..94095a8845a 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -2791,6 +2791,39 @@ input FindDemarcheInput @oneOf { number: Int } +type FormuleChampDescriptor implements ChampDescriptor { + """ + Description des champs d’un bloc répétable. + """ + champDescriptors: [ChampDescriptor!] @deprecated(reason: "Utilisez le champ `RepetitionChampDescriptor.champ_descriptors` à la place.") + + """ + Description du champ. + """ + description: String + + """ + Expression de la formule + """ + expression: String + id: ID! + + """ + Libellé du champ. + """ + label: String! + + """ + Est-ce que le champ est obligatoire ? + """ + required: Boolean! + + """ + Type de la valeur du champ. + """ + type: TypeDeChamp! @deprecated(reason: "Utilisez le champ `__typename` à la place.") +} + interface GeoArea { description: String geometry: GeoJSON! @@ -4997,6 +5030,11 @@ enum TypeDeChamp { """ expression_reguliere + """ + Formule + """ + formule + """ Titre de section """ diff --git a/app/graphql/schema.json b/app/graphql/schema.json index 1062ac91397..fcd944463df 100644 --- a/app/graphql/schema.json +++ b/app/graphql/schema.json @@ -1735,6 +1735,11 @@ "name": "ExpressionReguliereChampDescriptor", "ofType": null }, + { + "kind": "OBJECT", + "name": "FormuleChampDescriptor", + "ofType": null + }, { "kind": "OBJECT", "name": "HeaderSectionChampDescriptor", @@ -11492,6 +11497,145 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "FormuleChampDescriptor", + "description": null, + "fields": [ + { + "name": "champDescriptors", + "description": "Description des champs d’un bloc répétable.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "ChampDescriptor", + "ofType": null + } + } + }, + "isDeprecated": true, + "deprecationReason": "Utilisez le champ `RepetitionChampDescriptor.champ_descriptors` à la place." + }, + { + "name": "description", + "description": "Description du champ.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expression", + "description": "Expression de la formule", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "label", + "description": "Libellé du champ.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "required", + "description": "Est-ce que le champ est obligatoire ?", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "Type de la valeur du champ.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TypeDeChamp", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Utilisez le champ `__typename` à la place." + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "ChampDescriptor", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INTERFACE", "name": "GeoArea", @@ -20766,6 +20910,12 @@ "description": "Visa", "isDeprecated": false, "deprecationReason": null + }, + { + "name": "formule", + "description": "Formule", + "isDeprecated": false, + "deprecationReason": null } ], "possibleTypes": null diff --git a/app/graphql/types/champ_descriptor_type.rb b/app/graphql/types/champ_descriptor_type.rb index 06839995efb..71e4bdb8d3d 100644 --- a/app/graphql/types/champ_descriptor_type.rb +++ b/app/graphql/types/champ_descriptor_type.rb @@ -124,6 +124,8 @@ def resolve_type(object, context) Types::Champs::Descriptor::COJOChampDescriptorType when TypeDeChamp.type_champs.fetch(:expression_reguliere) Types::Champs::Descriptor::ExpressionReguliereChampDescriptorType + when TypeDeChamp.type_champs.fetch(:formule) + Types::Champs::Descriptor::FormuleChampDescriptorType end end end diff --git a/app/graphql/types/champs/descriptor/formule_champ_descriptor_type.rb b/app/graphql/types/champs/descriptor/formule_champ_descriptor_type.rb new file mode 100644 index 00000000000..a46911a2574 --- /dev/null +++ b/app/graphql/types/champs/descriptor/formule_champ_descriptor_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types::Champs::Descriptor + class FormuleChampDescriptorType < Types::BaseObject + implements Types::ChampDescriptorType + + field :expression, String, "Expression de la formule", null: true + + def expression + object.type_de_champ.formule_expression + end + end +end diff --git a/app/javascript/controllers/formula_editor_controller.ts b/app/javascript/controllers/formula_editor_controller.ts new file mode 100644 index 00000000000..ae27cfa9553 --- /dev/null +++ b/app/javascript/controllers/formula_editor_controller.ts @@ -0,0 +1,143 @@ +import { ApplicationController } from './application_controller'; + +interface ChampReference { + label: string; + stable_id: number; +} + +export class FormulaEditorController extends ApplicationController { + static targets = ['userExpression', 'stableExpression', 'validationMessage']; + + declare readonly userExpressionTarget: HTMLTextAreaElement; + declare readonly stableExpressionTarget: HTMLInputElement; + declare readonly validationMessageTarget: HTMLElement; + + declare readonly hasValidationMessageTarget: boolean; + + // Available champs passed as JSON in data-formula-editor-champs-value + static values = { + champs: Array, + debounceDelay: { type: Number, default: 300 } + }; + + declare readonly champsValue: ChampReference[]; + declare readonly debounceDelayValue: number; + + private debounceTimer: number | null = null; + + connect() { + // If we have an initial stable expression but empty user expression, + // convert stable_ids back to labels for display + if (this.stableExpressionTarget.value && !this.userExpressionTarget.value) { + this.userExpressionTarget.value = this.convertToLabels(this.stableExpressionTarget.value); + } + // Convert initial expression if present + this.convertExpression(); + } + + private convertToLabels(stableExpression: string): string { + const champsMap = new Map(); + + // Build a map of stable_id -> label + this.champsValue.forEach(champ => { + champsMap.set(champ.stable_id, champ.label); + }); + + // Replace {stable_id} with {Label} + return stableExpression.replace(/\{(\d+)\}/g, (match, stableId) => { + const id = parseInt(stableId, 10); + const label = champsMap.get(id); + + if (label !== undefined) { + return `{${label}}`; + } else { + return match; // Keep original if not found + } + }); + } + + onInput() { + // Debounce conversion to avoid too many updates + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = window.setTimeout(() => { + this.convertExpression(); + }, this.debounceDelayValue); + } + + private convertExpression() { + const userExpression = this.userExpressionTarget.value; + + if (!userExpression) { + this.stableExpressionTarget.value = ''; + this.clearValidation(); + // Clear any custom validity + this.userExpressionTarget.setCustomValidity(''); + return; + } + + try { + const stableExpression = this.convertToStableIds(userExpression); + this.stableExpressionTarget.value = stableExpression; + this.showValidation('', 'success'); + // Clear any custom validity + this.userExpressionTarget.setCustomValidity(''); + } catch (error) { + // Keep the original expression with unresolved variables + // Server-side validation will catch and report them + this.stableExpressionTarget.value = userExpression; + this.showValidation(error.message, 'error'); + // Note: setCustomValidity doesn't block AJAX saves, only form submissions + } + } + + private convertToStableIds(expression: string): string { + const champsMap = new Map(); + + // Build a map of label -> stable_id + this.champsValue.forEach(champ => { + // Normalize labels for case-insensitive matching + const normalizedLabel = champ.label.trim().toLowerCase(); + champsMap.set(normalizedLabel, champ.stable_id); + }); + + // Replace {Label} with {stable_id} + return expression.replace(/\{([^}]+)\}/g, (match, label) => { + const normalizedLabel = label.trim().toLowerCase(); + const stableId = champsMap.get(normalizedLabel); + + if (stableId !== undefined) { + return `{${stableId}}`; + } else { + // If not found, check if it's already a stable_id + if (/^\d+$/.test(label.trim())) { + return match; // Already a stable_id + } + throw new Error(`Champ "${label}" non trouvé`); + } + }); + } + + private showValidation(message: string, type: 'success' | 'error') { + if (!this.hasValidationMessageTarget) return; + + if (message) { + this.validationMessageTarget.textContent = message; + this.validationMessageTarget.className = type === 'error' ? + 'fr-message fr-message--error' : + 'fr-message fr-message--valid'; + this.validationMessageTarget.style.display = 'block'; + } else { + this.clearValidation(); + } + } + + private clearValidation() { + if (!this.hasValidationMessageTarget) return; + + this.validationMessageTarget.textContent = ''; + this.validationMessageTarget.style.display = 'none'; + } +} \ No newline at end of file diff --git a/app/models/champ.rb b/app/models/champ.rb index 9bc83a2f53c..8af72d6294c 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -42,8 +42,6 @@ def type_de_champ :exclude_from_view?, :non_fillable?, :fillable?, - :te_fenua?, - :lexpol?, :mandatory?, :prefillable?, :refresh_after_update?, @@ -57,7 +55,23 @@ def type_de_champ include DateEncodingConcern # pf champ - delegate :accredited_user_list, :visa?, :table_id, to: :type_de_champ + delegate :accredited_user_list, :table_id, to: :type_de_champ + + def visa? + type_champ == 'visa' + end + + def te_fenua? + type_champ == 'te_fenua' + end + + def lexpol? + type_champ == 'lexpol' + end + + def formule? + type_champ == 'formule' + end delegate(*TypeDeChamp.type_champs.values.map { "#{_1}?".to_sym }, to: :type_de_champ) delegate :piece_justificative_or_titre_identite?, :any_drop_down_list?, to: :type_de_champ @@ -73,6 +87,7 @@ def type_de_champ before_save :cleanup_if_empty before_save :normalize after_update_commit :fetch_external_data_later + after_save :refresh_dependent_formulas def public? !private? @@ -270,6 +285,26 @@ def normalize write_attribute(:value, value.delete("\u0000")) end + def dependent_formula_champs + # Find all formula champs in the dossier that depend on this champ + dossier.champs.filter do |champ| + champ.formule? && champ.type_de_champ.dependent_stable_ids&.include?(stable_id) + end + end + + def refresh_dependent_formulas + # Only refresh formulas if this champ's value has changed and it's not a formula itself + return if !saved_change_to_value? || formule? + + dependent_formula_champs.each do |formula_champ| + # Recompute and save the formula value + new_value = formula_champ.compute_value_from_formula + if formula_champ.value != new_value + formula_champ.update_column(:value, new_value) + end + end + end + class NotImplemented < ::StandardError def initialize(method) super(":#{method} not implemented") diff --git a/app/models/champs/formule_champ.rb b/app/models/champs/formule_champ.rb new file mode 100644 index 00000000000..9aad1aca4c6 --- /dev/null +++ b/app/models/champs/formule_champ.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Champs::FormuleChamp < Champ + before_validation :store_computed_value + + validates :value, presence: true, if: :validate_champ_value? + + def blank? + value.blank? + end + + def value + return '' if type_de_champ.formule_expression.blank? + compute_value_from_formula + end + + def for_export(path = :value) + value + end + + def for_api + value + end + + def for_api_v2 + value + end + + def search_terms + [value].compact + end + + def to_s + value.to_s + end + + def compute_value_from_formula + return '' if type_de_champ.formule_expression.blank? + + begin + calculation_service = FormulaCalculationService.new(dossier) + calculation_service.compute_value(self) + rescue StandardError => e + "Erreur : #{e.message}" + end + end + + private + + def store_computed_value + if type_de_champ.formule_expression.present? + write_attribute(:value, compute_value_from_formula) + end + end +end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 2c35e83814f..330f7bdaa87 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -213,6 +213,16 @@ def dependent_conditions(tdc) end end + def dependent_formulas(tdc) + stable_id = tdc.stable_id + + (tdc.public? ? types_de_champ_public : types_de_champ_private).filter do |other_tdc| + next if !other_tdc.formule? + + other_tdc.dependent_stable_ids.include?(stable_id) + end + end + # Estimated duration to fill the form, in seconds. # # If the revision is locked (i.e. published), the result is cached (because type de champs can no longer be mutated). diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 347f0a68818..c9587ddbd3b 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -10,7 +10,8 @@ class TypeDeChamp < ApplicationRecord cojo: :cojo_type_de_champ, lexpol: :lexpol, expression_reguliere: :expression_reguliere_type_de_champ, - referentiel_de_polynesie: :referentiel_de_polynesie + referentiel_de_polynesie: :referentiel_de_polynesie, + formule: :formule } MINIMUM_TEXTAREA_CHARACTER_LIMIT_LENGTH = 400 @@ -23,7 +24,8 @@ class TypeDeChamp < ApplicationRecord referentiel_de_polynesie: 'referentiel_de_polynesie', te_fenua: 'te_fenua', lexpol: 'lexpol', - visa: 'visa' + visa: 'visa', + formule: 'formule' } STRUCTURE = :structure @@ -45,7 +47,8 @@ class TypeDeChamp < ApplicationRecord te_fenua: REFERENTIEL_EXTERNE, lexpol: REFERENTIEL_EXTERNE, referentiel_de_polynesie: REFERENTIEL_EXTERNE, - visa: STRUCTURE + visa: STRUCTURE, + formule: STRUCTURE } TYPE_DE_CHAMP_TO_CATEGORIE = { @@ -142,10 +145,10 @@ class TypeDeChamp < ApplicationRecord referentiel_de_polynesie: [:table_id, :drop_down_other], te_fenua: [:parcelles, :batiments, :zones_manuelles, :te_fenua_layer], lexpol: [:lexpol_modele, :lexpol_mapping], - visa: [:accredited_users] + visa: [:accredited_users], + formule: [:formule_expression, :dependent_stable_ids] } INSTANCE_OPTIONS = INSTANCE_OPTIONS_BY_TYPE.values.reduce(&:+).uniq - INSTANCE_CHAMPS_PARAMS = [:numero_dn, :date_de_naissance] SIMPLE_ROUTABLE_TYPES = [ @@ -249,6 +252,7 @@ def self.dump(options) before_validation :check_mandatory before_validation :normalize_libelle + validate :validate_formula_references, if: :formule? before_save :remove_attachment, if: -> { type_champ_changed? } before_validation :set_drop_down_list_options, if: -> { type_champ_changed? } @@ -398,6 +402,28 @@ def lexpol? type_champ == TypeDeChamp.type_champs.fetch(:lexpol) end + def formule? + type_champ == TypeDeChamp.type_champs.fetch(:formule) + end + + def formule_user_expression + return '' unless formule? + # For display, convert stable_ids to labels if we have a revision + @formule_user_expression ||= ( + if revisions.any? + FormulaExpressionService.convert_to_libelles(formule_expression, revisions.first) + else + formule_expression + end + ) + end + + def dependent_stable_ids + return [] unless formule? + # Extract stable_ids dynamically from the expression + formule_expression.to_s.scan(/\{(\d+)\}/).map { |match| match[0].to_i }.uniq + end + def public? !private? end @@ -835,4 +861,14 @@ def set_drop_down_list_options def normalize_libelle self.libelle&.strip! end + + def validate_formula_references + return if formule_expression.blank? + + # Find any unresolved variables (non-numeric references) + unresolved_variables = formule_expression.scan(/\{([^\d}][^}]*)\}/).map(&:first) + if unresolved_variables.any? + errors.add(:formule_expression, "Champs inconnus : #{unresolved_variables.join(', ')}") + end + end end diff --git a/app/models/types_de_champ/formule_type_de_champ.rb b/app/models/types_de_champ/formule_type_de_champ.rb new file mode 100644 index 00000000000..33f35e7d405 --- /dev/null +++ b/app/models/types_de_champ/formule_type_de_champ.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class TypesDeChamp::FormuleTypeDeChamp < TypesDeChamp::TypeDeChampBase + def initialize(type_de_champ) + super + validate_expression + end + + def estimated_fill_duration(revision) + 0.seconds + end + + private + + def validate_expression + return if @type_de_champ.formule_expression.blank? + + # TODO: Add Dentaku validation here when gem is added + # For now, basic syntax validation + expression = @type_de_champ.formule_expression.strip + + if expression.length > 1000 + @type_de_champ.errors.add(:formule_expression, :too_long, count: 1000) + end + + # Basic check for mustache syntax references + if expression.scan(/\{[^}]*\}/).any? { |ref| ref.length < 3 } + @type_de_champ.errors.add(:formule_expression, :invalid_field_reference) + end + rescue StandardError => e + @type_de_champ.errors.add(:formule_expression, :invalid_syntax, message: e.message) + end +end diff --git a/app/services/formula_calculation_service.rb b/app/services/formula_calculation_service.rb new file mode 100644 index 00000000000..d0a157e3876 --- /dev/null +++ b/app/services/formula_calculation_service.rb @@ -0,0 +1,205 @@ + +# frozen_string_literal: true + +class FormulaCalculationService + class CircularReferenceError < StandardError; end + class InvalidFieldReferenceError < StandardError; end + class CalculationError < StandardError; end + + def initialize(dossier, locale: I18n.locale) + @dossier = dossier + @locale = locale + @calculator = create_calculator + end + + def compute_value(formule_champ) + return '' if formule_champ.type_de_champ.formule_expression.blank? + + expression = formule_champ.type_de_champ.formule_expression + + # Detect circular references + detect_circular_references(formule_champ, expression) + + # Resolve field references using labels + resolved_expression = resolve_field_references(expression) + + # Calculate with Dentaku + result = @calculator.evaluate(resolved_expression) + + # Format result + format_result(result) + rescue CircularReferenceError + "Erreur : référence circulaire détectée" + rescue InvalidFieldReferenceError => e + "Erreur : champ '#{e.message}' introuvable" + rescue Dentaku::ParseError => e + "Erreur de syntaxe : #{e.message}" + rescue Dentaku::UnboundVariableError => e + "Erreur : variable '#{e.unbound_variable}' non définie" + rescue StandardError => e + "Erreur de calcul : #{e.message}" + end + + private + + def create_calculator + calculator = Dentaku::Calculator.new + + # Add French function aliases if locale is French + if @locale.to_s.start_with?('fr') + add_french_functions(calculator) + end + + calculator + end + + def add_french_functions(calculator) + # Add French aliases for common Excel functions + calculator.add_function(:SOMME, :numeric, -> (values) { values.sum }) + calculator.add_function(:MOYENNE, :numeric, -> (values) { values.sum.to_f / values.length }) + calculator.add_function(:SI, :numeric, -> (condition, true_value, false_value) { + # Treat 0, false, nil, and empty string as false (Excel-like behavior) + truthy = case condition + when nil, false, '', 0, 0.0 + false + else + true + end + truthy ? true_value : false_value + }) + calculator.add_function(:MIN, :numeric, -> (values) { values.min }) + calculator.add_function(:MAX, :numeric, -> (values) { values.max }) + calculator.add_function(:ABS, :numeric, -> (value) { value.abs }) + calculator.add_function(:ARRONDI, :numeric, -> (value, precision = 0) { value.round(precision) }) + end + + def detect_circular_references(formule_champ, expression, visited = Set.new) + champ_stable_id = formule_champ.stable_id + + if visited.include?(champ_stable_id) + raise CircularReferenceError, "Référence circulaire détectée" + end + + visited.add(champ_stable_id) + + # Extract field references from expression (now stable_ids) + field_references = extract_field_references(expression) + + field_references.each do |stable_id| + referenced_champ = find_champ_by_stable_id(stable_id) + next unless referenced_champ&.formule? + + # Recursively check for circular references + detect_circular_references( + referenced_champ, + referenced_champ.type_de_champ.formule_expression, + visited.dup + ) + end + end + + def resolve_field_references(expression) + # Replace {stable_id} patterns with actual values + expression.gsub(/\{([^}]+)\}/) do |_match| + stable_id_str = $1.strip + + # Try to parse as stable_id (integer), fallback to label for backward compatibility + if /^\d+$/.match?(stable_id_str) + champ = find_champ_by_stable_id(stable_id_str.to_i) + if champ.nil? + raise InvalidFieldReferenceError, "Champ ##{stable_id_str}" + end + else + # Fallback for old expressions using labels + champ = find_champ_by_label(stable_id_str) + if champ.nil? + raise InvalidFieldReferenceError, stable_id_str + end + end + + get_champ_numeric_value(champ) + end + end + + def extract_field_references(expression) + expression.scan(/\{([^}]+)\}/).map do |match| + ref = match[0].strip + # Return stable_id if it's a number, otherwise return as-is (for labels) + /^\d+$/.match?(ref) ? ref.to_i : ref + end + end + + def find_champ_by_label(label) + # Find champ by matching the libelle from the type_de_champ + @dossier.champs.find do |champ| + champ.libelle&.strip&.casecmp?(label.strip) + end + end + + def find_champ_by_stable_id(stable_id) + # Find champ by stable_id (more robust than label matching) + @dossier.champs.find { |champ| champ.stable_id == stable_id } + end + + def get_champ_numeric_value(champ) + case champ.type_champ + when 'integer_number' + champ.value.present? ? champ.value.to_i : 0 + when 'number', 'decimal_number' + champ.value.present? ? champ.value.to_f : 0 + when 'yes_no' + champ.value == 'true' ? 1 : 0 + when 'checkbox' + champ.value == 'on' ? 1 : 0 + when 'formule' + # Recursive calculation for formula fields + result = compute_value(champ) + result.is_a?(String) && result.match?(/\A-?\d+(\.\d+)?\z/) ? result.to_f : 0 + when 'date' + # Convert date to days since epoch for calculations + champ.value.present? ? Date.parse(champ.value).to_time.to_i / (24 * 3600) : 0 + when 'datetime' + # Convert datetime to timestamp for calculations + champ.value.present? ? DateTime.parse(champ.value).to_time.to_i : 0 + when 'drop_down_list', 'multiple_drop_down_list' + # Try to extract numbers from dropdown values + extract_number_from_text(champ.value) + else + # For text fields, try to extract numbers + extract_number_from_text(champ.value) + end + rescue StandardError + 0 + end + + def extract_number_from_text(text) + return 0 if text.blank? + + # Try to find a number in the text + number_match = text.to_s.match(/-?\d+(?:[.,]\d+)?/) + return 0 unless number_match + + # Handle French decimal separator (comma) + number_string = number_match[0].tr(',', '.') + number_string.to_f + end + + def format_result(result) + case result + when Integer + result.to_s + when Float, BigDecimal + # Si pas de partie décimale, convertir en entier + if result % 1 == 0 + result.to_i.to_s + else + # Enlever les zéros inutiles à la fin + result.to_s.sub(/\.?0+$/, '') + end + when TrueClass, FalseClass + result ? '1' : '0' + else + result.to_s + end + end +end diff --git a/app/services/formula_expression_service.rb b/app/services/formula_expression_service.rb new file mode 100644 index 00000000000..cbdf3a4c4a0 --- /dev/null +++ b/app/services/formula_expression_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class FormulaExpressionService + class << self + def convert_to_stable_ids(expression, revision) + return '' if expression.blank? + + expression.gsub(/\{([^}]+)\}/) do |match| + libelle = $1.strip + tdc = find_type_de_champ_by_libelle(libelle, revision) + + if tdc + "{#{tdc.stable_id}}" + else + match # Garde l'original si pas trouvé + end + end + end + + def convert_to_libelles(stable_expression, revision) + return '' if stable_expression.blank? + + stable_expression.gsub(/\{(\d+)\}/) do |match| + stable_id = $1.to_i + tdc = find_type_de_champ_by_stable_id(stable_id, revision) + tdc ? "{#{tdc.libelle}}" : match + end + end + + private + + def find_type_de_champ_by_libelle(libelle, revision) + revision.types_de_champ.find { |tdc| tdc.libelle&.strip&.casecmp?(libelle.strip) } + end + + def find_type_de_champ_by_stable_id(stable_id, revision) + revision.types_de_champ.find { |tdc| tdc.stable_id == stable_id } + end + end +end diff --git a/app/views/shared/champs/formule/_show.html.haml b/app/views/shared/champs/formule/_show.html.haml new file mode 100644 index 00000000000..5e42cb48069 --- /dev/null +++ b/app/views/shared/champs/formule/_show.html.haml @@ -0,0 +1,5 @@ +- if champ.value.present? + = render FormulaValueDisplayComponent.new(value: champ.value) +- else + %span.fr-text--mention-grey + Aucune valeur calculée diff --git a/champ_formule.md b/champ_formule.md new file mode 100644 index 00000000000..5b9fa75e167 --- /dev/null +++ b/champ_formule.md @@ -0,0 +1,780 @@ +# Spécification : Champ Formule + +## État actuel de l'implémentation (Août 2025) + +**✅ Statut : Fonctionnel en environnement de développement** +- Feature flag `formule` activé dans Flipper +- Service de calcul avec Dentaku opérationnel +- Conversion bidirectionnelle labels ↔ stable_ids implémentée +- Fonctions françaises (SOMME, SI, MOYENNE) disponibles + +## Descriptif + +Le champ formule permet de calculer automatiquement des valeurs **numériques ou textuelles** en fonction des champs précédents dans un formulaire. + +Basé sur l'analyse des formulaires existants en Polynésie française, cette fonctionnalité répond aux besoins concrets suivants : + +**🏆 Cas d'usage prioritaires identifiés :** +- **Totaux de commande** : Quantité × Prix unitaire (très fréquent) +- **Montants avec abattements** : Réductions pour administrations publiques (-50%) +- **Calculs de délais** : Dates d'échéance, durées de traitement +- **Messages récapitulatifs** : Confirmations personnalisées avec détails calculés +- **Concaténation d'informations** : Noms complets, adresses complètes + +### Fonctionnement + +Un champ formule s'appuie sur les champs précédents pour effectuer ses calculs. Dans le cas d'une annotation privée, la formule peut référencer tous les champs usagers et toutes les annotations qui précèdent le champ formule. + +La valeur calculée se met à jour automatiquement dès qu'un champ dépendant est modifié, en utilisant le système de conditions existant (modèle `Logic`). + +### Types de formules supportées + +- **Formules numériques** : calculs mathématiques, conditions retournant des nombres +- **Formules textuelles** : génération de texte, concaténation, conditions retournant du texte + +## Gems à ajouter au projet + +### Dentaku +```ruby +gem 'dentaku', '~> 3.5.4' +``` + +**Pourquoi Dentaku ?** +- Parser et évaluateur sécurisé pour formules mathématiques **et textuelles** +- Support des variables et fonctions personnalisées +- Gestion de la précédence des opérateurs et parenthèses +- Fonctions intégrées numériques (SUM, MIN, MAX, IF, etc.) +- **Fonctions intégrées textuelles (CONCAT, LEFT, RIGHT, MID, LEN, FIND, SUBSTITUTE, CONTAINS)** +- Évaluation sécurisée d'expressions utilisateur sans risques de sécurité +- Cache des AST pour de meilleures performances + +## Architecture implémentée + +### Système hybride labels/stable_ids + +**Principe fondamental :** Les formules utilisent des **stable_ids** en interne pour garantir la robustesse, mais affichent des **labels** pour l'utilisateur. + +#### **Workflow de conversion :** +1. **Interface utilisateur** : L'admin saisit `{Prix unitaire} * {Quantité}` +2. **Conversion JavaScript** : `FormulaEditorController` convertit en `{1234} * {5678}` +3. **Stockage BDD** : Expression sauvegardée avec stable_ids +4. **Calcul serveur** : `FormulaCalculationService` utilise les stable_ids +5. **Affichage retour** : Reconversion stable_ids → labels pour l'utilisateur + +#### **Avantages critiques des stable_ids :** +- ✅ **Immunité au renommage** : Les formules survivent aux changements de libellés +- ✅ **Unicité garantie** : Pas de collision entre champs homonymes +- ✅ **Performance** : Résolution directe sans recherche par label +- ✅ **Traçabilité** : Identification précise des dépendances + +#### **Code existant (extraits) :** +```javascript +// app/javascript/controllers/formula_editor_controller.ts +private convertToStableIds(expression: string): string { + return expression.replace(/\{([^}]+)\}/g, (match, label) => { + const stableId = champsMap.get(label.toLowerCase().trim()); + return stableId ? `{${stableId}}` : match; + }); +} +``` + +```ruby +# app/services/formula_calculation_service.rb +def resolve_field_references(expression) + expression.gsub(/\{(\d+)\}/) do |_match| + stable_id = $1.to_i + champ = find_champ_by_stable_id(stable_id) + get_champ_numeric_value(champ) + end +end +``` + +## Composants techniques + +### Contraintes d'ordre des variables (À IMPLÉMENTER) + +**Règle fondamentale :** Une formule ne peut référencer que les champs **précédents** dans l'ordre du formulaire, exactement comme le système de conditions existant. + +#### **🔒 Contraintes pour champs publics usager :** +- ✅ **Variables système** : `{Nº dossier}`, `{Date de dépôt}`, `{Demandeur}` → **Toujours disponibles** +- ✅ **Champs publics précédents** : selon `position` dans `revision_types_de_champ` +- ❌ **Champs publics suivants** : interdits (prévention références circulaires) +- ❌ **Annotations privées** : jamais accessibles depuis champs publics + +#### **🔓 Contraintes pour annotations privées (accès élargi) :** +- ✅ **Variables système** : `{Nº dossier}`, `{Date de dépôt}`, `{Demandeur}` → **Toujours disponibles** +- ✅ **TOUS les champs publics** : peu importe leur position → **Toujours accessibles** +- ✅ **Annotations privées précédentes** : selon `position` dans `revision_types_de_champ_private` +- ❌ **Annotations privées suivantes** : interdites + +#### **Justification métier :** +``` +📋 FORMULAIRE USAGER (public) 🏢 INTERFACE INSTRUCTEUR (privé) +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ 1. Nom │ ──→ │ Tous champs publics │ +│ 2. Prénom │ │ (déjà remplis par usager) │ +│ 3. Formule: {Nom} {Prénom} │ │ │ +│ │ │ 1. Note interne │ +│ ❌ Ne peut PAS voir → │ │ 2. Formule: {Nom} + {Note} │ +└─────────────────────────────┘ └─────────────────────────────┘ +``` + +**Avantages :** +- 🔄 **Cohérence totale** avec système de conditions existant +- 🚫 **Prévention boucles infinies** : A dépend de B qui dépend de A +- 👤 **UX logique** : ordre visuel = ordre logique des calculs +- ⚡ **Performance** : calculs séquentiels sans recalculs en cascade + +### Architecture : Système de colonnes avec stable_ids + +**⚠️ PRINCIPE FONDAMENTAL :** Les variables utilisent les **stable_ids** en interne, jamais les labels directement. + +**Pourquoi cette architecture hybride :** +- **Colonnes** : Pour identifier les variables disponibles et leurs contraintes d'accès +- **Stable_ids** : Pour référencer les champs de manière robuste et immuable +- **Labels** : Pour l'affichage utilisateur uniquement + +**Justification :** +- ✅ Le système de colonnes est **plus récent** et en développement actif +- ✅ Le système de tags est en **réécriture** pour avoir une interface plus conviviale +- ✅ **Cohérence** : même nommage que l'interface de filtrage instructeur +- ✅ **Évolutivité** : nouvelles colonnes automatiquement disponibles +- ✅ **Performance** : système déjà mis en cache (`Current.procedure_columns`) + +**Génération automatique des variables avec contraintes d'ordre :** +```ruby +# Dans le contrôleur de configuration du champ formule +def available_variables_for_formula(current_type_de_champ) + current_position = current_type_de_champ.coordinate.position + current_private = current_type_de_champ.private? + + # Variables système : toujours disponibles + system_variables = @procedure.columns + .select { |col| col.table.in?(['self', 'user', 'etablissement']) } + .select(&:filterable?) + + # Variables de champs selon les règles métier + if current_private + # FORMULE EN ANNOTATION PRIVÉE : accès élargi + field_variables = @procedure.columns + .select { |col| col.table == 'type_de_champ' } + .select { |col| + source_tdc = find_type_de_champ_by_stable_id(col.stable_id) + if source_tdc.public? + true # TOUS les champs publics toujours accessibles + else + # Annotations privées : seulement celles AVANT + source_tdc.coordinate.position < current_position + end + } + else + # FORMULE EN CHAMP PUBLIC : accès restreint + field_variables = @procedure.columns + .select { |col| col.table == 'type_de_champ' } + .select { |col| + source_tdc = find_type_de_champ_by_stable_id(col.stable_id) + # Seulement les champs publics PRÉCÉDENTS + source_tdc.public? && source_tdc.coordinate.position < current_position + } + end + + (system_variables + field_variables).map { |col| + { + label: col.label, # "Date de dépôt" + syntax: "{#{col.label}}", # "{Date de dépôt}" + type: col.type, # :datetime + category: col.table, # "self", "user", "type_de_champ" + h_id: col.h_id # Identifiant technique + } + } +end +``` + +### Autocomplétion des champs + +**Stimulus Controller pour l'autocomplétion :** +```javascript +// app/javascript/controllers/formula_field_controller.js +export default class extends Controller { + static targets = ["expression", "preview", "examples"] + static values = { availableFields: Array } + + connect() { + this.setupAutocomplete() + } + + // Autocomplétion sur frappe de '{' + handleInput(event) { + const text = event.target.value + const cursorPos = event.target.selectionStart + const beforeCursor = text.substring(0, cursorPos) + + if (beforeCursor.endsWith('{')) { + this.showFieldDropdown(cursorPos) + } + } + + showFieldDropdown(position) { + const dropdown = this.createDropdown(this.availableFieldsValue) + this.positionDropdown(dropdown, position) + } + + // Insertion d'un champ sélectionné + insertField(fieldName) { + const textarea = this.expressionTarget + const cursorPos = textarea.selectionStart + const text = textarea.value + + // Remplace '{' par '{Nom du champ}' + const newText = text.substring(0, cursorPos - 1) + + `{${fieldName}}` + + text.substring(cursorPos) + + textarea.value = newText + this.validateExpression() + } +} +``` + +### Banque d'exemples + +**Configuration YAML :** +```yaml +# config/formula_examples.yml - voir section précédente +``` + +**Helper pour charger les exemples :** +```ruby +# app/helpers/formula_examples_helper.rb +module FormulaExamplesHelper + def formula_examples + @formula_examples ||= YAML.load_file( + Rails.root.join('config', 'formula_examples.yml') + ) + end + + def examples_by_category(category) + formula_examples[category.to_s] || [] + end +end +``` + +**Composant Vue pour la popup d'exemples :** +```erb + +
+
+ <% %w[financial temporal text quantity].each do |category| %> +
+

<%= t("formula_examples.#{category}.title") %>

+ <% examples_by_category(category).each do |example| %> +
+ <%= example['name'] %> + <%= truncate(example['formula'], length: 40) %> + <%= example['description'] %> +
+ <% end %> +
+ <% end %> +
+
+``` + +## Étapes de développement + +### ✅ Étapes TERMINÉES (implémentation actuelle) + +#### Étape 1 : Fondations techniques ✅ +**Implémentation complète du champ formule fonctionnel** + +**Réalisé :** +- ✅ **Modèle** : `formule: 'formule'` dans TypeDeChamp +- ✅ **Classes** : + - `TypesDeChamp::FormuleTypeDeChamp` avec validation + - `Champs::FormuleChamp` avec calcul automatique +- ✅ **Service de calcul** : `FormulaCalculationService` avec Dentaku +- ✅ **Feature flag** : Activé dans Flipper +- ✅ **Conversion labels/stable_ids** : JavaScript bidirectionnel +- ✅ **Fonctions françaises** : SOMME, SI, MOYENNE, ARRONDI +- ✅ **Détection références circulaires** + +#### Étape 2 : Interface d'administration ✅ +**Interface de configuration implémentée** + +**Réalisé :** +- ✅ **Interface épurée** dans le flux d'édition des champs +- ✅ **Textarea** avec conversion labels/stable_ids transparente +- ✅ **Validation JavaScript** en temps réel +- ✅ **Messages localisés** (fr/en) +- ⚠️ **À améliorer** : Pas d'autocomplétion ni banque d'exemples + +**Interface proposée :** +``` +┌─ Champ Formule ──────────────────────────┐ +│ Libellé : [Total commande_________] │ +│ │ +│ Expression : [________________________] │ +│ 👁️ [Exemples] 🔍 [Aide] │ +│ │ +│ ☑️ Obligatoire ⚙️ Plus d'options │ +└──────────────────────────────────────────┘ +``` + +**Critères d'acceptation :** +- Interface sobre qui s'intègre naturellement dans l'éditeur +- Accès aux exemples et aide sans quitter le contexte +- Autocomplétion fluide des noms de champs +- Validation immédiate sans encombrement visuel + +#### Étape 3 : Interface usager ✅ +**Affichage en lecture seule implémenté** + +**Réalisé :** +- ✅ **Composant** `FormuleComponent` pour l'affichage +- ✅ **Valeur calculée** affichée automatiquement +- ✅ **Messages d'erreur** clairs si calcul impossible +- ✅ **Export** : Support API v1, v2 et export CSV + +### 🚀 Étapes À RÉALISER (prioritaires) + +#### Étape 4 : Contraintes d'ordre et contexte ⚠️ CRITIQUE +**En tant que système, je dois respecter les mêmes contraintes que les conditions** + +**Tâches prioritaires :** +- [ ] **Validation des stable_ids accessibles** selon position +- [ ] **Règles contexte public/privé** : + - Champ public : accès aux champs publics précédents seulement + - Annotation privée : accès à TOUS les champs publics + annotations précédentes +- [ ] **Message d'erreur explicite** si référence interdite +- [ ] **Tests de non-régression** sur formules existantes + +**Implémentation suggérée :** +```ruby +# Dans TypeDeChamp ou FormulaCalculationService +def available_stable_ids_for_formula + current_position = coordinate.position + + if private? + # Annotation : tous les publics + privés précédents + all_public_stable_ids + preceding_private_stable_ids(current_position) + else + # Champ public : seulement publics précédents + preceding_public_stable_ids(current_position) + end +end +``` + +#### Étape 5 : Autocomplétion et aide contextuelle +**En tant qu'administrateur, je veux découvrir facilement les variables disponibles** + +**Tâches :** +- [ ] **Dropdown d'autocomplétion** sur frappe de `{` +- [ ] **Liste des variables disponibles** selon contraintes d'ordre +- [ ] **Conversion automatique** label → stable_id à la sélection +- [ ] **Indicateur de type** (text, number, date) pour chaque variable + +**Code à ajouter dans FormulaEditorController :** +```typescript +// Enrichir avec les variables disponibles selon contexte +static values = { + availableVariables: Array, // Variables avec stable_id + label + type + context: String // 'public' ou 'private' +} + +private showAutocomplete() { + const variables = this.availableVariablesValue.filter(v => { + // Filtrer selon contraintes d'ordre et contexte + return this.canAccessVariable(v); + }); + + this.displayDropdown(variables); +} + +### Étape 5 : Recalcul automatique (Sprint 3) +**En tant qu'usager, je veux que mes calculs se mettent à jour automatiquement** + +**Approche Stimulus légère :** +```javascript +// Recalcul sur changement de champ +export default class extends Controller { + recalculateFormulas() { + // Détecte les champs référencés modifiés + // Recalcule uniquement les formules concernées + // Update via Turbo Stream + } +} +``` + +**Tâches priorisées :** +- [ ] Détection de changement sur les champs sources +- [ ] Recalcul différentiel (pas tout recalculer) +- [ ] Update de l'affichage via Turbo +- [ ] Protection contre les boucles infinies + +**Critères pratiques :** +- Latence < 200ms pour recalcul simple +- Support jusqu'à 5 formules par formulaire +- Fonctionnel sur mobile + +### Étape 6 : Annotations privées (Sprint 4) +**En tant qu'agent, je veux automatiser mes calculs internes** + +**Cas d'usage agent :** +- Calcul automatique de montants à facturer +- Score d'éligibilité automatique +- Récapitulatifs pour la décision + +**Tâches simples :** +- [ ] Activer le type formule dans les annotations +- [ ] Accès à tous les champs usager depuis les annotations +- [ ] Sécurité : usager ne voit pas les formules privées +- [ ] Test workflow : création → instruction → décision + +**Priorité réduite :** Peut être reporté si le MVP utilisateur fonctionne bien + +### Étape 7 : Tests et qualité (transverse) +**Intégré dans chaque sprint pour livrer de la qualité** + +**Tests essentiels par sprint :** + +**Sprint 1 :** +- [ ] Tests unitaires : `FormuleChamp#compute_value` +- [ ] Tests d'intégration : Création + calcul basique +- [ ] Tests d'erreur : Syntaxe invalide, champ inexistant + +**Sprint 2-3 :** +- [ ] Tests UI : Interface sobre, popup exemples +- [ ] Tests JavaScript : Autocomplétion, insertion +- [ ] Tests de performance : 100ms max pour recalcul + +**Sprint 4 :** +- [ ] Tests d'acceptation : Workflow complet usager/agent +- [ ] Tests de régression : Compatibilité formulaires existants + +**Objectif pragmatique :** 80% de couverture sur les chemins critiques + +### Étape 8 : Banque d'exemples et autocomplétion +**En tant qu'administrateur, je veux accéder facilement aux formules courantes et aux champs disponibles** + +**Tâches :** +- [ ] Créer le fichier de configuration `formula_examples.yml` +- [ ] Implémenter le Stimulus controller pour l'autocomplétion +- [ ] Développer la popup d'exemples avec catégorisation +- [ ] Ajouter la détection de frappe `{` pour l'autocomplétion +- [ ] Implémenter l'insertion automatique des exemples +- [ ] Créer les styles CSS pour une interface sobre + +**Critères d'acceptation :** +- Frappe de `{` déclenche l'autocomplétion des champs +- Popup d'exemples accessible via bouton discret +- Insertion d'exemple adapte automatiquement aux champs disponibles +- Interface reste légère dans le contexte d'édition de formulaire + +### Étape 9 : Documentation contextuelle +**En tant qu'utilisateur, je veux une aide immédiate sans quitter mon contexte** + +**Tâches :** +- [ ] Tooltip sur les fonctions Dentaku lors de la frappe +- [ ] Aide contextuelle intégrée (pas de documentation externe) +- [ ] Exemples dynamiques basés sur les champs disponibles +- [ ] Messages d'erreur clairs et actionables + +**Critères d'acceptation :** +- Aide accessible sans navigation externe +- Exemples adaptés au contexte du formulaire en cours +- Messages d'erreur proposent des solutions +- Formation de 5 minutes suffit pour un administrateur + +## Roadmap actualisée (post-implémentation de base) + +### ✅ Phase 1 : MVP (TERMINÉ) +- Base fonctionnelle avec Dentaku +- Conversion labels/stable_ids +- Interface d'administration minimale +- Affichage usager en lecture seule + +### 🚀 Phase 2 : Contraintes métier (2 jours) - PRIORITÉ HAUTE +- **Jour 1** : Implémenter les contraintes d'ordre + - Validation des stable_ids selon position + - Règles différenciées public/privé + - Tests unitaires complets +- **Jour 2** : Messages d'erreur et tests d'intégration + - Messages explicites pour références interdites + - Tests sur procédures complexes + +### 📝 Phase 3 : Amélioration UX (2-3 jours) +- **Autocomplétion** (1 jour) + - Dropdown des variables disponibles + - Respect des contraintes d'ordre + - Indicateurs de type +- **Banque d'exemples** (1 jour) + - 15-20 formules types + - Adaptation aux champs disponibles + - Insertion facilitée +- **Documentation contextuelle** (0.5 jour) + - Aide sur les fonctions françaises + - Tooltips explicatifs + +### 🔄 Phase 4 : Intégrations avancées (2 jours) +- **Recalcul automatique** via Stimulus +- **Intégration publipostage** : formules comme variables +- **Optimisations performance** : cache des calculs + +**Total estimé : 6-7 jours** pour une solution complète et robuste + +### Métriques de succès +- **Adoption :** 30% des nouveaux formulaires utilisent au moins 1 champ formule +- **Facilité :** 80% des administrateurs trouvent les exemples sans aide +- **Fiabilité :** 95% des formules fonctionnent du premier coup +- **Performance :** Recalcul < 100ms même avec 10 formules + +## Questions et points d'attention révisés + +### Choix d'architecture validés : + +1. **Interface utilisateur** : Comment éviter la surcharge visuelle ? + - ✅ **Validé** : Interface sobre, composant discret intégré + - ✅ **Validé** : Aide accessible via boutons icônes légers + - ✅ **Validé** : Popup d'exemples au lieu d'interface riche + +2. **Approche utilisateur** : Templates vs formules libres ? + - ✅ **Validé** : **Banque d'exemples** + liberté d'adaptation + - ✅ **Validé** : **Autocomplétion** pour la découverte des champs + - ✅ **Validé** : Progression : exemple → adaptation → création libre + +3. **Syntaxe et référencement** : + - ✅ **Validé** : Syntaxe Dentaku standard avec fonctions françaises + - ✅ **Validé** : Variables `{Label de la colonne}` (système de colonnes) + - ✅ **Validé** : Autocomplétion basée sur `procedure.columns` + - ✅ **Validé** : Types simplifiés (tout texte, conversion auto) + - ✅ **Validé** : Cohérence avec interface de filtrage instructeur + +4. **Architecture fork** : + - ✅ **Validé** : Constantes INSTANCE_* pour la compatibilité + +5. **Performance** : Comment optimiser les recalculs pour de gros formulaires ? + - Cache des expressions parsées + - Calcul paresseux (lazy evaluation) + - Éviter les recalculs en cascade + +6. **Sécurité** : Quelles sont les fonctions autorisées dans les formules ? + - Fonctions mathématiques standard + - **Fonctions de manipulation de chaînes Dentaku** + - Pas d'accès aux fonctions système + +### Banque d'exemples (basée sur l'analyse des formulaires réels et variables de colonnes) + +**Configuration de la banque d'exemples avec variables de colonnes réelles :** +```yaml +# config/formula_examples.yml +financial: + - name: "Total simple" + formula: "{Prix unitaire} * {Quantité}" + description: "Calcule le montant d'une ligne de commande" + variables: ["Prix unitaire", "Quantité"] + - name: "Total avec abattement public" + formula: 'SI({Type demandeur} = "Administration", {Montant} * 0.5, {Montant})' + description: "Applique -50% pour les administrations publiques" + variables: ["Type demandeur", "Montant"] + - name: "Montant avec remise" + formula: "{Montant} * (1 - {Taux remise} / 100)" + description: "Applique un taux de remise en pourcentage" + variables: ["Montant", "Taux remise"] + +temporal: + - name: "Délai depuis dépôt" + formula: "AUJOURDHUI() - {Date de dépôt}" + description: "Calcule les jours écoulés depuis le dépôt" + variables: ["Date de dépôt"] + - name: "Échéance automatique" + formula: "{Date de dépôt} + {Délai jours}" + description: "Calcule une date d'échéance à partir du dépôt" + variables: ["Date de dépôt", "Délai jours"] + - name: "Durée traitement" + formula: "{Date de passage en instruction} - {Date de dépôt}" + description: "Calcule la durée entre dépôt et instruction" + variables: ["Date de passage en instruction", "Date de dépôt"] + +text: + - name: "Nom complet usager" + formula: 'CONCATENER({Prénom}, " ", {Nom})' + description: "Combine prénom et nom (si particulier)" + variables: ["Prénom", "Nom"] + - name: "Récapitulatif dossier" + formula: 'CONCATENER("Dossier ", {Nº dossier}, " déposé le ", {Date de dépôt})' + description: "Message récapitulatif avec infos dossier" + variables: ["Nº dossier", "Date de dépôt"] + - name: "Statut conditionnel" + formula: 'SI({Montant} > 50000, "Validation requise", "Traitement automatique")' + description: "Message selon conditions de montant" + variables: ["Montant"] + +quantity: + - name: "Somme de quantités" + formula: "SOMME({Quantité 1}, {Quantité 2}, {Quantité 3})" + description: "Additionne plusieurs champs quantité" + variables: ["Quantité 1", "Quantité 2", "Quantité 3"] + - name: "Moyenne" + formula: "MOYENNE({Valeur 1}, {Valeur 2}, {Valeur 3})" + description: "Calcule une moyenne de valeurs" + variables: ["Valeur 1", "Valeur 2", "Valeur 3"] +``` + +**Interface popup d'exemples :** +``` +┌─ Formules courantes ─────────────────────┐ +│ 💰 Financier │ +│ • Total simple: {Prix} * {Quantité} │ +│ • Abattement public: IF({Admin}... │ +│ • Remise: {Montant} * (1 - {Taux}.. │ +│ │ +│ ⏰ Dates & Délais │ +│ • Délai: {Fin} - {Début} │ +│ • Échéance: {Date} + {Jours} │ +│ │ +│ 📝 Texte & Messages │ +│ • Nom complet: CONCAT({Prénom}... │ +│ • Récapitulatif: CONCAT("Commande... │ +│ │ +│ [Utiliser] [Adapter] [Fermer] │ +└──────────────────────────────────────────┘ +``` + +### Fonctions disponibles (noms français comme Excel) : + +**Décision d'architecture :** Utilisation de **noms de fonctions français** (style Excel) pour une meilleure familiarité des utilisateurs français. + +#### Fonctions mathématiques : +- Arithmétiques : `+`, `-`, `*`, `/`, `%` +- Comparaisons : `=`, `!=`, `<`, `>`, `<=`, `>=` +- Logiques : `ET`, `OU`, `NON` +- Conditions : `SI(condition, si_vrai, si_faux)` +- Agrégation : `SOMME()`, `MIN()`, `MAX()`, `MOYENNE()` +- Math : `ARRONDI()`, `ARRONDI.SUP()`, `ARRONDI.INF()`, `ABS()`, `RACINE()` +- Dates : `AUJOURDHUI()`, `ANNEE()`, `MOIS()`, `JOUR()` + +#### Fonctions textuelles : +- Concaténation : `CONCATENER()` +- Extraction : `GAUCHE()`, `DROITE()`, `STXT()` +- Informations : `NBCAR()`, `CHERCHE()`, `TROUVE()` +- Transformation : `SUBSTITUE()`, `MAJUSCULE()`, `MINUSCULE()` + +**Implémentation :** Configuration Dentaku avec alias français : +```ruby +# Configuration des fonctions françaises +Dentaku::Calculator.new.configure do |c| + c.add_function(:si, :numeric, ->(condition, si_vrai, si_faux) { + condition ? si_vrai : si_faux + }) + c.add_function(:somme, :numeric, ->(*args) { args.sum }) + c.add_function(:concatener, :string, ->(*args) { args.join }) + # etc... +end +``` + +### Contraintes spécifiques au fork : + +1. **Utilisation des constantes INSTANCE_*** : + - `INSTANCE_TYPE_CHAMPS` : Ajout du type `formule: 'formule'` + - `INSTANCE_TYPE_DE_CHAMP_TO_CATEGORIE` : Classification `formule: STANDARD` + - `INSTANCE_OPTIONS` : Ajout de `formule_expression` + +2. **Gestion des types simplifiée** : + - **Toutes les références de champs retournent systématiquement du texte** + - **Conversion automatique texte→numérique lors des opérations mathématiques** + - **Résultat final toujours textuel (pas de type de retour à choisir)** + +3. **À valider avec les utilisateurs réels** : + - **Test d'usage** : 3 administrateurs créent un formulaire avec champ formule + - **Métrique clé** : Temps moyen pour créer une formule de total + - **Feedback** : Points de friction dans l'interface sobre + - **Adoption** : Utilisation spontanée après formation de 5 minutes + +### Limitations initiales réalistes : + +- **Expressions limitées à 500 caractères** (largement suffisant pour 95% des cas) +- **Banque d'exemples figée** dans un premier temps (15-20 formules) +- **Résultat toujours textuel** (simplification technique) +- **Autocomplétion basique** : nom de champs uniquement (pas de fonctions) +- **Pas de formules imbriquées complexes** (garde la simplicité) + +### Interface d'autocomplétion adaptative selon contexte : + +```html + +
+
+

📁 Variables système (toujours disponibles)

+
+
Nº dossier
+
Date de dépôt
+
Demandeur
+
Nom
+
Prénom
+
+
+ + +
+

📋 Champs précédents uniquement

+
+ +
Premier champ
+
Deuxième champ
+
+
+ ⚠️ Seuls les champs situés avant ce champ formule sont disponibles +
+
+ + +
+

📋 Tous les champs usager (accès élargi)

+
+ +
Premier champ
+
Deuxième champ
+
Dernier champ
+
+
+ +
+

🏢 Annotations précédentes

+
+ +
Note interne
+
Évaluation
+
+
+ 🔓 Accès à tous les champs usager + annotations précédentes seulement +
+
+
+``` + +### Validation des contraintes d'ordre : + +```ruby +# Validation côté serveur des références de formule +def validate_formula_references + return unless formule? + + referenced_stable_ids = extract_stable_ids_from_expression(formule_expression) + available_stable_ids = available_variables_for_formula(self).map { |v| v[:stable_id] } + + invalid_refs = referenced_stable_ids - available_stable_ids + if invalid_refs.any? + errors.add(:formule_expression, "Référence interdite à des champs non disponibles") + end +end +``` + +### Évolutions futures possibles : + +- **Intelligence artificielle** : génération de formules en langage naturel +- **Templates dynamiques** : création basée sur l'usage réel +- **Validation avancée** : détection d'incohérences métier +- **Performance** : cache intelligent des calculs +- **Contraintes métier** : règles spécifiques par type de procédure \ No newline at end of file diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index ac5bc05f9a9..badb73fa774 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -37,6 +37,7 @@ def setup_features(features) :switch_domain, # :lexpol, :visa, + :formule, # pf: feature flag pour la navigation contextuelle entre personas :contextual_persona_navigation ] diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml index 492c00897b3..902760f1311 100644 --- a/config/locales/models/type_de_champ/fr.yml +++ b/config/locales/models/type_de_champ/fr.yml @@ -65,8 +65,12 @@ fr: lexpol: "Lexpol" expression_reguliere: 'Expression régulière' referentiel_de_polynesie: 'Référentiel des administrations' + formule: 'Formule' errors: type_de_champ: attributes: header_section_level: gap_error: "devrait être précédé d'un titre de niveau %{level}" + formule_expression: + invalid_field_reference: "Référence de champ invalide" + invalid_syntax: "Syntaxe invalide: %{message}" diff --git a/spec/components/types_de_champ_editor/champ_component_spec.rb b/spec/components/types_de_champ_editor/champ_component_spec.rb index 7000e7cdbd0..88288cc2580 100644 --- a/spec/components/types_de_champ_editor/champ_component_spec.rb +++ b/spec/components/types_de_champ_editor/champ_component_spec.rb @@ -83,5 +83,31 @@ expect(page).to have_css("select##{ActionView::RecordIdentifier.dom_id(coordinate, :move_and_morph)}") end end + + describe 'tdc formule' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :formule, libelle: 'Calcul TVA', formule_expression: '{Prix HT} * 1.20' }]) } + let(:coordinate) { procedure.draft_revision.revision_types_de_champ_public.first } + + it 'displays formula expression field when champ type is formule' do + expect(page).to have_field('Expression de la formule', type: 'textarea', with: '{Prix HT} * 1.20') + end + + it 'shows formula help text' do + expect(page).to have_text('Utilisez les références aux champs avec la syntaxe {Nom du champ}.') + expect(page).to have_text('Fonctions disponibles : SOMME, MOYENNE, SI, MIN, MAX, ABS, ARRONDI.') + end + + it 'has proper HTML attributes for accessibility' do + expect(page).to have_css('textarea[rows="3"]') + expect(page).to have_css('textarea.fr-input') + expect(page).to have_css('textarea[placeholder*="Montant HT"]') + end + end + end + + describe 'formule feature flag' do + it 'has formule in FEATURE_FLAGS' do + expect(TypeDeChamp::FEATURE_FLAGS[:formule]).to eq(:formule) + end end end diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index 741ae4f8e97..671bba07bef 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -213,6 +213,10 @@ factory :champ_do_not_use_expression_reguliere, class: 'Champs::ExpressionReguliereChamp' do end + factory :champ_do_not_use_formule, class: 'Champs::FormuleChamp' do + value { 'Résultat calculé' } + end + factory :champ_do_not_use_repetition, class: 'Champs::RepetitionChamp' do transient do rows { 2 } diff --git a/spec/factories/type_de_champ.rb b/spec/factories/type_de_champ.rb index babb69448b6..0de0f26525a 100644 --- a/spec/factories/type_de_champ.rb +++ b/spec/factories/type_de_champ.rb @@ -262,5 +262,23 @@ end end end + + factory :type_de_champ_formule do + libelle { 'Champ formule' } + type_champ { TypeDeChamp.type_champs.fetch(:formule) } + formule_expression { '1 + 1' } + + trait :with_simple_expression do + formule_expression { '2 * 3' } + end + + trait :with_field_reference do + formule_expression { '{Montant HT} * 1.20' } + end + + trait :with_text_formula do + formule_expression { 'CONCAT({Prénom}, " ", {Nom})' } + end + end end end diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index d0a1abbac51..24916f78393 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -741,4 +741,85 @@ end end end + + describe '#dependent_formula_champs' do + let(:dossier) { build(:dossier) } + let(:montant_champ) { Champs::IntegerNumberChamp.new(dossier: dossier, stable_id: 123, value: '100') } + let(:tva_champ) { Champs::DecimalNumberChamp.new(dossier: dossier, stable_id: 456, value: '20.0') } + let(:formule_champ) { Champs::FormuleChamp.new(dossier: dossier, stable_id: 789) } + let(:autre_formule_champ) { Champs::FormuleChamp.new(dossier: dossier, stable_id: 101) } + + let(:montant_tdc) { build(:type_de_champ_integer_number) } + let(:tva_tdc) { build(:type_de_champ_decimal_number) } + let(:formule_tdc) { build(:type_de_champ_formule, dependent_stable_ids: [123, 456]) } + let(:autre_formule_tdc) { build(:type_de_champ_formule, dependent_stable_ids: [123]) } + + before do + allow(montant_champ).to receive(:type_de_champ).and_return(montant_tdc) + allow(tva_champ).to receive(:type_de_champ).and_return(tva_tdc) + allow(formule_champ).to receive(:type_de_champ).and_return(formule_tdc) + allow(autre_formule_champ).to receive(:type_de_champ).and_return(autre_formule_tdc) + + allow(dossier).to receive(:champs).and_return([montant_champ, tva_champ, formule_champ, autre_formule_champ]) + end + + context 'when champ is referenced by formula champs' do + it 'returns all formula champs that depend on this champ' do + dependent_champs = montant_champ.dependent_formula_champs + + expect(dependent_champs).to include(formule_champ, autre_formule_champ) + end + + it 'returns only relevant formula champs' do + dependent_champs = tva_champ.dependent_formula_champs + + expect(dependent_champs).to include(formule_champ) + expect(dependent_champs).not_to include(autre_formule_champ) + end + end + + context 'when champ is not referenced by any formula' do + let(:text_champ) { Champs::TextChamp.new(dossier: dossier, stable_id: 999) } + let(:text_tdc) { build(:type_de_champ_text) } + + before do + allow(text_champ).to receive(:type_de_champ).and_return(text_tdc) + allow(dossier).to receive(:champs).and_return([montant_champ, tva_champ, formule_champ, autre_formule_champ, text_champ]) + end + + it 'returns empty array' do + dependent_champs = text_champ.dependent_formula_champs + + expect(dependent_champs).to be_empty + end + end + + context 'when there are no formula champs in dossier' do + before do + allow(dossier).to receive(:champs).and_return([montant_champ, tva_champ]) + end + + it 'returns empty array' do + dependent_champs = montant_champ.dependent_formula_champs + + expect(dependent_champs).to be_empty + end + end + + context 'when dependent_stable_ids is nil' do + let(:formule_tdc_without_deps) { build(:type_de_champ_formule, dependent_stable_ids: nil) } + let(:formule_champ_without_deps) { Champs::FormuleChamp.new(dossier: dossier, stable_id: 202) } + + before do + allow(formule_champ_without_deps).to receive(:type_de_champ).and_return(formule_tdc_without_deps) + allow(dossier).to receive(:champs).and_return([montant_champ, formule_champ_without_deps]) + end + + it 'handles nil gracefully' do + dependent_champs = montant_champ.dependent_formula_champs + + expect(dependent_champs).to be_empty + end + end + end end diff --git a/spec/models/champs/formule_champ_spec.rb b/spec/models/champs/formule_champ_spec.rb new file mode 100644 index 00000000000..fb33dba8d63 --- /dev/null +++ b/spec/models/champs/formule_champ_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +describe Champs::FormuleChamp do + let(:type_de_champ) { build(:type_de_champ_formule, formule_expression: expression) } + let(:champ) { Champs::FormuleChamp.new(dossier: build(:dossier)) } + + before do + allow(champ).to receive(:type_de_champ).and_return(type_de_champ) + end + + describe '#value' do + context 'with simple expression' do + let(:expression) { '1 + 1' } + + it 'returns the computed value' do + expect(champ.value).to eq('2') + end + end + + context 'with field references' do + let(:expression) { '{Montant HT} * 2' } + let(:other_champ) { Champs::IntegerNumberChamp.new(dossier: champ.dossier, value: '100') } + + before do + allow(other_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_integer_number, libelle: 'Montant HT')) + allow(champ.dossier).to receive(:champs).and_return([champ, other_champ]) + end + + it 'resolves field references and computes' do + expect(champ.value).to eq('200') + end + end + + context 'with unsupported function' do + let(:expression) { 'CONCAT({Prénom}, " ", {Nom})' } + + it 'returns error message for unsupported functions' do + expect(champ.value).to include('Erreur') + end + end + + context 'with blank expression' do + let(:expression) { '' } + + it 'returns empty string' do + expect(champ.value).to eq('') + end + end + + context 'with nil expression' do + let(:expression) { nil } + + it 'returns empty string' do + expect(champ.value).to eq('') + end + end + end + + describe '#blank?' do + context 'with computed value' do + let(:expression) { '1 + 1' } + + it 'is not blank' do + expect(champ).not_to be_blank + end + end + + context 'with no expression' do + let(:expression) { '' } + + it 'is blank' do + expect(champ).to be_blank + end + end + end + + describe '#for_export' do + let(:expression) { '1 + 1' } + + it 'returns the computed value' do + expect(champ.for_export).to eq(champ.value) + end + end + + describe '#for_api' do + let(:expression) { '1 + 1' } + + it 'returns the computed value' do + expect(champ.for_api).to eq(champ.value) + end + end + + describe '#for_api_v2' do + let(:expression) { '1 + 1' } + + it 'returns the computed value' do + expect(champ.for_api_v2).to eq(champ.value) + end + end + + describe '#search_terms' do + let(:expression) { 'Résultat test' } + + it 'returns an array with the computed value' do + expect(champ.search_terms).to eq([champ.value]) + end + end + + describe '#to_s' do + let(:expression) { '1 + 1' } + + it 'returns the computed value as string' do + expect(champ.to_s).to eq(champ.value.to_s) + end + end + + describe 'validation' do + let(:test_champ) { Champs::FormuleChamp.new(dossier: build(:dossier)) } + + before do + allow(test_champ).to receive(:type_de_champ).and_return(type_de_champ) + end + + context 'with expression and computed value' do + let(:expression) { '1 + 1' } + + it 'is valid' do + expect(test_champ).to be_valid + end + end + + context 'with no expression' do + let(:expression) { '' } + + it 'is valid' do + expect(test_champ).to be_valid + end + end + end + + describe 'before_validation callback' do + let(:expression) { '2 + 2' } + + it 'has the callback defined' do + expect(Champs::FormuleChamp._validation_callbacks.map(&:filter)).to include(:store_computed_value) + end + end + + describe '#compute_value_from_formula' do + let(:dossier) { build(:dossier) } + let(:formule_champ) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: expression)) + end + + context 'with simple arithmetic' do + let(:expression) { '2 + 2' } + + it 'computes the result' do + expect(formule_champ.compute_value_from_formula).to eq('4') + end + end + + context 'with field references' do + let(:expression) { '{Autre nombre} * 2' } + let(:other_champ) { Champs::IntegerNumberChamp.new(dossier: dossier, value: '10') } + + before do + allow(other_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_integer_number, libelle: 'Autre nombre')) + allow(dossier).to receive(:champs).and_return([formule_champ, other_champ]) + end + + it 'resolves field references' do + expect(formule_champ.compute_value_from_formula).to eq('20') + end + end + + context 'when expression is blank' do + let(:expression) { '' } + + it 'returns empty string' do + expect(formule_champ.compute_value_from_formula).to eq('') + end + end + + context 'with invalid reference' do + let(:expression) { '{Inexistant}' } + + before do + allow(dossier).to receive(:champs).and_return([formule_champ]) + end + + it 'returns error message' do + expect(formule_champ.compute_value_from_formula).to include('Erreur') + end + end + end +end diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 65a68809406..a213dfe5ada 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -503,4 +503,43 @@ def never_valid it { is_expected.to eq([["« Référentiel des administrations »"], ["« Oui/Non »", "« Case à cocher seule »", "« Choix simple »", "« Choix multiple »"], ["« Nombre entier »", "« Nombre décimal »"], ["« Adresse en France »", "« Communes »", "« EPCI »", "« Départements »", "« Régions »", "« Pays »", "« Commune de Polynésie »", "« Code Postal de Polynésie »"]]) } end + + describe 'formula expression methods' do + let(:procedure) { build(:procedure) } + let(:revision) { procedure.active_revision } + let(:montant_tdc) { build(:type_de_champ_integer_number, libelle: 'Montant HT', stable_id: 123) } + let(:formule_tdc) { build(:type_de_champ_formule) } + + before do + allow(revision).to receive(:types_de_champ).and_return([montant_tdc, formule_tdc]) + allow(formule_tdc).to receive(:revisions).and_return([revision]) + end + + describe '#formule_user_expression' do + context 'when formule_expression contains stable_ids' do + before do + formule_tdc.formule_expression = '{123} * 1.2' + end + + it 'converts to user-friendly libelles' do + expect(formule_tdc.formule_user_expression).to eq('{Montant HT} * 1.2') + end + + it 'caches the result' do + expect(FormulaExpressionService).to receive(:convert_to_libelles).once.and_return('{Montant HT} * 1.2') + + 2.times { formule_tdc.formule_user_expression } + end + end + + context 'when not a formule type' do + let(:text_tdc) { build(:type_de_champ_text) } + + it 'returns empty string' do + expect(text_tdc.formule_user_expression).to eq('') + end + end + end + + end end diff --git a/spec/models/types_de_champ/formule_type_de_champ_spec.rb b/spec/models/types_de_champ/formule_type_de_champ_spec.rb new file mode 100644 index 00000000000..64f8f89e703 --- /dev/null +++ b/spec/models/types_de_champ/formule_type_de_champ_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +describe TypesDeChamp::FormuleTypeDeChamp do + let(:type_de_champ) { build(:type_de_champ_formule, formule_expression: expression) } + let(:formule_type_de_champ) { TypesDeChamp::FormuleTypeDeChamp.new(type_de_champ) } + + describe 'validation' do + context 'with valid expression' do + let(:expression) { '1 + 1' } + + it 'is valid' do + expect(formule_type_de_champ).to be_valid + expect(type_de_champ).to be_valid + end + end + + context 'with simple field reference' do + let(:expression) { '{Montant HT} * 1.20' } + + it 'is valid' do + expect(formule_type_de_champ).to be_valid + expect(type_de_champ).to be_valid + end + end + + context 'with text formula' do + let(:expression) { 'CONCAT({Prénom}, " ", {Nom})' } + + it 'is valid' do + expect(formule_type_de_champ).to be_valid + expect(type_de_champ).to be_valid + end + end + + context 'with too long expression' do + let(:expression) { 'A' * 1001 } + + it 'is invalid' do + formule_type_de_champ # trigger initialization + expect(type_de_champ.errors[:formule_expression]).to be_present + end + end + + context 'with invalid field reference' do + let(:expression) { '{}' } + + it 'is invalid' do + formule_type_de_champ # trigger initialization + expect(type_de_champ.errors[:formule_expression]).to be_present + end + end + + context 'with blank expression' do + let(:expression) { '' } + + it 'is valid' do + expect(formule_type_de_champ).to be_valid + expect(type_de_champ).to be_valid + end + end + end + + describe '#estimated_fill_duration' do + let(:expression) { '1 + 1' } + let(:revision) { build(:procedure_revision) } + + it 'returns 0 seconds as formule fields are not fillable' do + expect(formule_type_de_champ.estimated_fill_duration(revision)).to eq(0.seconds) + end + end +end diff --git a/spec/services/formula_calculation_service_spec.rb b/spec/services/formula_calculation_service_spec.rb new file mode 100644 index 00000000000..40b8ecf76b0 --- /dev/null +++ b/spec/services/formula_calculation_service_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +describe FormulaCalculationService do + let(:dossier) { create(:dossier) } + let(:service) { described_class.new(dossier) } + + describe '#compute_value' do + context 'with simple arithmetic expressions' do + let(:formule_champ) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '2 + 3')) + end + + it 'computes basic arithmetic' do + expect(service.compute_value(formule_champ)).to eq('5') + end + end + + context 'with field references' do + let!(:montant_champ) { Champs::IntegerNumberChamp.new(dossier: dossier, value: '1000') } + let!(:taux_champ) { Champs::DecimalNumberChamp.new(dossier: dossier, value: '0.20') } + let(:formule_champ) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(montant_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_integer_number, libelle: 'Montant HT')) + allow(taux_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_decimal_number, libelle: 'Taux TVA')) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Montant HT} * (1 + {Taux TVA})')) + allow(dossier).to receive(:champs).and_return([montant_champ, taux_champ, formule_champ]) + end + + it 'resolves field references and computes' do + expect(service.compute_value(formule_champ)).to eq('1200') + end + end + + context 'with French functions' do + let!(:prix1_champ) { Champs::DecimalNumberChamp.new(dossier: dossier, value: '100') } + let!(:prix2_champ) { Champs::DecimalNumberChamp.new(dossier: dossier, value: '200') } + let!(:prix3_champ) { Champs::DecimalNumberChamp.new(dossier: dossier, value: '150') } + + before do + allow(prix1_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_decimal_number, libelle: 'Prix 1')) + allow(prix2_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_decimal_number, libelle: 'Prix 2')) + allow(prix3_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_decimal_number, libelle: 'Prix 3')) + allow(dossier).to receive(:champs).and_return([prix1_champ, prix2_champ, prix3_champ]) + end + + context 'when locale is French' do + let(:service) { described_class.new(dossier, locale: :fr) } + + it 'supports SOMME function' do + formule_champ = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Prix 1} + {Prix 2} + {Prix 3}')) + allow(dossier).to receive(:champs).and_return([prix1_champ, prix2_champ, prix3_champ, formule_champ]) + expect(service.compute_value(formule_champ)).to eq('450') + end + + it 'supports MOYENNE function' do + formule_champ = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '({Prix 1} + {Prix 2} + {Prix 3}) / 3')) + allow(dossier).to receive(:champs).and_return([prix1_champ, prix2_champ, prix3_champ, formule_champ]) + expect(service.compute_value(formule_champ)).to eq('150') + end + + it 'supports SI function' do + formule_champ = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: 'SI({Prix 1} > 50, {Prix 2}, {Prix 3})')) + allow(dossier).to receive(:champs).and_return([prix1_champ, prix2_champ, prix3_champ, formule_champ]) + expect(service.compute_value(formule_champ)).to eq('200') + end + end + end + + context 'with different field types' do + let!(:yes_no_champ) { Champs::YesNoChamp.new(dossier: dossier, value: 'true') } + let!(:checkbox_champ) { Champs::CheckboxChamp.new(dossier: dossier, value: 'on') } + let!(:date_champ) { Champs::DateChamp.new(dossier: dossier, value: '2024-01-01') } + + before do + allow(yes_no_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_yes_no, libelle: 'Eligible')) + allow(checkbox_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_checkbox, libelle: 'Accepte CGV')) + allow(date_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_date, libelle: 'Date debut')) + allow(dossier).to receive(:champs).and_return([yes_no_champ, checkbox_champ, date_champ]) + end + + it 'converts yes_no to numeric' do + formule_champ = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Eligible} * 100')) + expect(service.compute_value(formule_champ)).to eq('100') + end + + it 'converts checkbox to numeric' do + formule_champ = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Accepte CGV} * 50')) + expect(service.compute_value(formule_champ)).to eq('50') + end + end + + context 'with text fields containing numbers' do + let!(:text_champ) { Champs::TextChamp.new(dossier: dossier, value: 'Il y a 25 éléments') } + + before do + allow(text_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_text, libelle: 'Quantite')) + allow(dossier).to receive(:champs).and_return([text_champ]) + end + + it 'extracts numbers from text' do + formule_champ = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Quantite} * 2')) + expect(service.compute_value(formule_champ)).to eq('50') + end + end + + context 'with empty or missing fields' do + let!(:empty_champ) { Champs::IntegerNumberChamp.new(dossier: dossier, value: '') } + let(:formule_champ) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(empty_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_integer_number, libelle: 'Vide')) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Vide} + 10')) + allow(dossier).to receive(:champs).and_return([empty_champ, formule_champ]) + end + + it 'treats empty fields as zero' do + expect(service.compute_value(formule_champ)).to eq('10') + end + + it 'handles missing field references' do + formule_champ_missing = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ_missing).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Inexistant} + 5')) + expect(service.compute_value(formule_champ_missing)).to include("Erreur : champ 'Inexistant' introuvable") + end + end + + context 'with circular references' do + let!(:formule1) { Champs::FormuleChamp.new(dossier: dossier) } + let!(:formule2) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(formule1).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, libelle: 'Formule A', formule_expression: '{Formule B} + 1')) + allow(formule2).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, libelle: 'Formule B', formule_expression: '{Formule A} + 1')) + allow(dossier).to receive(:champs).and_return([formule1, formule2]) + end + + it 'detects circular references' do + expect(service.compute_value(formule1)).to include('référence circulaire') + end + end + + context 'with syntax errors' do + let(:formule_champ) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '2 + + 3')) + end + + it 'handles syntax errors gracefully' do + result = service.compute_value(formule_champ) + expect(result).to include('Erreur').or include('erreur').or be_empty + end + end + + context 'with complex nested formulas' do + let!(:base_champ) { Champs::IntegerNumberChamp.new(dossier: dossier, value: '100') } + let!(:formule_intermediate) { Champs::FormuleChamp.new(dossier: dossier) } + let(:formule_final) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(base_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_integer_number, libelle: 'Base')) + allow(formule_intermediate).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, libelle: 'Intermediate', formule_expression: '{Base} * 2')) + allow(formule_final).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Intermediate} + 50')) + allow(dossier).to receive(:champs).and_return([base_champ, formule_intermediate, formule_final]) + end + + it 'handles nested formula calculations' do + expect(service.compute_value(formule_final)).to eq('250') + end + end + + context 'with decimal results' do + let!(:dividend_champ) { Champs::IntegerNumberChamp.new(dossier: dossier, value: '100') } + let!(:divisor_champ) { Champs::IntegerNumberChamp.new(dossier: dossier, value: '3') } + let(:formule_champ) { Champs::FormuleChamp.new(dossier: dossier) } + + before do + allow(dividend_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_integer_number, libelle: 'Dividend')) + allow(divisor_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_integer_number, libelle: 'Divisor')) + allow(formule_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Dividend} / {Divisor}')) + allow(dossier).to receive(:champs).and_return([dividend_champ, divisor_champ, formule_champ]) + end + + it 'preserves true decimal results' do + result = service.compute_value(formule_champ) + expect(result).to start_with('33.333333') + expect(result).not_to end_with('.0') + end + + it 'converts whole number decimals to integers' do + allow(divisor_champ).to receive(:value).and_return('10') + formule_champ_whole = Champs::FormuleChamp.new(dossier: dossier) + allow(formule_champ_whole).to receive(:type_de_champ).and_return(build(:type_de_champ_formule, formule_expression: '{Dividend} / {Divisor}')) + expect(service.compute_value(formule_champ_whole)).to eq('10') + end + end + end +end diff --git a/spec/services/formula_expression_service_spec.rb b/spec/services/formula_expression_service_spec.rb new file mode 100644 index 00000000000..98eb61ce5b2 --- /dev/null +++ b/spec/services/formula_expression_service_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +describe FormulaExpressionService do + let(:procedure) { build(:procedure) } + let(:revision) { procedure.active_revision } + let(:montant_tdc) { build(:type_de_champ_integer_number, libelle: 'Montant HT', stable_id: 123) } + let(:tva_tdc) { build(:type_de_champ_decimal_number, libelle: 'TVA', stable_id: 456) } + + before do + allow(revision).to receive(:types_de_champ).and_return([montant_tdc, tva_tdc]) + end + + describe '.convert_to_stable_ids' do + context 'with field references' do + let(:expression) { '{Montant HT} * 1.2 + {TVA}' } + + it 'converts libelles to stable_ids' do + stable_expr, dependencies = FormulaExpressionService.convert_to_stable_ids(expression, revision) + + expect(stable_expr).to eq('{123} * 1.2 + {456}') + expect(dependencies).to match_array([123, 456]) + end + end + + context 'with unknown field reference' do + let(:expression) { '{Montant HT} + {Inexistant}' } + + it 'keeps unknown references as-is' do + stable_expr, dependencies = FormulaExpressionService.convert_to_stable_ids(expression, revision) + + expect(stable_expr).to eq('{123} + {Inexistant}') + expect(dependencies).to eq([123]) + end + end + + context 'with blank expression' do + let(:expression) { '' } + + it 'returns empty values' do + stable_expr, dependencies = FormulaExpressionService.convert_to_stable_ids(expression, revision) + + expect(stable_expr).to eq('') + expect(dependencies).to eq([]) + end + end + + context 'with case insensitive matching' do + let(:expression) { '{montant ht} + {TVA}' } + + it 'matches case insensitively' do + stable_expr, dependencies = FormulaExpressionService.convert_to_stable_ids(expression, revision) + + expect(stable_expr).to eq('{123} + {456}') + expect(dependencies).to match_array([123, 456]) + end + end + end + + describe '.convert_to_libelles' do + context 'with stable_id references' do + let(:stable_expression) { '{123} * 1.2 + {456}' } + + it 'converts stable_ids to libelles' do + result = FormulaExpressionService.convert_to_libelles(stable_expression, revision) + + expect(result).to eq('{Montant HT} * 1.2 + {TVA}') + end + end + + context 'with unknown stable_id' do + let(:stable_expression) { '{123} + {999}' } + + it 'keeps unknown stable_ids as-is' do + result = FormulaExpressionService.convert_to_libelles(stable_expression, revision) + + expect(result).to eq('{Montant HT} + {999}') + end + end + + context 'with blank expression' do + let(:stable_expression) { '' } + + it 'returns empty string' do + result = FormulaExpressionService.convert_to_libelles(stable_expression, revision) + + expect(result).to eq('') + end + end + end + + describe 'roundtrip conversion' do + let(:original_expression) { '{Montant HT} * 1.2 + {TVA} - 100' } + + it 'preserves the original expression through conversion cycle' do + stable_expr, _deps = FormulaExpressionService.convert_to_stable_ids(original_expression, revision) + result = FormulaExpressionService.convert_to_libelles(stable_expr, revision) + + expect(result).to eq(original_expression) + end + end +end