Compare commits

..

4 Commits

6 changed files with 104 additions and 8 deletions
@@ -21,6 +21,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageName: '', imageName: '',
imageSrc: '', imageSrc: '',
dxfFile: null, dxfFile: null,
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
bandMm: 0.1, bandMm: 0.1,
minPointsPct: 95, minPointsPct: 95,
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce) dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
@@ -59,8 +60,19 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
}, },
get calibrated() { return this.calState === 'calibrated'; }, get calibrated() { return this.calState === 'calibrated'; },
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
get manualPxMm() {
const raw = String(this.mmPx || '').trim().replace(',', '.');
if (!raw) return null;
const v = Number(raw);
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
return 1 / v;
},
// #98: compilato ma non valido -> errore in pagina
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
get canRun() { get canRun() {
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy; // #98: la scala a mano sostituisce la calibrazione della stazione
return !!(this.stationId && (this.calibrated || this.manualPxMm !== null) && this.imageFile && this.dxfFile) && !this.busy;
}, },
_setImage(file) { _setImage(file) {
@@ -120,6 +132,8 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png'); fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name); fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId); fd.append('station_id', this.stationId);
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione
if (this.manualPxMm !== null) fd.append('px_per_mm', String(this.manualPxMm));
fd.append('band_mm', String(this.bandMm)); fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct)); fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light'); fd.append('polarity', this.dark ? 'dark' : 'light');
@@ -680,13 +680,23 @@
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600"> <p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span> {{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p> </p>
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600"> <p x-show="stationId && calState && !calibrated && manualPxMm === null" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido #}
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span> <span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl" <a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a> class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span> <span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p> </p>
</div> </div>
<!-- #98: mm/px a mano per immagini da file, sostituisce la calibrazione della stazione -->
<div>
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
= <span x-text="fmt(manualPxMm)"></span> px/mm
</p>
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
</div>
<!-- Immagine: camera o file --> <!-- Immagine: camera o file -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
@@ -51,7 +51,9 @@
<!-- Subtask --> <!-- Subtask -->
<div> <div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label> <label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
<select x-model="selectedSubtaskId" <select x-model="selectedSubtaskId"
@change="loadData()"
:disabled="subtasks.length === 0" :disabled="subtasks.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"> class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutti i punti") }}</option> <option value="">{{ _("Tutti i punti") }}</option>
@@ -287,20 +289,52 @@
</div> </div>
</div> </div>
</template> </template>
<template x-if="!capability || capability.cp == null"> <!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
<div>
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-steel text-left">
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
<th class="py-1 font-medium text-right">n</th>
<th class="py-1 font-medium text-right">Cp</th>
<th class="py-1 font-medium text-right">Cpk</th>
</tr>
</thead>
<tbody>
<template x-for="row in capabilityAll" :key="row.subtask.id">
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
</tr>
</template>
</tbody>
</table>
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
</div>
</template>
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p> <p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
</template> </template>
</div> </div>
</div> </div>
<!-- Row 2: Carta di Controllo --> <!-- Row 2: Carta di Controllo -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6"> <!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2> <h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
<div id="control-chart" style="min-height: 350px;"></div> <div id="control-chart" style="min-height: 350px;"></div>
</div> </div>
<!-- Row 3: Istogramma --> <!-- Row 3: Istogramma -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6"> <div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2> <h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
<div id="histogram-chart" style="min-height: 350px;"></div> <div id="histogram-chart" style="min-height: 350px;"></div>
</div> </div>
@@ -346,6 +380,8 @@ function spcDashboard() {
subtasks: [], subtasks: [],
// Data state // Data state
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
summary: null, summary: null,
capability: null, capability: null,
controlChart: null, controlChart: null,
@@ -408,6 +444,8 @@ function spcDashboard() {
async onRecipeChange() { async onRecipeChange() {
this.selectedSubtaskId = ''; this.selectedSubtaskId = '';
this.loadedSubtaskId = ''; // #100
this.capabilityAll = []; // #100
this.selectedRunId = ''; // #58 this.selectedRunId = ''; // #58
this.runs = []; this.runs = [];
this.subtasks = []; this.subtasks = [];
@@ -459,10 +497,11 @@ function spcDashboard() {
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58 if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString()); if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
try { try {
// Parallel fetch of all endpoints // Parallel fetch of all endpoints
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([ const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = await Promise.allSettled([
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()), fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
this.selectedSubtaskId this.selectedSubtaskId
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json()) ? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
@@ -473,6 +512,17 @@ function spcDashboard() {
this.selectedSubtaskId this.selectedSubtaskId
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json()) ? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
: Promise.resolve(null), : Promise.resolve(null),
// #100: senza punto scelto, capability di ogni punto della ricetta
requestedSubtaskId
? Promise.resolve([])
: Promise.all(this.subtasks.map(st => {
const p = new URLSearchParams(params.toString());
p.set('subtask_id', st.id);
return fetch('/statistics/api/capability?' + p.toString())
.then(r => r.json())
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
.catch(() => ({ subtask: st, data: null }));
})),
]); ]);
// Summary (always available) // Summary (always available)
@@ -505,6 +555,9 @@ function spcDashboard() {
this.histogram = null; this.histogram = null;
} }
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
this.loadedSubtaskId = requestedSubtaskId;
this.hasData = true; this.hasData = true;
// Render charts after DOM update // Render charts after DOM update
@@ -522,7 +575,7 @@ function spcDashboard() {
renderCharts() { renderCharts() {
// Control chart // Control chart
if (this.controlChart && this.selectedSubtaskId) { if (this.controlChart && this.loadedSubtaskId) { // #100
renderControlChart('control-chart', this.controlChart); renderControlChart('control-chart', this.controlChart);
} else { } else {
const el = document.getElementById('control-chart'); const el = document.getElementById('control-chart');
@@ -530,7 +583,7 @@ function spcDashboard() {
} }
// Histogram // Histogram
if (this.histogram && this.selectedSubtaskId) { if (this.histogram && this.loadedSubtaskId) { // #100
const tol = this.capability ? { const tol = this.capability ? {
utl: this.capability.utl, utl: this.capability.utl,
ltl: this.capability.ltl, ltl: this.capability.ltl,
@@ -178,6 +178,12 @@ class TestDxfCompareProxy:
assert set(kwargs["files"]) == {"image", "dxf"} assert set(kwargs["files"]) == {"image", "dxf"}
assert kwargs["files"]["image"][1] == PNG assert kwargs["files"]["image"][1] == PNG
def test_manual_px_per_mm_forwarded(self, logged_in_client, mock_api_client):
# #98: scala a mano dal wizard (immagine da file): inoltrata al backend
mock_api_client.post.return_value = self.OK
self._post(logged_in_client, station_id="4", px_per_mm="71.1237")
assert mock_api_client.post.call_args.kwargs["data"]["px_per_mm"] == "71.1237"
def test_save_true_forwarded(self, logged_in_client, mock_api_client): def test_save_true_forwarded(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"} mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
resp = self._post(logged_in_client, save="true") resp = self._post(logged_in_client, save="true")
@@ -229,4 +235,5 @@ class TestPages:
assert "js/dxf_recipe_wizard.js" in html assert "js/dxf_recipe_wizard.js" in html
assert "dxfRecipeWizard(" in html assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
assert 'id="run-task-results"' in html assert 'id="run-task-results"' in html
assert "loadTaskResults" in html assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html assert "/statistics/api/runs/" in html
def test_dashboard_all_points_overview(self, logged_in_client, mock_api_client):
# #100: con «Tutti i punti» la pagina ha tabella per-punto e select che ricarica da solo
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert "capabilityAll" in html
assert "loadedSubtaskId" in html
assert '@change="loadData()"' in html
@@ -3485,3 +3485,7 @@ msgstr "Invalid camera address"
#: templates/admin/stations.html blueprints/admin.py (#88) #: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera" msgid "Risposta non valida dal servizio camera"
msgstr "Invalid response from the camera service" msgstr "Invalid response from the camera service"
#: templates/statistics/dashboard.html (#100)
msgid "Scegli un punto per carta di controllo e istogramma"
msgstr "Choose a point for the control chart and histogram"