feat(production): la misura torna a cercare l'operatore

Il conto alla rovescia non si ferma piu' a zero. Oltre la scadenza continua
nell'altro senso e dice da quanto la linea e' in ritardo, in rosso, su tutte le
schermate del percorso di misura: lista task, riepilogo, scelta ricetta. Un
ritardo va letto, non dedotto. Allo scadere dell'intervallo la misura si
ripropone da sola: cicalino, un avviso di cinque secondi, e l'operatore torna al
primo task di misura da qualunque schermata si trovi.

Un ciclo copre tutti i task di misura della ricetta, quindi solo l'ultimo lo
chiude e fa ripartire l'intervallo. Farlo ripartire al primo avrebbe fatto
misurare una ricetta con tre task di misura tre volte piu' spesso di come e'
configurata. E' il server a decidere quale task chiude il ciclo, perche' e' lui
a conoscere la sequenza: la lista dei task di misura viaggia con la produzione
(measurement_task_ids), che e' anche cio' che permette a una schermata qualsiasi
di sapere dove riportare l'operatore.

Aggiunta la rimisura: si gira il pezzo e si misura di nuovo dentro lo stesso
ciclo, senza chiudere niente e senza guadagnare tempo sulla scadenza. Le due
letture restano entrambe in statistica, che e' il motivo per cui si prendono.

Chi sta gia' misurando quando l'intervallo scade e' in ritardo, non perso: la
banda diventa rossa e lo si lascia lavorare. Portarlo altrove a meta' ciclo
cancellerebbe quote che ha davanti agli occhi senza guadagnare nulla.

Sistemato anche il pulsante "Conferma ciclo" dell'overlay: alzava una bandierina
locale e basta, quindi il ciclo non veniva mai registrato sul server da quella
strada. Ora passa da confirmCycle come il pulsante della barra.

Migrazione 008: il registro eventi della produzione impara task_measured e
remeasure, e la colonna task_id — un task_measured che non dice quale task non
registra niente di utile. I valori nuovi entrano tutti insieme perche' allargare
una enum MySQL riscrive la tabella, stesso ragionamento dei tipi di task in 007.
Verificata su SQLite (batch mode) e in MySQL con --sql.

La logica dell'orologio — come si legge un ritardo, quando suona, dove sta la
misura — vive in un solo posto (production-clock.js) e la schermata di misura la
usa invece di riscriverla.

Test: +10 (265). Coprono il ciclo che non riparte a meta', la rimisura che non
sposta la scadenza, il rifiuto a linea ferma, la sequenza dei task di misura
esposta dalla produzione, i proxy Flask e la validita' JS della lista task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 19:31:51 +00:00
parent 6fbff2fe76
commit bde8fafd77
20 changed files with 1586 additions and 529 deletions
@@ -0,0 +1,176 @@
/**
* The measurement clock, shared by every screen an operator can be on.
*
* Two things the shop floor asked for live here. First, the countdown does not stop
* at zero: it keeps going the other way, so how late a measurement is can be read
* off the screen instead of worked out. Second, when the interval expires the
* measurement comes back on its own, wherever the operator happens to be - the task
* list, the summary, the recipe picker.
*
* The number always comes from the server (seconds_to_next_measurement, already
* signed). Ticking locally only keeps the display smooth between round trips, and
* every resync overwrites it: a tab that was asleep, or a machine whose clock is
* off, still shows the same figure as the station next to it.
*
* ProductionClock holds the pure parts, used here and by the measurement screen,
* which has its own component but must not grow a second copy of these rules.
*/
(function (global) {
'use strict';
var ProductionClock = {
/* mm:ss of the distance from the deadline, in whichever direction. The sign is
carried by the wording around it ("tra" / "in ritardo di"), not by a minus. */
format: function (seconds) {
var total = Math.abs(Math.round(seconds || 0));
var m = Math.floor(total / 60);
var s = total % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
},
hasClock: function (seconds) {
return seconds !== null && seconds !== undefined;
},
isOverdue: function (seconds) {
return ProductionClock.hasClock(seconds) && seconds <= 0;
},
/* Three short beeps. Wrapped because a browser refuses to make noise before the
page has been touched, and a silent buzzer must not take the screen with it.
Whether a light column replaces this is question D-5. */
playBuzzer: function () {
try {
var ctx = new (global.AudioContext || global.webkitAudioContext)();
[0, 0.25, 0.5].forEach(function (delay) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'square';
gain.gain.value = 0.3;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.15);
});
} catch (_) {}
},
/* The task the operator has to be taken back to: the first measurement task of
the recipe. Null when the recipe has none, which is the whole reason this is
a lookup and not an assumption. */
measurementUrl: function (run, executeUrlTemplate) {
var ids = (run && run.measurement_task_ids) || [];
if (!ids.length) return null;
return executeUrlTemplate.replace('/0', '/' + ids[0]);
},
};
global.ProductionClock = ProductionClock;
/* How long the operator gets to see why the screen is about to change. Long
enough to read it, short enough that it is not a way of putting the
measurement off. */
var ANNOUNCE_SECONDS = 5;
/* The server is asked again on this cadence: the run may have been stopped or
closed from another screen, and a sleeping tab drifts. */
var RESYNC_MS = 60000;
/**
* Banner component for the screens that only watch the clock.
*
* options.currentUrl - endpoint returning the run open at this station, or null
* options.executeUrl - url of a task execution page with 0 as the task id
*/
global.productionClock = function (options) {
var opts = options || {};
return {
run: null,
seconds: null,
announced: false,
redirectIn: 0,
_tick: null,
_resync: null,
init: function () {
var self = this;
this.load();
this._tick = setInterval(function () { self.onSecond(); }, 1000);
this._resync = setInterval(function () { self.load(); }, RESYNC_MS);
},
destroy: function () {
if (this._tick) clearInterval(this._tick);
if (this._resync) clearInterval(this._resync);
},
load: async function () {
try {
var resp = await fetch(opts.currentUrl);
if (!resp.ok) return;
var run = await resp.json();
this.adopt(run && run.id ? run : null);
} catch (e) {
// Offline or server down: the page stays usable, just without the clock.
}
},
adopt: function (run) {
this.run = run;
this.seconds = run ? run.seconds_to_next_measurement : null;
if (!run || run.status !== 'running') {
// Closed or stopped: nothing is due, and an announcement left on screen
// would be telling the operator to go and measure a stopped line.
this.announced = false;
this.redirectIn = 0;
return;
}
// Arriving on a page that is already late is the same event as going late
// while sitting on it.
if (this.isDue) this.announce();
},
get running() { return !!this.run && this.run.status === 'running'; },
get paused() { return !!this.run && this.run.status === 'paused'; },
get visible() {
return !!this.run && ProductionClock.hasClock(this.seconds);
},
get isDue() { return this.running && ProductionClock.isOverdue(this.seconds); },
get display() { return ProductionClock.format(this.seconds); },
get measurementUrl() {
return ProductionClock.measurementUrl(this.run, opts.executeUrl || '');
},
onSecond: function () {
var wasAnnounced = this.announced;
if (this.running && ProductionClock.hasClock(this.seconds)) {
// Past zero it keeps counting, into the negative: that is the figure the
// operator reads as "how long I am late by".
this.seconds--;
if (this.seconds <= 0) this.announce();
}
if (wasAnnounced && this.redirectIn > 0) {
this.redirectIn--;
if (this.redirectIn === 0) this.goToMeasurement();
}
},
announce: function () {
if (this.announced) return;
this.announced = true;
ProductionClock.playBuzzer();
// Without a measurement task to go to there is nothing to announce beyond
// the banner going red, and a redirect to nowhere would be a broken link.
this.redirectIn = this.measurementUrl ? ANNOUNCE_SECONDS : 0;
},
goToMeasurement: function () {
var url = this.measurementUrl;
if (url) global.location.href = url;
},
};
};
})(window);