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.
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.