étude de cas · 2026

Application métier / Traçabilité
Ortho Control

Ortho Control

01

le défi

Le laboratoire d'orthodontie pilotait sa production sur Monday.com. Comme tableau, l'outil faisait le travail, mais il traînait trois problèmes qu'aucun réglage ne corrige : il se paie par utilisateur et par mois, indéfiniment ; les données — liées à des patients — vivent sur un serveur tiers ; et rien du métier n'y existe. Lots, péremptions, scan des codes médicaux, consommation de matière par travail : soit on les bricole par-dessus avec des automatisations fragiles, soit ils ne sont pas là. En parallèle courait une obligation légale. Un laboratoire qui fabrique des dispositifs médicaux sur mesure doit, sous le règlement européen MDR, pouvoir relier chaque appareil livré au lot de matériau qui a servi à le faire : si un lot de résine sort défectueux, il faut savoir chez quels patients il a été posé. Cela se tenait dans un tableur et dans la mémoire de ceux qui fabriquent. La commande avait donc deux moitiés : remplacer l'abonnement par un outil à soi, et faire que cet outil règle ce que l'abonnement ne réglait pas.

02

la solution

Une application de bureau conçue, développée et déployée de zéro, installée sur les postes du cabinet. Le parti pris d'interface : copier délibérément l'ergonomie de Monday — tableaux, colonnes de statut à étiquettes de couleur, édition en ligne — pour que personne n'ait à réapprendre à travailler. Changer d'outil ne devait pas se sentir comme changer d'outil. Dessous, en revanche, tout ce qui manquait : lots et péremptions, scan GS1 DataMatrix qui apporte code, lot et date de péremption en un seul tir de lecteur, décompte automatique du stock, étapes de fabrication horodatées, et la chaîne lot → consommation → cas fermée sans travail manuel supplémentaire. La décision structurelle : le cœur de l'application ne parle jamais à une base de données, il parle à une interface unique, avec deux implémentations interchangeables — SQLite en local, PostgreSQL en partagé. L'application a donc pu démarrer sur un seul poste et passer des mois plus tard à une base commune sans être réécrite. Le passage à l'échelle est écrit, pas improvisé : PostgreSQL en Docker sur le NAS du cabinet et volontairement pas sur le serveur de domaine, postes pointant sur un nom DNS et jamais sur une IP, installation par utilisateur sans droits administrateur, reprise de l'historique en une seule transaction vérifiée avec retour arrière, et un manuel de mise en service en français — hébergement et solutions de repli, réseau et pare-feu, sauvegardes et test de restauration, symptômes fréquents.

03

le résultat

En production, six postes, base partagée et mise à jour en temps réel entre eux. L'abonnement est supprimé et le cabinet est propriétaire de l'outil qu'il utilise ; les données patients sont restées chez lui ; l'exigence réglementaire est close — d'un lot défectueux on remonte aux patients, et d'un patient à ses lots. À ce jour : 293 cas tracés, 1 738 étapes de production horodatées, 8 tables et 20 807 lignes de TypeScript. Ce que j'en retiens tient dans trois pannes. Un stock descendu à −6 parce que deux postes lisaient avant d'écrire, corrigé par verrou de ligne et vérifié avec deux instances contre un vrai PostgreSQL. Une coupure réseau qui fermait l'application entière au lieu de la dégrader, devenue une reconnexion à attente croissante qui se resynchronise. Et des prévisions de rupture fantaisistes — dix-neuf ans d'autonomie sur une plaque — nées d'une même formule copiée trois fois et divergée. Depuis, quand la base de calcul est trop mince, les panneaux refusent de répondre et disent pourquoi : un chiffre inventé sur lequel quelqu'un va commander de la matière est pire qu'un tiret.

le mandat

quitter monday sans que personne ne s'en aperçoive

avant · Monday.com

après · l'application

Colonne par colonne, c'est le même tableau — et cette ressemblance est voulue : changer d'outil ne devait pas se sentir comme changer d'outil. Ce qui change ne se voit pas ici : sous le tableau de droite il y a les lots, les péremptions, la consommation par travail et le scan des codes médicaux. Noms de patients pixelisés ; la capture de Monday garde sa structure réelle.

ce que ça change

coût récurrent

Supprimé. Plus de licence par utilisateur ni de renouvellement annuel : le cabinet est propriétaire du logiciel qu'il utilise.

les données

Chez le client. La base vit sur son propre NAS, pas sur le serveur d'un tiers. Avec des informations liées à des patients, ce n'est plus un détail.

fonctionnalité

Sur mesure. Scan GS1, traçabilité lot → cas, étapes de fabrication horodatées, alertes d'usure. Rien de tout cela ne rentrait dans l'outil générique.

dimension

le projet en chiffres

20 807

lignes TypeScript / TSX dans src/

2 334

lignes de scripts : AutoHotkey, PowerShell, Node

98

commits en 3 mois

8

tables, avec 2 backends interchangeables

293

cas de laboratoire tracés

1 738

étapes de production horodatées

stack

ce qu'il y a dessous

Runtime / UI

Electron 39 · React 19 · TypeScript 5.9 en strict · electron-vite 5 avec HMR · Vibe, le design system de Monday (@vibe/core 4)

Données

SQLite (better-sqlite3 12) · PostgreSQL (pg 8) · temps réel par LISTEN/NOTIFY · migrations maison idempotentes · visualisations en SVG écrites à la main

Intégrations

Codes GS1 DataMatrix, parser maison · lecteur 2D en mode HID (Inateck BCST-52) · lecture de .xlsx sans dépendance · étiquettes Brother QL-810W · automatisation du bureau en AutoHotkey

Build / déploiement

electron-builder (NSIS, Windows) · ESLint et Prettier en flat config · installateur par utilisateur, sans administrateur · icône et AppUserModelID via rcedit et PowerShell · migration SQLite → PostgreSQL par script transactionnel

architecture

une couche de données enfichable

La décision structurelle du projet : l'IPC ne parle jamais à une base de données, il parle à une interface DataStore. Deux implémentations respectent le même contrat, et passer de l'une à l'autre ne demande pas de recompiler — cela se choisit par variable d'environnement ou par un fichier de configuration. C'est ce qui a permis de démarrer en local, sur un seul poste, et de passer des mois plus tard à une base partagée sans réécrire l'application.

Renderer

React 19 · un composant par tableau

Tables éditables en ligne, panneaux de statistiques, primitives SVG maison. Aucun accès aux données.

↕ window.api — contextBridge, sans nodeIntegration

Main

Processus Node · IPC, impression, surveillance de dossier

Mappage fin au-dessus du store ; portée des réglages (communs ou par poste) ; diffusion des changements aux fenêtres.

↕ DataStore — une seule interface, un seul contrat

Persistance

SqliteStore

En local, par défaut. Fichier dans le profil de l'utilisateur, WAL, migrations additives.

PgStore

PostgreSQL central sur le NAS du laboratoire. Chargé par import dynamique, transactions avec verrou de ligne et notifications en temps réel.

La logique partagée par les deux côtés — comme la règle de décompte automatique du stock — vit dans src/shared/ avec ses propres types structurels, pour ne dépendre ni des types du renderer ni de ceux du store.

déploiement

d'un poste à toute la clinique

L'application a démarré sur un seul ordinateur, en base locale. Dès qu'elle est devenue l'outil de travail du laboratoire, le mandat a grandi : la mettre sur tous les postes du cabinet, contre une base unique, avec mise à jour en temps réel entre eux.

C'est la part qui sépare un projet personnel d'un système que d'autres utilisent, et celle qui a obligé à écrire le plus de décisions avant de toucher à quoi que ce soit — sur une infrastructure déjà en production pour autre chose.

  1. 01

    PostgreSQL en Docker, sur le NAS du cabinet

    Et délibérément pas sur le serveur principal, qui est à la fois contrôleur de domaine et serveur de fichiers de tout le cabinet. Si la base tombe, l'application s'arrête — pas les sessions, ni les partages, ni le logiciel patients. Le NAS est déjà en RAID, allumé 24 h et dans le plan de sauvegarde.

  2. 02

    Les postes visent un nom DNS, jamais une IP

    Pour pouvoir déplacer la base de machine le jour où il le faudra, sans repasser sur les ordinateurs un par un.

  3. 03

    Installation sans droits d'administrateur

    L'installateur est par utilisateur et la configuration de chaque poste est un fichier dans son propre profil : monter un nouvel ordinateur ne demande ni identifiants de domaine ni registre. Passer de la base locale à la base partagée — ou revenir en arrière — c'est éditer ce fichier, sans recompiler. L'option par stratégie de domaine existe dans le code, mais elle a été écartée : elle n'apportait rien ici et exigeait un administrateur.

  4. 04

    L'historique migré par script transactionnel

    Il conserve les identifiants d'origine — lots, consommations et événements se référencent entre eux — et repositionne les séquences à la fin ; il convertit les dates en fuseau explicite, sans quoi tout l'historique se décalait d'une heure et la production changeait de case. Le tout dans une seule transaction, avec les comptages vérifiés à l'intérieur : si ça ne tombe pas juste, rollback. Il a un mode à blanc.

  5. 05

    Ce qui appartient à tous et ce qui est à chaque poste

    En passant en base partagée, les réglages d'interface ont cessé d'être « les miens ». Les étiquettes de statut et leurs couleurs sont partagées — tout le monde doit voir la même chose ; les largeurs de colonnes non, parce qu'elles dépendent de l'écran qu'on a devant soi et que les partager déplacerait le tableau des cinq autres au moindre glissement. Le partage se résout côté serveur et l'interface n'en sait rien.

  6. 06

    La mise en service est écrite, pas improvisée

    Un manuel en français, avec son PDF : décision d'hébergement et solutions de repli si le NAS ne convient pas, vérifications préalables, réseau et pare-feu, sauvegardes et test de restauration, symptômes fréquents et checklist finale. Il dit aussi ce qu'on ne touche pas — ajouter une règle au pare-feu, jamais l'activer depuis zéro avec une seule règle, ce qui couperait le reste des services du cabinet. Un logiciel sur mesure sans manuel d'exploitation n'est pas une économie, c'est une dépendance déguisée.

fonctionnalité

trois surfaces de travail

inventaire

« Inventaire »
  • Entrée par scan GS1 DataMatrix : un seul tir du lecteur apporte GTIN, lot et péremption, et le parser les sépare. Si le GTIN est déjà au catalogue, l'application le reconnaît et évite le doublon.
  • Tout se saisit en emballages, jamais en unités. « J'ai reçu 12 flacons », et l'application multiplie par le contenu. C'est la règle qui a évité le plus d'erreurs de stock.
  • Journal par article : chaque entrée, sortie et correction laisse une ligne datée, liée à son lot. La traçabilité du produit se lit d'un trait.
  • Panneau de statistiques en trois vues : l'essentiel (ce qu'il faut commander), la vue d'ensemble et la fiche par article — valeur du stock, rotation, péremptions et autonomie.
Le stock se lit dans les deux unités à la fois — « 123.3 sachets / 1225 u », « 3.2 boîtes / 1520 u ». C'est la règle qui a empêché que « 12 flacons » rentrent de nouveau comme 144 ml.

suivi de production

« Suivi Labo »
  • Table façon Monday : édition en ligne, colonnes redimensionnables et renommables, colonnes de statut avec étiquettes et couleurs créées par l'utilisateur, regroupement automatique par statut.
  • Étapes de fabrication horodatées : chaque changement d'étape écrit un événement daté, ce qui transforme une étiquette de couleur en un historique mesurable (durées, goulots, charge par semaine).
  • Une tâche terminée se verrouille. La traçabilité par lot ne tient que si une tâche vaut un travail livré ; l'avertissement, en plus d'interdire, ouvre le passage suivant du patient.
  • Alerte active d'usure de la fraise de coupe, déduite d'un journal d'événements plutôt que d'un compteur stocké — pour que six postes qui ajoutent en même temps ne se marchent pas dessus.
Le sous-processus de fabrication d'un travail terminé : les six étapes datées à la minute, 11,5 jours au total, l'étape la plus lente signalée en orange et la comparaison avec les 124 autres tâches du même traitement. C'est de là que sort la médiane de production.

pont d'entrée des rendez-vous

« Ortholeader »
  • Le logiciel patients du laboratoire est fermé et sans API. La voie choisie est légitime par construction : on utilise sa propre fonction d'export de la table vers Excel. Pas de lecture de sa base, pas d'interception réseau, pas de scraping d'écran.
  • La seule donnée qui traverse les deux systèmes est le numéro d'ordre, la clé commune. Couplage minimal et stable.
  • Captation automatique par AutoHotkey : export toutes les deux heures en plage de travail, seulement si le poste est inactif depuis 90 secondes, et la souris comme la fenêtre active sont remises où elles étaient.
  • Rien ne passe en production sans approbation humaine, et le pont se nettoie tout seul des rendez-vous déjà couverts par le suivi — sa fonction est de vérifier qu'aucun ne reste sur le carreau.
Deux colonnes de texte qui ne se mélangent jamais : ce que le cabinet écrit en exportant le rendez-vous, et ce que note le laboratoire. Seule la seconde passe dans la tâche. Chaque ligne s'envoie ou s'annule à la main.
La thèse du projet, dans un tableau. Le matériau sorti du stock pour ce travail précis, lot par lot, avec son numéro de lot réel et sa date. C'est l'exigence réglementaire réglée : d'un lot défectueux on remonte aux patients, et d'un patient à ses lots.

ingénierie

cinq problèmes réels et comment ils se sont fermés

Ils ont été détectés contre les données et le matériel réels du laboratoire, pas dans un environnement de test : certains en usage quotidien, d'autres reproduits avec deux postes travaillant en même temps sur la base partagée. C'est la partie du projet dont j'ai le plus appris, alors je la documente avec le symptôme mesuré sous les yeux.

un lot à stock négatif

concurrence
symptôme
Avec deux postes en même temps, deux sorties de 8 unités sur un lot de 10 le laissaient à −6.
cause
Les deux postes ont lu 10, les deux ont conclu qu'il y en avait assez, et comme l'UPDATE était relatif les deux soustractions se sont appliquées. Avec un seul PC cela n'existe pas ; avec six, chaque « lire puis écrire » est une course.
solution
Toute opération de lecture-écriture passe en transaction avec SELECT … FOR UPDATE. Vérifié avec deux instances simultanées contre un vrai PostgreSQL.

une connexion oisive qui tuait toute l'application

résilience
symptôme
En coupant la connexion au serveur — un redémarrage nocturne, le réseau qui tombe, un pare-feu qui coupe sur inactivité — l'application ne se dégradait pas : elle se fermait entière, sans avertissement, sur chaque poste connecté.
cause
Une erreur sur une connexion oisive du pool est émise comme événement ; sans écouteur pool.on('error'), Node en fait une exception non capturée.
solution
Un gestionnaire sur le pool, et l'écoute des notifications accroche son error avant de se connecter. La reconnexion réessaie toujours avec attente croissante (2 s → 30 s) et resynchronise au retour : ce qui s'est passé pendant la coupure n'a laissé aucune notification pour ce poste.

« rupture de stock dans 2 jours » sur un matériau que personne n'avait touché

modèle de calcul
symptôme
Le panneau annonçait de fausses ruptures, et la même résine donnait 26 jours d'autonomie dans un filtre et 316 dans un autre. Une plaque est montée à 7 171 jours — dix-neuf ans.
cause
Trois copies de la même formule naïve (sorties ÷ jours), fausses pour trois raisons différentes : elle comptait les corrections d'inventaire comme de la consommation, divisait par la durée nominale du filtre au lieu de celle de l'historique réel, et traitait un mouvement isolé comme un rythme.
solution
Une définition unique, en moyenne pondérée de demi-vie 30 jours (les jours sans sortie comptent au dénominateur, et c'est ce qui distingue « 12 sorties en 12 jours » de « 12 en 12 mois »). En dessous de 7 jours observés et de 2 sorties, le panneau refuse de répondre et dit pourquoi, au lieu de donner un chiffre sur lequel quelqu'un finirait par commander.

la recherche cessait d'accepter les touches, sans rien à regarder

diagnostic
symptôme
Intermittent, tous les quelques jours : le champ de recherche n'écrivait plus et il fallait fermer l'application. À l'écran, tout semblait normal.
cause
Impossible à reproduire à volonté, alors j'ai construit une sonde dans l'application (un raccourci qui vide l'état du DOM dans la base, sans jamais enregistrer ce qui est tapé — des noms de patients passent par ce champ). Le premier vidage a écarté les hypothèses évidentes. Le second a été concluant : les touches arrivaient bien au DOM, sans être annulées, avec le champ vide — le contenu avait perdu le « focus de page » de Chromium, un état où les touches sont livrées mais où le texte ne s'insère pas.
solution
Le seul geste qui répare est webContents.focus() depuis le processus principal ; depuis la page, il n'y a aucun moyen. Il se déclenche désormais tout seul dès qu'une touche arrive sans focus, et l'utilisateur ne le remarque pas.

12 flacons entrés comme 144 ml

design d'interface
symptôme
Taper « 12 » sur une entrée de résine donnait 144 ml au lieu de 12 000. Le stock bougeait à peine et on croyait que le panneau ne se rafraîchissait pas.
cause
Le champ demandait des emballages — très bien — mais s'ouvrait avec un contenu par défaut de 1 unité. Demander la bonne unité ne suffit pas si la valeur par défaut est la mauvaise. Le « stock minimum » avait le même défaut de fond : il se lisait en millilitres quand l'utilisateur pensait en flacons, et l'alerte ne se déclenchait jamais.
solution
Tout champ de quantité s'ouvre avec l'emballage de référence de l'article et affiche l'équivalent dans les deux unités à la fois — « 12 flacons = 12 000 ml » — pour qu'une valeur mal posée saute aux yeux avant de valider. L'arithmétique des emballages a été unifiée dans un module partagé.
Le résultat du troisième incident. L'application ne se contente pas de donner un nombre de jours : elle dit sur quoi elle l'a calculé — « 136 j · rupture vers le 31/01/27 · estimation fiable sur 24 j (40 sorties) ». Quand elle n'a pas assez de base, elle écrit un tiret et le motif au lieu d'un chiffre.

code

où vit la complexité

SuiviLaboBoard.tsx
5 295
InventaireBoard.tsx
3 020
InventaireStats.tsx
1 595
SuiviLaboStats.tsx
1 492
store/sqlite.ts
1 274
store/postgres.ts
1 247
lib/viz.tsx
781
store/types.ts
758
main/print.ts
600
lib/fraise.ts
463
27 fichiers restants
4 282

Lignes par fichier dans src/, sur 20 807 au total. Les deux tableaux concentrent 40 % du code et sont la cible déclarée du prochain refactor : ils ont grossi par accumulation de colonnes et de modales, et c'est là qu'il est le plus difficile d'entrer. Les deux backends pèsent presque pareil (1 274 et 1 247) — signe que le contrat DataStore est resté symétrique.

écrans

le reste de l'application

Production : cinq compteurs sur un axe commun, comparaison avec la période de référence et état du tableau.
Vue d'ensemble de l'inventaire. Seuls les euros et les mouvements s'additionnent : additionner des millilitres avec des plaques ne voudrait rien dire.
Fiche par article : courbe de stock reconstruite à rebours, état de chaque lot et rythme de consommation pondéré.
L'alerte d'usure : compteur depuis le dernier changement, total du laboratoire et durée de vie des fraises précédentes.
Les tâches terminées se verrouillent. Une tâche vaut un travail livré, et la réécrire fausserait sa traçabilité.
Création d'article par scan : un tir du lecteur apporte code, lot et péremption dans un seul code bidimensionnel.
Le journal d'un article : entrées de lot, sorties et corrections mêlées par date, chacune liée à son lot.
Actions en masse. C'est l'un des quatre chemins d'écriture du tableau, et c'est pour cela qu'il passe aussi par le verrouillage.
Réglages partagés : les couleurs et les étiquettes, tout le monde les voit pareil ; la largeur des colonnes est propre à chaque poste.

Toutes les captures proviennent d'une copie de la base réelle, avec les noms de patients remplacés par des noms fictifs. Les volumes, lots, dates et étapes sont ceux du laboratoire.

critère

ce que j'en retiens

refuser de répondre est une fonction

Un nombre inventé sur lequel quelqu'un va commander du matériel est pire qu'un tiret et un motif. Plusieurs parties de l'application disent « je n'ai pas assez de base » et expliquent pourquoi.

la voie légitime est aussi la robuste

Lire la base du logiciel fermé a été écarté au profit de son propre export. En plus de respecter la licence, c'est la seule chose qui ne casse pas quand l'éditeur met à jour.

instrumenter plutôt que deviner

Le défaut de focus est resté des semaines sans explication. Deux vidages d'une sonde écrite pour l'occasion l'ont fermé ; aucune quantité de lecture du code ne l'aurait fait.

une seule définition par concept

Presque toutes les erreurs de calcul venaient de la même formule copiée trois fois puis divergée. Maintenant chaque concept — autonomie, état de stock, nombre de plaques — a un seul endroit.

Et une honnêteté que le projet m'oblige à ajouter : quitter un abonnement n'est pas gratuit, cela change la facture de place. Le cabinet ne paie plus de mensualité, mais il dépend de ce que cela reste maintenu — d'où la mise en service écrite pas à pas, l'installation qui ne demande pas d'administrateur et les deux backends qui partagent un contrat. Un logiciel sur mesure que seul son auteur sait exploiter n'est pas une économie, c'est une autre dépendance. Logiciel propriétaire, dépôt privé ; les détails d'infrastructure du client sont omis.

Traçabilité des lots Scan GS1 Base partagée en temps réel Logiciel sur mesure Fin de l'abonnement mensuel
technologies

Electron·React·TypeScript·SQLite·PostgreSQL·Docker·electron-vite·AutoHotkey

un projet
similaire ?

parlons-en