La maison Poujauran est une boulangerie artisanale du 7e arrondissement de Paris. Ils fournissent des restaurants, des hôtels et des traiteurs en plus de la vente en boutique. Le volume de commandes professionnelles est conséquent : plusieurs dizaines par jour, avec des récurrences hebdomadaires, des ajustements de dernière minute et des livraisons à coordonner.

Et tout ça est géré par un programme qui tourne sous DOS. Littéralement. Un exécutable compilé en Clipper (un langage de la fin des années 80 basé sur dBASE), qui affiche des menus en texte blanc sur fond bleu, stocke les données dans des fichiers .dbf, et s’exécute dans une fenêtre de compatibilité sur un PC Windows dont personne n’ose toucher le BIOS. Le programme fonctionne. Il fonctionne depuis 1994. Mais la personne qui l’a écrit est partie depuis longtemps, les fichiers .dbf se corrompent de temps en temps, et il n’y a pas de sauvegarde automatisée.

Ils nous ont contactés pour le remplacer par quelque chose de moderne. Pas une usine à gaz, un outil simple qui fait ce que l’outil DOS fait, mais sur le web, avec plusieurs utilisateurs, et des sauvegardes.

Comprendre ce que fait l’outil avant de le remplacer

La première semaine a été de l’observation. On s’est assis à côté de la personne qui utilise l’outil DOS au quotidien, et on a noté tout ce qu’elle faisait. C’est la partie du projet qu’on sous-estime toujours, et qui conditionne tout le reste.

On a identifié les gestes répétés, les raccourcis clavier qu’elle connaît par cœur, les cas où elle contourne le programme (un post-it collé sur l’écran avec les prix qui ont changé mais qu’on n’a pas mis à jour dans le logiciel). Ce genre de détail ne s’invente pas. Il faut le voir sur place.

L’outil gère quatre choses :

Les clients professionnels : nom, adresse de livraison, contacts, conditions de paiement, historique. Chaque client a ses habitudes (commande standard du lundi, du vendredi, etc.).

Les produits : le catalogue de la boulangerie avec les prix professionnels. Des pains, des viennoiseries, de la pâtisserie. Chaque produit a un prix unitaire qui peut varier selon le client (remises négociées).

Les commandes : la prise de commande quotidienne. Le plus souvent par téléphone, parfois par fax (oui, en 2019). La commande référence un client, une date de livraison, et une liste de produits avec des quantités. Beaucoup de commandes sont des duplications de la commande de la veille avec des ajustements.

Les bons de livraison : imprimés chaque matin, ils récapitulent ce qui doit partir et où. C’est le document que le livreur emporte.

Rien de spectaculaire. Mais le diable est dans les détails : les commandes récurrentes qu’il faut pouvoir dupliquer en un clic, les prix qui changent selon le client, les modifications de dernière minute à 5h du matin, l’impression des bons qui doit être rapide parce qu’on n’attend pas.

modèle de données — les 4 entités métierClientnom · adresse · contactconditions paiementhas_many :commandesProduitnom · prix de baseprix client via joinCommandedate de livraisonstatut (saisie · validée · livrée)belongs_to :clienthas_many :lignesLigneCommandeproduit · quantité · prix unitairebelongs_to :commande, :produitBonLivraisondate · adressePDF imprimablefrom :commande1..n1..ngénère

Pourquoi Rails 5.2

On aurait pu faire ça avec n’importe quoi. Un Google Sheets partagé aurait probablement couvert 70% du besoin. Mais le client veut un outil à lui, qui tourne chez lui, avec ses données chez lui. Et il veut que ça dure aussi longtemps que le programme DOS qu’on remplace, c’est-à-dire potentiellement vingt ans.

Rails 5.2 (sorti en avril dernier) est un bon choix pour un back-office CRUD de cette taille. Le framework fait le gros du travail : formulaires, validations, routes RESTful, sessions, authentification. On ne va pas passer trois mois à réinventer une gestion de sessions quand has_secure_password fait le boulot en cinq minutes.

# Gemfile — le strict minimum
gem "rails", "~> 5.2.2"
gem "sqlite3"        # suffisant pour le volume
gem "puma", "~> 3.11"
gem "sass-rails", "~> 5.0"
gem "turbolinks", "~> 5"
gem "coffee-rails", "~> 4.2"
gem "bootsnap", ">= 1.1.0", require: false

On part sur SQLite. Oui, SQLite. Pour un back-office avec un ou deux utilisateurs simultanés et quelques dizaines de commandes par jour, SQLite est largement suffisant. Pas de serveur de base de données à maintenir, la base est un fichier unique qu’on peut sauvegarder par simple copie. Si le volume augmente un jour, migrer vers PostgreSQL est une affaire de quelques heures avec Rails.

Le choix de SQLite n’est pas de la paresse. Un PostgreSQL sur ce volume, c’est un processus daemon de plus à surveiller, des mises à jour de sécurité à appliquer, un pg_dump à configurer. Sur un Mac Mini sous le comptoir d’une boulangerie, chaque dépendance en moins, c’est une panne en moins à diagnostiquer par téléphone un samedi matin.

Les modèles : le cœur du métier

La traduction du modèle métier en code Rails est directe. On nomme les modèles en français — le client ne lira pas le code, mais l’équipe qui reprendra la maintenance dans cinq ans comprendra le domaine immédiatement.

class Client < ApplicationRecord
  has_many :commandes, dependent: :destroy
  has_many :prix_clients, dependent: :destroy
  has_many :produits, through: :prix_clients

  validates :nom, presence: true
  validates :adresse_livraison, presence: true

  scope :actifs, -> { where(actif: true) }
  scope :alphabetique, -> { order(:nom) }

  def prix_pour(produit)
    prix_clients.find_by(produit: produit)&.prix || produit.prix
  end
end

La méthode prix_pour encapsule toute la logique de tarification. Chaque client peut avoir un prix négocié pour un produit donné via la table PrixClient. Si aucun prix spécifique n’existe, on retombe sur le prix catalogue. Le programme Clipper faisait la même chose en quarante lignes avec des IF imbriqués.

class Commande < ApplicationRecord
  belongs_to :client
  has_many :ligne_commandes, dependent: :destroy
  has_many :produits, through: :ligne_commandes

  accepts_nested_attributes_for :ligne_commandes,
    allow_destroy: true,
    reject_if: :all_blank

  validates :date_livraison, presence: true
  validates :client, presence: true

  enum statut: { saisie: 0, validee: 1, livree: 2 }

  scope :du_jour, ->(date = Date.current) {
    where(date_livraison: date)
  }
  scope :a_livrer, -> { validee.du_jour }

  def total
    ligne_commandes.sum { |l| l.quantite * l.prix_unitaire }
  end

  def dupliquer_pour(nouvelle_date)
    dup.tap do |c|
      c.date_livraison = nouvelle_date
      c.statut = :saisie
      ligne_commandes.each do |ligne|
        c.ligne_commandes.build(
          produit: ligne.produit,
          quantite: ligne.quantite,
          prix_unitaire: ligne.prix_unitaire
        )
      end
    end
  end
end

Le dupliquer_pour est la fonctionnalité la plus utilisée. Chaque matin, l’opératrice prend la commande de la veille d’un client et la duplique pour aujourd’hui. L’outil DOS faisait ça avec un raccourci clavier F5. Dans le back-office web, c’est un bouton “Dupliquer” qui appelle cette méthode, pré-remplit le formulaire avec les mêmes lignes, et laisse l’utilisatrice ajuster les quantités avant de valider.

Le accepts_nested_attributes_for est ce qui permet de gérer la commande et ses lignes dans un seul formulaire. Rails gère la création, la modification et la suppression des lignes via des champs _destroy dans le formulaire. Pas besoin de JavaScript complexe pour ajouter ou retirer des lignes.

class LigneCommande < ApplicationRecord
  belongs_to :commande
  belongs_to :produit

  validates :quantite, presence: true,
    numericality: { greater_than: 0 }
  validates :prix_unitaire, presence: true,
    numericality: { greater_than_or_equal_to: 0 }

  before_validation :fixer_prix_unitaire, on: :create

  private

  def fixer_prix_unitaire
    return if prix_unitaire.present?
    self.prix_unitaire = commande&.client&.prix_pour(produit)
  end
end

Le callback fixer_prix_unitaire fige le prix au moment de la création de la ligne. Si le tarif catalogue ou le prix client change ensuite, les commandes déjà passées ne bougent pas. C’est le comportement qu’on attend pour de la facturation : le prix d’une commande, c’est celui du jour où on l’a passée.

La table de prix intermédiaire

Le prix par client est le mécanisme qui a demandé le plus de réflexion. Dans le programme DOS, les prix étaient codés en dur dans les fiches client, dupliqués partout. Ici, on passe par une table de jointure :

class PrixClient < ApplicationRecord
  belongs_to :client
  belongs_to :produit

  validates :prix, presence: true,
    numericality: { greater_than: 0 }
  validates :produit, uniqueness: { scope: :client_id }
end

Avec la migration correspondante :

create_table :prix_clients do |t|
  t.references :client, foreign_key: true, null: false
  t.references :produit, foreign_key: true, null: false
  t.decimal :prix, precision: 8, scale: 2, null: false
  t.timestamps
end

add_index :prix_clients, [:client_id, :produit_id], unique: true

L’index unique empêche d’avoir deux prix pour le même produit chez le même client. On pose cette contrainte au niveau de la base, pas seulement dans le modèle Ruby. Les validations ActiveRecord, c’est bien. Mais si un jour quelqu’un écrit un script qui insère directement en base, l’index unique est là pour rattraper le coup.

La migration des données : les fichiers .dbf

Le plus gros risque du projet, c’est la migration des données existantes. Les fichiers .dbf (dBASE) contiennent l’historique de 25 ans de commandes. Le client y tient. C’est aussi son historique de facturation.

Le format dBASE est vieux mais bien documenté. C’est un format binaire avec un header fixe qui décrit les colonnes (nom, type, taille) et des enregistrements séquentiels. La gem dbf lit les fichiers sans problème. On écrit un script Rake pour la migration :

# lib/tasks/import_dbf.rake
namespace :import do
  desc "Importer les clients depuis les fichiers dBASE"
  task clients: :environment do
    table = DBF::Table.new("data/CLIENTS.DBF")
    table.each do |record|
      next if record.nil? # enregistrement supprimé
      Client.find_or_create_by!(
        nom: encode_dos(record.nom),
        adresse_livraison: encode_dos(record.adresse),
        telephone: record.tel,
        conditions_paiement: encode_dos(record.cond_pmt),
        actif: record.actif != "N"
      )
    end
  end
end

def encode_dos(str)
  return nil if str.blank?
  str.encode("UTF-8", "CP850",
    invalid: :replace,
    undef: :replace,
    replace: "?")
    .strip
end

encode_dos est le genre de truc qu’on ne voit pas dans les specs mais qui casse tout si on l’oublie. Les fichiers .dbf encodent le texte en CP850 (la page de code DOS pour l’Europe occidentale). Les accents, les cédilles, les ligatures, tout ça vit dans des octets qui n’ont pas la même signification en UTF-8. Sans cette conversion, “Pâtisserie” devient “P├ótisserie” et les recherches ne fonctionnent plus.

On a le même script pour les produits, les commandes et les lignes de commande. Chaque script a un mode dry-run qui affiche ce qui serait importé sans rien écrire en base. Le client valide les données sur papier avant qu’on lance l’import définitif.

Les routes : REST et rien d’autre

Le routage est minimal. Quatre ressources, un controller de sessions, un scope pour l’impression :

Rails.application.routes.draw do
  resources :clients do
    resources :commandes, shallow: true
  end
  resources :produits
  resources :commandes, only: [] do
    member do
      post :dupliquer
      get  :bon_livraison
    end
  end

  get "livraisons", to: "livraisons#index"
  get "login",  to: "sessions#new"
  post "login", to: "sessions#create"
  delete "logout", to: "sessions#destroy"

  root to: "commandes#index"
end

Le shallow: true sur les commandes imbriquées dans les clients est une convention Rails qui simplifie les URL. La création d’une commande passe par /clients/42/commandes/new (on sait pour quel client), mais l’édition passe par /commandes/17 (on n’a plus besoin du client dans l’URL, il est dans la commande). C’est plus propre, et ça évite les URL à rallonge.

La racine de l’application pointe sur la liste des commandes du jour. C’est la première chose qu’on voit en ouvrant le navigateur le matin.

Ce qui change pour l’utilisateur

L’outil DOS avait un avantage : il était rapide. Pas d’animation, pas de chargement de page, tu tapes un code produit et la quantité apparaît. Reproduire cette fluidité dans un navigateur web est un vrai défi.

Turbolinks aide. Les transitions de page sont quasi-instantanées parce que Turbolinks ne recharge que le body HTML, pas les assets. Pour la saisie de commande, on ajoute du JavaScript côté client pour que l’ajout de lignes ne nécessite pas un aller-retour serveur à chaque produit. Un formulaire dynamique avec des champs qui se dupliquent, du accepts_nested_attributes_for côté Rails.

L’impression des bons de livraison, c’est l’autre point critique. À 5h du matin, il faut que ça sorte sur l’imprimante en deux clics. On utilise le CSS @media print pour avoir un rendu papier propre directement depuis le navigateur, sans passer par une génération PDF :

@media print {
  nav, .actions, .no-print { display: none; }
  body { font-size: 11pt; }
  .bon-livraison {
    page-break-after: always;
    border: none;
  }
  .bon-livraison table {
    width: 100%;
    border-collapse: collapse;
  }
  .bon-livraison td, .bon-livraison th {
    border-bottom: 0.5pt solid #ccc;
    padding: 2pt 4pt;
  }
}

C’est plus simple qu’un PDF généré côté serveur, et ça marche avec n’importe quelle imprimante sans configuration. Le navigateur fait le rendu, l’imprimante imprime. Pas de gem wkhtmltopdf à installer, pas de dépendance binaire à maintenir sur le Mac Mini.

La page /livraisons agrège toutes les commandes validées du jour, regroupées par tournée de livraison. Un seul Ctrl+P imprime les bons pour toute la matinée.

L’authentification : le strict minimum

Deux utilisateurs. Pas de création de compte en libre-service, pas de mot de passe oublié par email, pas d’OAuth. Un has_secure_password et une table users avec un nom et un mot de passe hashé :

class User < ApplicationRecord
  has_secure_password
  validates :nom, presence: true, uniqueness: true
end

Le controller de sessions tient en trente lignes. Le before_action :authenticate dans ApplicationController protège toutes les routes. Les comptes sont créés en console Rails — User.create!(nom: "Marie", password: "..."). C’est volontairement fruste. Devise, pour deux utilisateurs qui ne changeront pas leur mot de passe en cinq ans, c’est apporter un canon pour écraser une mouche.

La structure du projet

Le module PoujauranRails est un Rails 5.2 standard. Pas de gems exotiques, pas de service objects, pas de patterns qui impressionnent en entretien technique. La complexité est dans le domaine métier, pas dans l’architecture :

app/
├── models/
│   ├── client.rb
│   ├── produit.rb
│   ├── commande.rb
│   ├── ligne_commande.rb
│   ├── prix_client.rb
│   └── user.rb
├── controllers/
│   ├── clients_controller.rb
│   ├── produits_controller.rb
│   ├── commandes_controller.rb
│   ├── livraisons_controller.rb
│   └── sessions_controller.rb
├── views/
│   ├── clients/
│   ├── produits/
│   ├── commandes/
│   ├── livraisons/
│   └── layouts/
└── assets/
    └── stylesheets/
        └── print.scss
lib/
└── tasks/
    └── import_dbf.rake

Rails impose cette structure. Chaque entité a son modèle, son controller, ses vues. On ne va pas réinventer l’architecture pour un CRUD de cinq tables. La convention fait le boulot.

Le déploiement : pas de Heroku, pas de cloud

Le client veut que tout tourne sur sa machine, dans sa boulangerie. Un Mac Mini sous le comptoir, Puma en mode production, Nginx en reverse proxy, et un cron qui copie le fichier SQLite sur un disque externe chaque nuit :

# /etc/cron.d/poujauran-backup
0 2 * * * cp /opt/poujauran/db/production.sqlite3 \
  /Volumes/Backup/poujauran-$(date +\%Y\%m\%d).sqlite3

Pas de Docker, pas de Kubernetes, pas de CI/CD. C’est un git pull && bundle install && rails db:migrate && systemctl restart puma quand on déploie une mise à jour. La mise en production a pris une demi-journée. Installer Ruby, cloner le dépôt, configurer Puma et Nginx, tester l’impression, former l’utilisatrice.

C’est volontairement simple. Le client ne veut pas dépendre d’un service cloud pour accéder à son outil de commandes. Il ne veut pas non plus payer un hébergement mensuel. Une machine physique avec une copie de sauvegarde, c’est un modèle qu’il comprend et qu’il peut maintenir sans nous.

On a l’habitude de déployer sur Heroku ou AWS. Revenir à du bare metal, c’est rafraîchissant. Et ça rappelle que la majorité des petites entreprises n’ont pas besoin de plus.