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:
@@ -420,20 +420,34 @@
|
||||
{# ================================================================
|
||||
MEASUREMENT TIMER BANNER
|
||||
================================================================ #}
|
||||
{# Past zero the counter turns round: it stops saying how long is left and starts
|
||||
saying how late the line is, in red. A delay has to be read, not deduced. #}
|
||||
<div x-show="timerActive"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
|
||||
class="shrink-0 border-t px-4 py-2"
|
||||
:class="isOverdue
|
||||
? 'bg-red-50 dark:bg-red-900/30 border-red-400 dark:border-red-700'
|
||||
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<svg class="w-5 h-5 text-amber-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 animate-pulse"
|
||||
:class="isOverdue ? 'text-red-600' : 'text-amber-600'"
|
||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
<span x-show="!isOverdue" class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{{ _('Prossima misurazione tra') }}
|
||||
</span>
|
||||
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
|
||||
<span x-show="isOverdue" class="text-sm font-bold text-red-800 dark:text-red-200">
|
||||
{{ _('Misurazione in ritardo di') }}
|
||||
</span>
|
||||
<span class="text-lg font-bold font-mono px-2 py-0.5 rounded"
|
||||
:class="isOverdue
|
||||
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900/40'
|
||||
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40'"
|
||||
x-text="timerDisplay"></span>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||
<span class="text-xs"
|
||||
:class="isOverdue ? 'text-red-600 dark:text-red-300' : 'text-amber-600 dark:text-amber-400'">
|
||||
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
|
||||
</span>
|
||||
</div>
|
||||
@@ -474,8 +488,11 @@
|
||||
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||
{{ _('Linea ferma') }}
|
||||
</span>
|
||||
{# A line stopped past its interval is frozen at a delay, not at a wait. Both
|
||||
read as mm:ss, so the wording is what has to change. #}
|
||||
<span class="text-xs text-amber-800 dark:text-amber-200">
|
||||
{{ _('Il conto alla rovescia è congelato a') }}
|
||||
<span x-show="timerRemaining > 0">{{ _('Il conto alla rovescia è congelato a') }}</span>
|
||||
<span x-show="timerRemaining <= 0">{{ _('Misurazione in ritardo di') }}</span>
|
||||
<span class="font-mono font-bold" x-text="timerDisplay"></span>
|
||||
— {{ _('serve il capoturno per riprendere') }}
|
||||
</span>
|
||||
@@ -579,6 +596,17 @@
|
||||
</svg>
|
||||
{{ _('Fine ciclo misura') }}
|
||||
</button>
|
||||
{# Girare il pezzo e rimisurare, senza chiudere il ciclo: a second reading
|
||||
of the same part is not a new one, and must not restart the interval. #}
|
||||
<button x-show="isComplete && productionStarted"
|
||||
x-transition
|
||||
@click="remeasure()"
|
||||
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-3">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
|
||||
</svg>
|
||||
{{ _('Rimisura') }}
|
||||
</button>
|
||||
<button x-show="cycleConfirmed"
|
||||
x-transition
|
||||
@click="goToNextTask()"
|
||||
@@ -610,6 +638,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
È ORA DI MISURARE — the interval expired on a task that is not the
|
||||
measurement. The operator is told why the screen is about to change, then
|
||||
taken there; there is no way to dismiss it, because putting the measurement
|
||||
off is exactly what the interval exists to prevent.
|
||||
================================================================ #}
|
||||
<div x-show="announcedRedirect"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center border-2 border-red-500">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
||||
<svg class="w-8 h-8 text-red-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('È ora di misurare') }}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-5">
|
||||
{{ _('Ritorno alla misurazione tra') }}
|
||||
<span class="font-bold font-mono" x-text="redirectIn"></span> s
|
||||
</p>
|
||||
<button @click="goToMeasurementTask()"
|
||||
class="btn w-full justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-bold py-2.5">
|
||||
{{ _('Vai alla misura') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
COMPLETION OVERLAY
|
||||
================================================================ #}
|
||||
@@ -659,9 +718,10 @@
|
||||
{{ _('Riepilogo') }}
|
||||
</button>
|
||||
|
||||
{# Production phase: confirm the cycle (starts the interval timer) #}
|
||||
{# Production phase: confirm the cycle. Goes through confirmCycle so the
|
||||
server records it - setting the flag here only looked like it had. #}
|
||||
<button x-show="productionStarted"
|
||||
@click="showCompletionOverlay = false; cycleConfirmed = true"
|
||||
@click="confirmCycle()"
|
||||
class="btn btn-primary flex-1 justify-center gap-2">
|
||||
{{ _('Conferma ciclo') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
@@ -689,6 +749,18 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# The piece can be turned over here, where the operator actually is when the
|
||||
quotes are done - not only from the bar behind the overlay. #}
|
||||
<button x-show="productionStarted"
|
||||
@click="remeasure()"
|
||||
class="mt-3 w-full text-sm text-[var(--text-secondary)] hover:text-primary
|
||||
inline-flex items-center justify-center gap-1.5 py-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
|
||||
</svg>
|
||||
{{ _('Girare il pezzo e rimisurare') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -761,6 +833,9 @@
|
||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
||||
measurement lives - are shared with the other screens rather than restated. #}
|
||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||
|
||||
<script>
|
||||
/**
|
||||
@@ -797,6 +872,13 @@ function taskExecute() {
|
||||
// This is read back from the server on every load instead.
|
||||
productionRun: null,
|
||||
productionError: '',
|
||||
// The measurement tasks of this recipe, in order: the loop the operator stays
|
||||
// in once production starts, and what says which task closes a cycle.
|
||||
measurementTaskIds: [],
|
||||
// The interval has elapsed and it has been said out loud - buzzer, and the
|
||||
// operator on their way back to the measurement. Kept so it is said once.
|
||||
dueAnnounced: false,
|
||||
redirectIn: 0,
|
||||
|
||||
// ---- Cycle & workflow state ----
|
||||
cycleConfirmed: false,
|
||||
@@ -917,6 +999,7 @@ function taskExecute() {
|
||||
this.productionRun = run;
|
||||
this.productionStarted = run.status !== 'closed';
|
||||
this.cycleCount = run.cycle_count;
|
||||
this.measurementTaskIds = run.measurement_task_ids || [];
|
||||
|
||||
const seconds = run.seconds_to_next_measurement;
|
||||
if (seconds === null || seconds === undefined) {
|
||||
@@ -1078,17 +1161,26 @@ function taskExecute() {
|
||||
},
|
||||
|
||||
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
||||
/* A cycle spans every measurement task of the recipe, so this one only closes
|
||||
it if it is the last. The server decides that - it knows the sequence - and
|
||||
the cycle count coming back tells us which of the two happened. */
|
||||
async confirmCycle() {
|
||||
this.cycleConfirmed = true;
|
||||
this.showCompletionOverlay = false;
|
||||
|
||||
// Recorded server-side so the count and the next deadline outlive this page.
|
||||
if (this.productionRun) {
|
||||
const countBefore = this.cycleCount;
|
||||
const run = await this.postProduction(
|
||||
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
||||
{ task_id: this.task.id },
|
||||
);
|
||||
if (run) {
|
||||
this.adoptProductionRun(run);
|
||||
// Cycle still open: on to the next measurement task, interval untouched.
|
||||
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
|
||||
this.goToTask(this.nextMeasurementTaskId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1101,6 +1193,25 @@ function taskExecute() {
|
||||
}
|
||||
},
|
||||
|
||||
/* Rimisura: turn the piece over and measure it again, inside the same cycle.
|
||||
|
||||
Not a new cycle - the deadline and the count stay where they are, so a second
|
||||
reading of the same part does not buy another interval's worth of time. Both
|
||||
readings stay in the statistics: that is what they are for. */
|
||||
async remeasure() {
|
||||
if (this.productionRun) {
|
||||
const run = await this.postProduction(
|
||||
'{{ url_for("measure.api_remeasure", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
||||
{ task_id: this.task.id },
|
||||
);
|
||||
// Refused - the line is stopped, or the production is closed. The screen
|
||||
// stays as it is: clearing it would hide the reason.
|
||||
if (!run) return;
|
||||
this.adoptProductionRun(run);
|
||||
}
|
||||
this.resetForNewCycle();
|
||||
},
|
||||
|
||||
/* POST to a production endpoint, returning the updated run or null on failure. */
|
||||
async postProduction(url, body) {
|
||||
this.productionError = '';
|
||||
@@ -1125,18 +1236,34 @@ function taskExecute() {
|
||||
|
||||
// ---- Measurement timer ----
|
||||
/* Ticks locally for a smooth display, but the number it starts from always comes
|
||||
from the server, and every page load resynchronises it. */
|
||||
from the server, and every page load resynchronises it.
|
||||
It does not stop at zero: past the deadline it keeps counting the other way,
|
||||
and the banner reads the delay off it. */
|
||||
startCountdownFrom(seconds) {
|
||||
this.stopMeasurementTimer();
|
||||
this.timerRemaining = seconds;
|
||||
this.timerActive = true;
|
||||
// Landing on a page that is already late is the same event as going late
|
||||
// while sitting on it; being back in time re-arms the announcement.
|
||||
if (seconds <= 0) {
|
||||
this.onMeasurementDue();
|
||||
} else {
|
||||
this.dueAnnounced = false;
|
||||
this.redirectIn = 0;
|
||||
}
|
||||
var self = this;
|
||||
this._timerInterval = setInterval(function () {
|
||||
self.timerRemaining--;
|
||||
if (self.timerRemaining <= 0) {
|
||||
self.onTimerExpired();
|
||||
}
|
||||
}, 1000);
|
||||
this._timerInterval = setInterval(function () { self.onSecond(); }, 1000);
|
||||
},
|
||||
|
||||
onSecond() {
|
||||
const wasAnnounced = this.dueAnnounced;
|
||||
this.timerRemaining--;
|
||||
if (this.timerRemaining <= 0) this.onMeasurementDue();
|
||||
|
||||
if (wasAnnounced && this.redirectIn > 0) {
|
||||
this.redirectIn--;
|
||||
if (this.redirectIn === 0) this.goToMeasurementTask();
|
||||
}
|
||||
},
|
||||
|
||||
stopMeasurementTimer() {
|
||||
@@ -1147,32 +1274,79 @@ function taskExecute() {
|
||||
this.timerActive = false;
|
||||
},
|
||||
|
||||
onTimerExpired() {
|
||||
this.stopMeasurementTimer();
|
||||
this.playBuzzer();
|
||||
// Reset for new measurement cycle
|
||||
/* The interval has run out. Said once, then acted on.
|
||||
|
||||
Someone already measuring is late, not lost: the banner turns red and they
|
||||
are left alone. Taking them elsewhere mid-cycle would wipe quotes they can
|
||||
see on the screen and gain nothing.
|
||||
|
||||
Otherwise a new cycle starts, and it starts at the first measurement task of
|
||||
the recipe - re-arming wherever the operator happens to be standing would
|
||||
skip whatever comes before it and still count as a full cycle. */
|
||||
onMeasurementDue() {
|
||||
if (this.dueAnnounced) return;
|
||||
this.dueAnnounced = true;
|
||||
window.ProductionClock.playBuzzer();
|
||||
|
||||
if (this.isMeasurementTaskOfRun && !this.cycleConfirmed) return;
|
||||
|
||||
if (this.task.id === this.measurementTaskIds[0]) {
|
||||
this.resetForNewCycle();
|
||||
return;
|
||||
}
|
||||
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
|
||||
},
|
||||
|
||||
/* Clear the screen for another pass over the same quotes.
|
||||
Local only: everything already measured is on the server, and the next
|
||||
reading is saved beside it rather than replacing it. */
|
||||
resetForNewCycle() {
|
||||
this.cycleConfirmed = false;
|
||||
this.showCompletionOverlay = false;
|
||||
this.measurements = [];
|
||||
this.currentIndex = 0;
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
this.inputStartedAt = Date.now();
|
||||
},
|
||||
|
||||
playBuzzer() {
|
||||
try {
|
||||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
// 3 short beeps
|
||||
[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 (_) {}
|
||||
// ---- Where the measurement lives ----
|
||||
|
||||
get isMeasurementTaskOfRun() {
|
||||
return this.measurementTaskIds.indexOf(this.task.id) !== -1;
|
||||
},
|
||||
|
||||
get measurementTaskUrl() {
|
||||
return window.ProductionClock.measurementUrl(
|
||||
this.productionRun, '{{ url_for("measure.task_execute", task_id=0) }}',
|
||||
);
|
||||
},
|
||||
|
||||
/* The next measurement task of the recipe, or null if this is the last one -
|
||||
which is the one that closes the cycle and restarts the interval. */
|
||||
get nextMeasurementTaskId() {
|
||||
const ids = this.measurementTaskIds;
|
||||
const idx = ids.indexOf(this.task.id);
|
||||
if (idx === -1 || idx >= ids.length - 1) return null;
|
||||
return ids[idx + 1];
|
||||
},
|
||||
|
||||
get isOverdue() {
|
||||
return this.timerActive && this.timerRemaining <= 0;
|
||||
},
|
||||
|
||||
get announcedRedirect() {
|
||||
return this.dueAnnounced && this.redirectIn > 0;
|
||||
},
|
||||
|
||||
goToMeasurementTask() {
|
||||
const url = this.measurementTaskUrl;
|
||||
if (url) window.location.href = url;
|
||||
},
|
||||
|
||||
goToTask(taskId) {
|
||||
window.location.href =
|
||||
'{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskId);
|
||||
},
|
||||
|
||||
// ---- Avvio Produzione ----
|
||||
@@ -1224,18 +1398,35 @@ function taskExecute() {
|
||||
await this.confirmCycle();
|
||||
},
|
||||
|
||||
/* mm:ss of the distance from the deadline, either side of it. The direction is
|
||||
carried by the wording in the banner, not by a minus sign. */
|
||||
get timerDisplay() {
|
||||
var m = Math.floor(this.timerRemaining / 60);
|
||||
var s = this.timerRemaining % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
return window.ProductionClock.format(this.timerRemaining);
|
||||
},
|
||||
|
||||
// ---- Navigate to next task (Completato) ----
|
||||
/* With a production open the operator stays in the measurement loop until the
|
||||
capoturno closes it: after the last measurement task they go back to the
|
||||
first, not out to the summary. Before production starts, and on the
|
||||
documental tasks that come before the measurement, the plain sequence
|
||||
applies - that is the run-up, not the loop. */
|
||||
goToNextTask() {
|
||||
if (this.productionStarted && this.isMeasurementTaskOfRun) {
|
||||
const next = this.nextMeasurementTaskId || this.measurementTaskIds[0];
|
||||
// A recipe with a single measurement task loops on the spot: reloading the
|
||||
// same page to arrive at the same state would only cost a round trip.
|
||||
if (next === this.task.id) {
|
||||
this.resetForNewCycle();
|
||||
return;
|
||||
}
|
||||
this.goToTask(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const currentIdx = taskIds.indexOf(this.task.id);
|
||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||
this.goToTask(taskIds[currentIdx + 1]);
|
||||
} else {
|
||||
this.goToSummary();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user