Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added $(readlink
Empty file.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ conception est **terminée et validée** ; l'implémentation n'a pas commencé.

| Document | Rôle |
|---|---|
| `docs/02-architecture.md` | **La référence.** Tout en découle. 22 sections, 33 ADR |
| `docs/02-architecture.md` | **La référence.** Tout en découle. 22 sections, 41 ADR |
| `docs/03-glossaire.md` | **Autorité de nommage.** Ne jamais s'en écarter |
| `docs/04-parametrage-sato.md` | **Ce que l'imprimante a en mémoire.** Fait foi sur la géométrie |
| `docs/01-etat-des-lieux.md` | Ce que faisait l'ancienne application, et pourquoi |
Expand Down
317 changes: 254 additions & 63 deletions SUIVI.md

Large diffs are not rendered by default.

130 changes: 118 additions & 12 deletions TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,16 +199,26 @@ pesée** : le prix au kilo lu dans le catalogue, le poids, le tarif appliqué, l

Les quatre postes doivent afficher **la même chaîne de 8 caractères**. Si celui-ci
diverge, un des réglages que les quatre postes partagent n'est pas le même ici : la
grille de tarifs, les garde-fous, le gabarit d'étiquette, les catégories, mais aussi les
réglages du matériel qui voyagent avec la configuration clonée — le décalage
d'étiquette, le noircissement, la vitesse, le débit de la balance. Page **Poste** →
l'aperçu du diff dit exactement lequel.
grille de tarifs, les garde-fous, le gabarit d'étiquette, les catégories, l'affichage ou
non des produits vendus à l'unité, mais aussi les réglages du matériel qui voyagent avec
la configuration clonée — le décalage d'étiquette, le noircissement, la vitesse, le débit
de la balance. Page **Poste** → l'aperçu du diff dit exactement lequel.

**Une chaîne différente ne veut donc pas dire « les prix sont faux ».** Si quelqu'un a
monté le noircissement sur ce poste parce que l'étiquette sortait pâle, ce poste diverge
et ses prix sont justes. Lisez le diff avant de conclure : c'est lui qui nomme le
réglage, pas l'empreinte.

**Deux autres divergences, et elles sont normales.** Un poste qui vend des melons à la
pièce et un poste qui ne pèse que du vrac ne montrent pas la même grille : il est normal
que leurs empreintes diffèrent. Et surtout, **une montée de version change à elle seule
l'empreinte de tous les postes**, même réglés à l'identique, parce qu'un réglage nouveau
entre dans le calcul dès qu'il existe — avant même que quiconque y touche. Pendant un
déploiement échelonné, quatre postes rigoureusement identiques affichent donc deux
chaînes : une pour ceux qui sont passés à la version neuve, une pour les autres.
**Comparez les versions avant de comparer les empreintes.** La version est écrite en bas
de l'écran client, à côté du nombre de produits pesables.

3. **L'écran affiche « Le poste ne peut pas calculer les prix (ERR-CFG-01) »** → la
configuration est invalide, le poste tourne en configuration d'usine. L'écran
d'administration liste **toutes** les fautes en français, d'un coup. En ligne de
Expand All @@ -221,12 +231,93 @@ réglage, pas l'empreinte.
Vous pouvez aussi **restaurer une version précédente** de la configuration : les cinq
dernières sont conservées, page **Poste**.

## Un réglage enregistré est revenu tout seul une minute plus tard

Ce n'est pas une panne, c'est un garde-fou, et il ne s'arme que sur trois réglages : la
**balance**, l'**imprimante** et l'**adresse du poste**. Ce sont les seuls qui peuvent
couper la branche sur laquelle on est assis — un port qui n'existe pas, une file
d'impression mal nommée, une adresse que le navigateur n'atteint plus. Le poste les
applique, puis attend qu'on lui dise que ça marche encore. Tous les autres réglages
s'enregistrent sans rien demander.

**Ce que vous voyez.** Un bandeau en haut de l'écran d'administration : « Configuration
appliquée mais NON CONFIRMÉE. Ce qui a changé : … Le poste reviendra tout seul à la
version précédente dans … secondes si personne ne confirme. » À côté, un bouton **« Tout
fonctionne : confirmer »**.

**Ce qu'il faut faire.** Aller vérifier ce que le bandeau nomme — le poids s'affiche à
nouveau, une étiquette de test sort, l'écran répond encore — puis toucher **« Tout
fonctionne : confirmer »**.

**Si personne ne confirme dans les 60 secondes**, le poste remet en service ce qu'il
faisait tourner avant, et remet le fichier de configuration dans l'état où il était.
Rien n'est cassé et rien n'est perdu : refaites la modification, et confirmez cette
fois-ci. Si c'est l'adresse du poste que vous aviez changée et que l'écran
d'administration ne répond plus, ne touchez à rien : l'ancienne adresse revient d'elle-même
au bout de la minute.

**Pendant l'attente, un second enregistrement est refusé**, avec cette phrase : « Une
configuration attend encore d'être confirmée. Confirmez-la, ou laissez le poste revenir
tout seul à la version précédente, puis enregistrez de nouveau. » Confirmez, ou laissez
passer la minute.

**Ce que ce retour arrière détruisait, et ne détruit plus.** Sur un poste dont la
configuration était refusée (`ERR-CFG-01`), et sur celui-là seulement, le retour arrière
écrivait les réglages d'usine par-dessus le fichier du magasin : le nom de la coopérative
disparaissait, il ne restait qu'un seul tarif — la remise adhérent avec lui —, le contrôle
du panier passait à l'arrêt et le pilote d'impression devenait « Aperçu », qui écrit un
fichier et n'imprime rien. Une minute d'inattention effaçait le geste qui venait justement
de réparer le poste. **Sur un poste à jour, cela ne peut plus arriver** : le retour arrière
remet le fichier tel qu'il était avant l'enregistrement, et rien d'autre.

**Si c'est arrivé sur un poste resté en version ancienne**, les réglages ne sont pas
perdus : le poste garde les cinq dernières versions de son fichier de configuration, à côté
de lui, en `config.json.1` à `config.json.5`.

1. Page **Poste**, panneau **Cinq versions restaurables**. Chacune porte sa date et son
empreinte, la 1 étant la plus récente.
2. Touchez **« Remettre cette version en service »** sur la 1, puis **confirmez** si le
bandeau le demande.
3. Vérifiez sur la page **Règles** que les paliers de tarif sont revenus, et sur la page
**Matériel** que le pilote d'impression est bien celui de l'imprimante. Si ce n'était
pas la bonne version, recommencez avec la suivante.
4. **Mettez le poste à jour** : c'est la seule chose qui empêche que cela recommence.

Sur ces mêmes postes anciens, l'écran peut aussi refuser d'enregistrer en signalant une
faute sur le **pilote d'impression** alors que personne n'y a touché. Même cause, même
remède : mettez le poste à jour, puis reprenez.

## Le catalogue est vide, ou un produit a disparu de la grille

1. **« Catalogue vide. En attente de `flv_2.csv` depuis 4 min »** → le fichier n'est pas
arrivé. L'écran de dépannage affiche **le chemin ou l'adresse surveillée et le compte
utilisé**. Vérifiez le partage, ou **glissez-déposez un CSV** sur la page Catalogue :
c'est fait pour ça.
1. **« Catalogue vide. En attente du fichier `flv_2.csv`. »** sur l'écran client → le
fichier n'est pas arrivé. L'écran de dépannage porte en permanence, au-dessus des gros
boutons, la ligne **« Catalogue surveillé : … »** : le chemin du fichier attendu, ou
l'adresse du partage et le compte utilisé. C'est là que le poste va chercher, et nulle
part ailleurs.

Touchez **« Recharger le catalogue »**. Le poste répond aussitôt ce qu'il a **vu** :

- *« Aucun fichier flv_2.csv dans C:\ProgramData\OpenScale\data\catalog\incoming : il
n'y a rien à relire. »* → le fichier n'est pas arrivé. Voyez du côté du producteur ou
du partage, ou **glissez-déposez un CSV** sur cette même page : c'est fait pour ça.
- *« flv_2.csv est là, dans … : la veille le relit maintenant. »* → le fichier est
arrivé. Quelques secondes plus tard, l'écran écrit l'issue — le nom du fichier, ce
qu'il est devenu, l'heure et l'inventaire : *« flv_2.csv appliqué le 24/07/2026 à
14:32 via dépôt local — 355 reçus · 331 pesables · 8 non pesables · 16 anomalies. »*

Deux issues méritent d'être lues jusqu'au bout :

- **« identique au précédent »** veut dire que le fichier était le même, à l'octet près.
Ce n'est pas une panne — un producteur peut déposer le même export chaque nuit — mais
**aucun nouveau catalogue n'est entré en service** : si vous attendiez une correction,
elle n'est pas dans ce fichier.
- **« Aucun nouvel import enregistré à cet instant »**, au bout d'une trentaine de
secondes, veut dire que rien n'a été lu : le fichier n'était pas là, ou il n'avait pas
fini d'arriver. Le poste ne lit jamais un fichier encore en cours d'écriture. Attendez
la fin de la copie, et recommencez.

Le poste continue de peser pendant tout ce temps, avec le catalogue qu'il connaissait.

2. **Feu Catalogue rouge, `ERR-CAT-03`, avec un numéro de ligne** → le fichier est
corrompu à cette ligne. **Le catalogue précédent reste en service** : le poste continue
de peser avec ce qu'il connaissait. Transmettez le numéro de ligne au producteur du
Expand All @@ -235,19 +326,34 @@ dernières sont conservées, page **Poste**.
mais n'arrive pas à le supprimer, et la suppression **est** l'accusé de réception. Le
même fichier sera relu indéfiniment. Corrigez les droits du partage pour le compte
indiqué.
4. **« 16 anomalies à corriger dans Odoo »** → ce n'est **pas** une panne du poste. Ce
4. **Le panneau « Le dernier fichier n'a pas pris service » dit « échec », avec
`ERR-DB-01`** → le fichier a bien été lu et qualifié, mais le poste n'a pas pu écrire le
résultat dans sa base. **Le catalogue en service n'a pas changé** : la grille tourne
toujours sur ce qu'elle connaissait. Ce panneau est en bas de l'écran de dépannage, et
il montre aussi bien un fichier refusé qu'un fichier en échec. Regardez le feu
**Disque**, puis la page **Journal**, panneau **Journal technique** : le poste y écrit ce
qui l'a empêché d'écrire.
5. **« 16 anomalies à corriger dans Odoo »** → ce n'est **pas** une panne du poste. Ce
sont des lignes du fichier dont le code-barres est mal saisi ; ces produits sont déjà
inutilisables sur les balances actuelles. Cliquez sur « voir les lignes » : elles sont
**nommées**, avec la valeur fautive. Transmettez cette liste.
5. **« 8 non pesables — préemballés (7), code interne 0490 (1) »** → **aucune action**.
6. **« 8 non pesables — préemballés (7), code interne 0490 (1) »** → **aucune action**.
Ces produits ne relèvent pas de la balance.
6. **Feu rouge « chute du nombre de produits pesables », lot non appliqué** → le fichier
7. **Feu rouge « chute du nombre de produits pesables », lot non appliqué** → le fichier
reçu contient beaucoup moins de produits que le précédent. Le poste **refuse** de
l'appliquer et garde l'ancien : c'est presque toujours un décalage de colonne chez le
producteur. Le poste nomme les trois motifs majoritaires.
7. **Un produit n'est plus proposé** → quelqu'un a peut-être pris la décision de ne plus
8. **Un produit n'est plus proposé** → quelqu'un a peut-être pris la décision de ne plus
le proposer depuis l'écran (page Catalogue). Cette décision **survit aux imports
suivants**, exprès. La même page permet de la retirer.
9. **Toute une famille de produits manque, et le compte du bas de l'écran client ne colle
plus avec l'inventaire** → ce poste masque peut-être les produits vendus à l'unité. Page
**Catalogue**, panneau **« Ce que la grille montre »** : il dit combien de produits
vendus à l'unité sont masqués sur ce poste, et la case **« Afficher les produits vendus
à l'unité »** les remet. C'est ce qui explique qu'un inventaire annonce 331 produits
pesables pendant que le bas de l'écran client en compte 316 : **rien n'est perdu**,
c'est un choix de ce poste. Un produit masqué reste vendable — la caisse lit toujours
son code-barres, et une étiquette déjà imprimée reste valable.

## L'écran affiche « Une erreur est survenue » et se recharge en boucle

Expand Down
19 changes: 18 additions & 1 deletion cmd/openscale/capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ type scriptedStream struct {
// endErr, when set, is what the port answers instead of staying silent -- a cable
// pulled in the middle of a measurement campaign.
endErr error
// link is the last set of options the opener was handed, and opens counts how many
// times it was called at all.
//
// A double that IGNORED its options cannot tell a caller which built a usable link
// from one which handed over a struct with no bitrate, no parity and no stop bits --
// and a real port refuses the second before it touches the device. Recording them is
// what makes that assertion possible; internal/scale/gramxfoc does the same with the
// port name.
link serial.Options
opens int
}

// newScriptedStream returns a port that answers these reads, in order, and then goes
Expand Down Expand Up @@ -102,8 +112,15 @@ func (s *scriptedStream) Close() error {

// opener is the seam capture is injected through: a serial port cannot be opened by
// `go test`, so the whole command is exercised through this.
//
// It KEEPS what it was handed, so that a test can assert on the link a caller built and
// not only on the bytes it read back.
func (s *scriptedStream) opener() serial.Opener {
return func(serial.Options) (io.ReadCloser, error) { return s, nil }
return func(o serial.Options) (io.ReadCloser, error) {
s.link = o
s.opens++
return s, nil
}
}

// refusingOpener is a port that is not there: the commonest failure of the bench, and
Expand Down
78 changes: 74 additions & 4 deletions cmd/openscale/catalogadmin.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"time"
Expand Down Expand Up @@ -62,22 +63,91 @@ const maxDroppedBytes = 8 << 20
// rules it always applies — including « a file still growing is not read » (§10.5), so a
// producer's export caught mid-copy is not turned into an amputated catalog by somebody
// pressing a button.
func (c adminCatalog) Reload(_ context.Context) error {
//
// What it DOES add is a single look at the watched file, and the sentence that look
// yields. The watch is mute when it finds nothing — it forgets and returns — so the one
// state a volunteer most needs to hear about was the one nothing ever said.
func (c adminCatalog) Reload(ctx context.Context) (string, error) {
source := c.source.current()
if source == nil {
// The refusal names a PAGE and not two configuration keys: the volunteer reading it
// has an administration screen in front of them, not a file, and the « réglages
// avancés » this sentence used to send them to were removed on 27/07/2026.
return errors.New("aucune source de catalogue n'a pu être ouverte sur ce poste : " +
return "", errors.New("aucune source de catalogue n'a pu être ouverte sur ce poste : " +
"choisissez où le poste va chercher le catalogue, sur la page Catalogue")
}
wake, ok := source.(waker)
if !ok {
return fmt.Errorf("la source %q ne sait pas relire à la demande : le prochain "+
return "", fmt.Errorf("la source %q ne sait pas relire à la demande : le prochain "+
"balayage la relira", source.Name())
}
seen := c.lookAtWatchedFile(ctx, source)
// The watch is woken WHATEVER the look saw, and in that order: the look is an
// observation, the wake is the act the button names. A file dropped between the two is
// then read at once instead of at the next tick.
wake.Wake()
return nil
return seen, nil
}

// watchBudget bounds the ONE look this route takes at the watched directory.
//
// The drop directory is a path a human typed and may well be a network share: without a
// budget, a volunteer stands in front of a screen that answers when the share feels like
// it. It is the same reason the printer status is observed outside the HTTP handler.
const watchBudget = 2 * time.Second

// lookAtWatchedFile says, in French, whether the file this station watches is there.
//
// It is NOT a second import path, and the difference is the whole point: nothing is
// opened, nothing is parsed, nothing is applied. The watch keeps doing all of that, with
// its stability rule, its quarantine and its acknowledgement. What this answers is the
// single fact the watch never produces, because finding nothing is how it returns
// silently — « nobody has put anything where the station is looking ».
//
// A source with no local file — a share — yields NOTHING rather than an absence: an
// answer that claimed a file was missing without having looked for one would be worse
// than the silence it replaces.
func (c adminCatalog) lookAtWatchedFile(ctx context.Context, source ports.CatalogSource) string {
watched, ok := source.(watchedFile)
if !ok {
return ""
}
path := watched.Path()
name, directory := filepath.Base(path), filepath.Dir(path)

ctx, cancel := ports.WithBudget(ctx, c.clock, watchBudget)
defer cancel()
switch present, err := fileExists(ctx, path); {
case err != nil:
return fmt.Sprintf("Le poste n'a pas pu regarder dans %s : %s.", directory, err)
case present:
return fmt.Sprintf("%s est là, dans %s : la veille le relit maintenant.",
name, directory)
}
return fmt.Sprintf("Aucun fichier %s dans %s : il n'y a rien à relire.", name, directory)
}

// fileExists answers « is it there? » WITHIN the budget of ctx.
//
// os.Stat takes no context and a stat on an unreachable share blocks inside the kernel,
// where no cancellation reaches it. The answer therefore travels on a buffered channel:
// the caller gives up on the deadline, and the goroutine ends with the stat — never later
// than the work it bounds — and writes into a buffer nobody has to read.
func fileExists(ctx context.Context, path string) (bool, error) {
answered := make(chan error, 1)
go func() {
_, err := os.Stat(path)
answered <- err
}()
select {
case err := <-answered:
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return err == nil, err
case <-ctx.Done():
return false, ctx.Err()
}
}

// Import takes a CSV dropped on the screen and writes it where the ordinary watcher will
Expand Down
Loading