Aller au contenu

Module Ouvrages

Objectif

Le module Ouvrages permet de composer des elements reutilisables a partir de lignes de catalogue, de main d'oeuvre, de produits standards, de menuiserie ou d'autres ouvrages. Un ouvrage devient ensuite une ligne exploitable dans les devis, factures et avoirs.

Le point le plus important pour la reprise n'est pas seulement le CRUD : le module transporte un payload complet dans les documents commerciaux avec le prefixe __ouvrage_payload__:. Ce contrat evite qu'un ouvrage insere dans un devis perde sa composition quand il est transforme, facture ou rendu.

Carte technique

flowchart TD
    Router["app/core/Router.php"] --> Controller["OuvrageController"]
    Controller --> Model["OuvrageModel"]
    Controller --> Taxonomy["CatalogueTaxonomyModel"]
    Model --> Sql["app/bd/ouvrages.sql"]
    Model --> Tables["ouvrages / ouvrage_lines"]

    Selector["choisir-produit-pour-devis.php"] --> Document["Devis / Facture / Avoir"]
    Document --> BaseDocument["BaseDocumentModel"]
    BaseDocument --> Payload["__ouvrage_payload__"]
    Payload --> Pdf["InternalPennylanePdfController"]
    Payload --> Facture["FactureModel"]

Fichiers a connaitre

Role Fichiers
Routage app/core/Router.php
Controleur app/controllers/OuvrageController.php
Modele app/models/OuvrageModel.php
Taxonomies app/models/CatalogueTaxonomyModel.php, app/bd/catalogue-taxonomies.sql
Vue CRUD app/views/ouvrages.php
Selecteur document app/views/partials/modals/choisir-produit-pour-devis.php
Lignes devis app/views/partials/tables/devis-templates.php, app/models/DevisModel.php
Lignes facture/avoir app/models/FactureModel.php, app/views/edit_avoir.php
Persistance payload app/models/BaseDocumentModel.php
PDF interne Pennylane app/controllers/InternalPennylanePdfController.php
SQL app/bd/ouvrages.sql, app/bd/ouvrages-tpe-feature.sql

Routes

Toutes les routes passent par :

  • Auth::check() ;
  • requireFeatureAccess(FEATURE_EDITER_CATALOGUE, APP_DIR . '/dashboard') ;
  • requireFeatureAccess(FEATURE_GERER_OUVRAGE, APP_DIR . '/dashboard').
Route Methode Controleur Usage
ouvrages GET index() Page de gestion.
ouvrages/search GET search() Recherche paginee AJAX.
ouvrages/get GET get() Charge un ouvrage avec ses lignes.
ouvrages/save POST save() Cree ou modifie un ouvrage.
ouvrages/delete POST delete() Archive logiquement un ouvrage.

Droits et plans

OuvrageModel::checkFeatureAccess() refait les controles de features :

  • FEATURE_EDITER_CATALOGUE ;
  • FEATURE_GERER_OUVRAGE.

Si FEATURE_GERER_OUVRAGE manque, le message indique que les ouvrages sont reserves aux societes ayant au moins le plan TPE.

Permissions catalogue :

  • liste : PermissionMiddleware::require('catalogue', 'view_all') ;
  • creation : catalogue/create ;
  • edition : catalogue/edit ;
  • suppression : catalogue/delete_all.

Schema de donnees

ouvrages

Table principale des ouvrages.

Champs structurants :

  • societe_id ;
  • created_by ;
  • titre ;
  • description_devis ;
  • unite ;
  • taxonomy_family_id, taxonomy_category_id ;
  • cout_achat_ht ;
  • prix_vente_ht ;
  • marge_ht ;
  • marge_percent ;
  • active ;
  • timestamps.

Indexes utiles :

  • (societe_id, active) ;
  • created_by ;
  • (societe_id, taxonomy_family_id, taxonomy_category_id, active).

ouvrage_lines

Composition detaillee d'un ouvrage.

Champs structurants :

  • ouvrage_id ;
  • line_type : material ou labor ;
  • source_type : catalogue, main_oeuvre, standard, carpentry, ouvrage ;
  • source_id ;
  • label_snapshot ;
  • description_snapshot ;
  • quantity ;
  • unit ;
  • purchase_unit_ht ;
  • sale_unit_ht ;
  • tva ;
  • config_json ;
  • sort_order.

config_json permet de garder une configuration locale de composant sans dependre uniquement de la source catalogue.

Extensions documents

ouvrages.sql modifie les enums :

  • devis_lignes.source accepte ouvrage ;
  • factures_lignes.source accepte ouvrage.

Cette extension est indispensable : une ligne ouvrage peut ne pas avoir de produit_id catalogue direct.

Creation et edition

OuvrageController::save() appelle OuvrageModel::save($_POST).

Regles verifiees :

  • titre obligatoire ;
  • au moins une ligne obligatoire ;
  • unite par defaut : Pce ;
  • taxonomie normalisee via CatalogueTaxonomyModel::normalizePair('ouvrage', ...) ;
  • sauvegarde transactionnelle ;
  • en edition, suppression puis reinsertion de toutes les lignes ;
  • suppression logique : active = 0.

Normalisation des lignes :

  • si lines arrive comme chaine JSON, elle est decodee ;
  • les lignes sans libelle sont ignorees ;
  • line_type inconnu devient material ;
  • line_type = labor force source_type = main_oeuvre ;
  • sources autorisees : catalogue, main_oeuvre, standard, carpentry, ouvrage ;
  • quantite minimale : 0.01 ;
  • prix achat/vente minimum : 0 ;
  • TVA minimum : 0 ;
  • config_json invalide devient null.

Totaux calcules :

  • cout_achat_ht = somme(quantity * purchase_unit_ht) ;
  • prix_vente_ht = somme(quantity * sale_unit_ht) ;
  • marge_ht = vente - achat ;
  • marge_percent = marge / achat * 100 si achat > 0, sinon 0.

Lecture et recherche

getPaginatedOuvrages() :

  • filtre toujours sur societe_id = active_societe_id ;
  • filtre toujours active = 1 ;
  • limite itemsPerPage entre 1 et 100 ;
  • recherche sur titre et description_devis ;
  • filtre optionnel par famille/categorie ;
  • joint les taxonomies scope = ouvrage ;
  • joint le createur et compte les lignes material / labor.

getById() :

  • refuse les ouvrages d'une autre societe ;
  • charge les lignes triees par sort_order, puis id ;
  • decode config_json en config ;
  • hydrate certaines images depuis les sources catalogue ;
  • calcule une TVA representative avec resolveOuvrageTva() ;
  • construit document_description.

resolveOuvrageTva() prend le taux TVA qui represente le plus gros total de vente HT parmi les lignes. Si aucune ligne taxable n'a de montant, le taux renvoye est 0.

Description documentaire

buildDocumentDescription() concatene :

  1. description_devis ;
  2. Matériaux : ... avec les lignes material ;
  3. Main d’œuvre : ... avec les lignes labor.

Chaque ligne est rendue sous la forme :

<quantite> x <label_snapshot>

Cette description est le resume humain visible dans les documents. Le payload complet reste stocke separement via __ouvrage_payload__.

Contrat payload document

Le contrat est dans BaseDocumentModel::saveLinePayload().

Quand la source est ouvrage et que le payload contient catalogue_kind = ouvrage ou lines, le champ package_description du payload document contient :

__ouvrage_payload__:<json complet>

La lecture inverse est dans BaseDocumentModel::getLinePayloadFromTable() :

  • si source = ouvrage ;
  • et si package_description commence par __ouvrage_payload__:;
  • alors le JSON est decode ;
  • id et source = ouvrage sont rajoutes ;
  • le payload complet est retourne au lieu du payload menuiserie standard.

Ce choix evite de perdre :

  • les lignes internes de l'ouvrage ;
  • les quantites/prix snapshots ;
  • les taux TVA ;
  • les configurations locales ;
  • les informations necessaires pour rouvrir le configurateur.

Devis, factures et avoirs

Devis vers facture

DevisModel::buildProgressInvoiceLines() detecte une ligne ouvrage si :

  • source === 'ouvrage' ;
  • ou sim_payload.catalogue_kind === 'ouvrage' ;
  • ou sim_payload.source_tab === 'ouvrage'.

Une ligne sans produit_id n'est traitee comme separateur que si ce n'est pas une ligne ouvrage. Cette regle est critique : les ouvrages peuvent exister sans produit catalogue direct.

Facture

FactureModel accepte explicitement le cas :

$source === 'ouvrage' && empty($ligne['produit_id'])

BaseDocumentModel::hydrateFactureLinesFromDevis() recharge le payload devis si la ligne facture ouvrage n'a pas encore sim_payload.lines.

Avoir

app/views/edit_avoir.php considere une ligne document valide si elle a un produit_id ou si source === 'ouvrage'.

PDF interne Pennylane

InternalPennylanePdfController::normalizePayload() reconnait aussi __ouvrage_payload__: pour reconstruire le payload ouvrage avant rendu.

Selecteur et configurateur

app/views/partials/modals/choisir-produit-pour-devis.php integre l'onglet ouvrage dans le selecteur de produits. Le JS partage les tabs :

  • catalogue ;
  • main_oeuvre ;
  • ouvrage ;
  • standard ;
  • carpentry.

app/views/partials/tables/devis-templates.php contient la regle de reouverture :

source === 'ouvrage' && uniProductModal.openOuvrageConfiguratorFromLine(row)

Donc modifier une ligne ouvrage dans un devis doit rouvrir le configurateur avec le payload sauvegarde, pas avec une reconstruction approximative depuis le catalogue.

Taxonomies

Les ouvrages utilisent les taxonomies catalogue avec scope = ouvrage.

Tables impliquees :

  • catalogue_taxonomy_families ;
  • catalogue_taxonomy_categories.

app/bd/catalogue-taxonomies.sql ajoute aussi l'index idx_ouvrages_taxonomy sur ouvrages.

Particularite schema runtime

OuvrageModel::__construct() appelle ensureSchema(), qui lit app/bd/ouvrages.sql et execute les instructions separees par ;.

Impact :

  • la premiere instanciation du modele peut tenter de creer/alterer des tables ;
  • l'utilisateur SQL applicatif doit avoir les droits necessaires ;
  • ce comportement peut masquer une migration non appliquee en local, mais echouer en production si les droits sont plus stricts ;
  • toute modification du SQL doit rester idempotente.

Tests existants

Tests presents dans le depot inspecte :

  • tests/ouvrage_configurator_inline_edit_test.php : verifie que le configurateur permet l'edition locale des composants ;
  • tests/ouvrage_document_payload_persistence_test.php : verifie le contrat __ouvrage_payload__, la restauration du payload et la conservation dans devis/factures ;
  • tests/ouvrages_selector_tva_dependency_test.php : verifie que la vue charge TVA_RATES avant le selecteur partage.

Commandes utiles :

php tests/ouvrage_configurator_inline_edit_test.php
php tests/ouvrage_document_payload_persistence_test.php
php tests/ouvrages_selector_tva_dependency_test.php

Points de vigilance

  1. Ne pas remplacer __ouvrage_payload__: par une colonne arbitraire sans migrer la lecture existante des devis, factures, avoirs et PDF.
  2. Ne pas supposer qu'une ligne ouvrage a un produit_id. Plusieurs chemins verifient explicitement ce cas.
  3. Le modele supprime puis reinsere toutes les lignes a l'edition. Si une future fonctionnalite reference ouvrage_lines.id, elle sera fragile.
  4. Le taux TVA affiche pour l'ouvrage est representatif, pas une preuve que toutes les lignes ont le meme taux.
  5. Les prix sont des snapshots. Modifier un produit source apres insertion ne doit pas modifier automatiquement les documents existants.
  6. ensureSchema() execute du SQL depuis le modele. En environnement verrouille, preferer une migration controlee avant de charger la page.
  7. Les ouvrages peuvent contenir d'autres ouvrages (source_type = ouvrage). Il faut eviter les boucles fonctionnelles si une future UI autorise la selection recursive.

Verification conseillee

  1. Creer un ouvrage avec au moins une ligne materiau et une ligne main d'oeuvre.
  2. Verifier ouvrages : totaux achat, vente, marge.
  3. Verifier ouvrage_lines : line_type, source_type, tva, config_json.
  4. Inserer l'ouvrage dans un devis depuis le selecteur.
  5. Verifier que devis_ligne_payload.package_description contient __ouvrage_payload__:.
  6. Modifier la ligne dans le devis : le configurateur doit se rouvrir avec les composants.
  7. Transformer le devis en facture et verifier que sim_payload.lines est encore present.
  8. Generer le PDF interne et verifier que la ligne ouvrage est rendue avec ses composants attendus.
  9. Lancer les trois tests PHP listes ci-dessus.

Questions ouvertes

  • Aucun test PDF dedie aux composants ouvrage n'a ete trouve dans les fichiers inspectes.
  • Le modele accepte source_type = ouvrage, mais la strategie produit pour empecher les compositions recursives n'est pas explicite dans le code inspecte.
  • Le SQL est execute au runtime par le modele ; a moyen terme, le comportement serait plus previsible si toutes les migrations etaient appliquees hors requete utilisateur.