feat(production): fermo linea e fine produzione fanno finalmente qualcosa

Punto 6 del documento modifiche del 28/07. I due pulsanti esistevano, chiedevano
correttamente l'autorizzazione del capoturno, e poi ricadevano su un commento:
"handled by GAIA integration (future)". E' la riga di apertura del documento.

Ora agiscono sulla produzione aperta: fermo linea sospende e congela il conto alla
rovescia, la ripresa lo fa ripartire restituendo il tempo del fermo, fine
produzione chiude ed emette il file di statistica. Le credenziali del capoturno
vanno direttamente all'endpoint, che le verifica e registra sulla traccia della
produzione chi ha autorizzato cosa: validarle a parte non lascerebbe traccia, e
sarebbe un giro in piu'.

In interfaccia il pulsante di fermo diventa "Riprendi" quando la linea e' ferma, e
una banda dichiara lo stato con il valore congelato a video: una linea ferma che
sembra in marcia e' il modo in cui si salta un intervallo di misura senza
accorgersene.

Il file di statistica ha richiesto di legare le misure alla produzione (migrazione
006): Measurement conosceva solo versione, lotto e seriale, nessuno dei quali
separa una produzione dalla successiva sulla stessa ricetta e sullo stesso lotto,
quindi "le misure dell'intera produzione" non era una query. Il CSV rispetta i
separatori configurati in system_settings e porta con se' i limiti di tolleranza:
senza quelli un esito pass/fail non e' piu' ricalcolabile dal file a distanza di
anni, che e' il senso di un documento per audit. Le misure esportate vengono
marcate synced_to_csv. Se la produzione non ha misure non viene scritto nulla: un
file vuoto sarebbe rumore nella cartella, non evidenza.

L'invio a GAIA resta assente e non abbozzato, con il punto d'innesto dichiarato in
close_run: una chiamata vuota che sembra collegata e' peggio di niente. Tutto il
resto del punto 6 non dipende dal gestionale e funziona adesso.

Anche la 006 e' stata eseguita su SQLite usa e getta prima di essere considerata
buona, e anche qui la prova ha trovato un difetto: aggiungere una colonna con
foreign key fa emettere ad alembic un ALTER di vincolo, che SQLite rifiuta. Ora usa
batch_alter_table con il vincolo nominato, che su MySQL resta un ALTER normale e su
SQLite ricostruisce la tabella - cosi' la chiave esterna non va persa per far
contento il dialetto dei test.

Corretto anche il proxy di salvataggio misura, che chiamava /api/measurements senza
slash finale e pagava un redirect 307 a ogni singola misura.

Traduzioni: pybabel aveva di nuovo indovinato sette voci marcandole fuzzy, e in
italiano "Linea ferma" era diventato "Lingua Preferita" su una banda di sicurezza.
Tradotte per esteso in IT ed EN e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 17:32:43 +00:00
parent 55e5e0153f
commit 7bc3c1f938
16 changed files with 889 additions and 159 deletions
+2
View File
@@ -47,6 +47,7 @@ async def create_measurement(
serial_number=data.serial_number,
input_method=data.input_method,
input_duration_ms=data.input_duration_ms,
production_run_id=data.production_run_id,
)
return MeasurementResponse.model_validate(measurement)
except ValueError as e:
@@ -76,6 +77,7 @@ async def create_measurement_batch(
serial_number=measurement_data.serial_number,
input_method=measurement_data.input_method,
input_duration_ms=measurement_data.input_duration_ms,
production_run_id=measurement_data.production_run_id,
)
measurements.append(measurement)
return [MeasurementResponse.model_validate(m) for m in measurements]
@@ -0,0 +1,63 @@
"""link measurements to their production run
Fine produzione has to hand the measurements of the whole production to the
statistics file. Without this column "the measurements of this production" is not a
query: measurements only knew their recipe version, lot and serial, none of which
delimits one run from the next on the same recipe and lot.
Nullable on purpose: measurements taken before this - and any taken outside a
production - simply have no run.
Revision ID: 006_measurement_run
Revises: 005_production_runs
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '006_measurement_run'
down_revision: Union[str, None] = '005_production_runs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Batch mode, because the column carries a foreign key: adding a constraint is
# an ALTER that SQLite cannot do, and alembic raises rather than silently
# dropping it. On MySQL this is a plain ALTER; on SQLite it rebuilds the table.
# Keeping the key is worth the ceremony - ON DELETE SET NULL means a measurement,
# the record that matters in an audit, survives its run being deleted.
with op.batch_alter_table('measurements') as batch_op:
batch_op.add_column(
sa.Column(
'production_run_id', sa.Integer,
sa.ForeignKey(
'production_runs.id',
ondelete='SET NULL',
# Named because batch mode requires it, and because an anonymous
# constraint cannot be referred to later.
name='fk_measurements_production_run',
),
nullable=True,
),
)
op.create_index(
'ix_measurements_production_run_id', 'measurements', ['production_run_id'],
)
# Where the statistics file for a closed run was written. No constraint, so a
# plain ALTER is enough.
op.add_column(
'production_runs',
sa.Column('statistics_path', sa.String(500), nullable=True),
)
def downgrade() -> None:
op.drop_column('production_runs', 'statistics_path')
op.drop_index('ix_measurements_production_run_id', table_name='measurements')
with op.batch_alter_table('measurements') as batch_op:
batch_op.drop_column('production_run_id')
+3
View File
@@ -14,6 +14,8 @@ class MeasurementCreate(BaseModel):
serial_number: Optional[str] = Field(None, max_length=100)
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
input_duration_ms: Optional[int] = Field(None, ge=0)
# The production this belongs to, when one is open at the station.
production_run_id: Optional[int] = Field(None, gt=0)
class MeasurementBatchCreate(BaseModel):
@@ -36,6 +38,7 @@ class MeasurementResponse(BaseModel):
serial_number: Optional[str] = None
input_method: str
input_duration_ms: Optional[int] = None
production_run_id: Optional[int] = None
measured_at: datetime
synced_to_csv: bool
+1
View File
@@ -41,6 +41,7 @@ class ProductionRunResponse(BaseModel):
paused_at: Optional[datetime]
closed_at: Optional[datetime]
closed_by: Optional[int]
statistics_path: Optional[str] = None
# Derived server-side so every client agrees on the countdown regardless of
# clock skew. Negative once the interval has elapsed: how long the run has been
+11
View File
@@ -48,6 +48,17 @@ class Measurement(Base):
Integer, nullable=True
)
# The production this was taken during, when there was one. Nullable: rows
# predating production runs, and measurements taken outside a production, have
# none. This is what makes "the measurements of this production" a query, which
# fine produzione needs to emit the statistics file.
production_run_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("production_runs.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Timestamp
measured_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True
+2
View File
@@ -84,6 +84,8 @@ class ProductionRun(Base):
closed_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
# Statistics file emitted when the run was closed, relative to the upload dir.
statistics_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
# index on it lets the database - not a check-then-insert race - guarantee that a
@@ -49,6 +49,7 @@ async def save_measurement(
serial_number: str | None = None,
input_method: str = "manual",
input_duration_ms: int | None = None,
production_run_id: int | None = None,
) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail."""
# Get subtask for tolerance values
@@ -72,6 +73,7 @@ async def save_measurement(
serial_number=serial_number,
input_method=input_method,
input_duration_ms=input_duration_ms,
production_run_id=production_run_id,
)
db.add(measurement)
await db.flush()
@@ -0,0 +1,161 @@
"""Statistics file emitted when a production is closed.
Fine produzione has to hand the measurements of the whole production over. That part
does not depend on the ERP: the file is produced now, and the hand-off to GAIA plugs
in beside it once the protocol is agreed (questions D-1 and D-2). Keeping it in its
own module makes that seam obvious rather than buried in the close path.
The CSV uses the delimiters configured in system_settings, the same ones the manual
export honours, so a shop floor set to the Italian convention (';' and ',') gets
files that open correctly in their Excel.
"""
import csv
import io
from datetime import datetime
from pathlib import Path
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask
# Written under the upload directory: it is the volume that is already mounted and
# backed up, so the files survive a container being replaced.
EXPORT_SUBDIR = "statistics"
HEADER = [
"production_run_id",
"station_code",
"recipe_code",
"version_id",
"lot_number",
"serial_number",
"measurement_id",
"subtask_id",
"marker_number",
"subtask_description",
"nominal",
"value",
"deviation",
"pass_fail",
# The limits travel with the row: without them a pass/fail cannot be re-derived
# from the file years later, which is the whole point of an audit record.
"ltl",
"lwl",
"uwl",
"utl",
"unit",
"input_method",
"input_duration_ms",
"measured_by",
"measured_at",
]
async def _csv_format(db: AsyncSession) -> tuple[str, str]:
"""Delimiter and decimal separator as configured for this installation."""
delimiter_row = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
)
delimiter = delimiter_row.scalar_one_or_none()
decimal_row = await db.execute(
select(SystemSetting).where(
SystemSetting.setting_key == "csv_decimal_separator"
)
)
decimal = decimal_row.scalar_one_or_none()
return (
delimiter.setting_value if delimiter else ",",
decimal.setting_value if decimal else ".",
)
async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
"""Render every measurement of a run as CSV text."""
delimiter, decimal_separator = await _csv_format(db)
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
recipe = (await db.execute(
select(Recipe).where(Recipe.id == run.recipe_id)
)).scalar_one_or_none()
rows = (await db.execute(
select(Measurement, RecipeSubtask)
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id, isouter=True)
.where(Measurement.production_run_id == run.id)
.order_by(Measurement.measured_at, Measurement.id)
)).all()
def number(value) -> str:
return "" if value is None else str(value).replace(".", decimal_separator)
output = io.StringIO()
writer = csv.writer(output, delimiter=delimiter, lineterminator="\n")
writer.writerow(HEADER)
for measurement, subtask in rows:
writer.writerow([
run.id,
station.code if station else "",
recipe.code if recipe else "",
measurement.version_id,
measurement.lot_number or "",
measurement.serial_number or "",
measurement.id,
measurement.subtask_id,
subtask.marker_number if subtask else "",
subtask.description if subtask else "",
number(subtask.nominal) if subtask else "",
number(measurement.value),
number(measurement.deviation),
measurement.pass_fail,
number(subtask.ltl) if subtask else "",
number(subtask.lwl) if subtask else "",
number(subtask.uwl) if subtask else "",
number(subtask.utl) if subtask else "",
(subtask.unit or "") if subtask else "",
measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "",
])
return output.getvalue()
async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | None:
"""Write the run's statistics file and mark its measurements as exported.
Returns the path relative to the upload directory, or None when the run has no
measurements - an empty file would only be noise in the folder.
"""
content = await build_statistics_csv(db, run)
# Header only: nothing was measured during this production.
if len(content.strip().splitlines()) <= 1:
return None
directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv"
(directory / filename).write_text(content, encoding="utf-8-sig")
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
# and these files are opened in Excel on the shop floor.
await db.execute(
update(Measurement)
.where(Measurement.production_run_id == run.id)
.values(synced_to_csv=True)
)
return f"{EXPORT_SUBDIR}/{filename}"
+17 -2
View File
@@ -25,7 +25,7 @@ from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.user import User
from src.backend.services import auth_service
from src.backend.services import auth_service, production_export_service
def _now() -> datetime:
@@ -333,7 +333,13 @@ async def close_run(
supervisor: User,
note: Optional[str] = None,
) -> ProductionRun:
"""Fine produzione: close the run for good and stop the timer."""
"""Fine produzione: close the run for good, stop the timer, emit the statistics.
Everything here happens without the ERP. Handing the production over to GAIA
goes at the marked seam below, once D-1 and D-2 are answered; until then the
file is produced and the run is properly closed, which is what the shop floor
needs either way.
"""
_require_open(run)
run.status = "closed"
run.closed_at = _now()
@@ -344,6 +350,15 @@ async def close_run(
# column is what keeps exactly one open at a time.
run.active_station_id = None
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
await db.flush()
# Statistics file for the whole production, and the measurements marked as sent.
run.statistics_path = await production_export_service.export_run_statistics(db, run)
# --- ERP hand-off (GAIA) plugs in here once the protocol is defined (D-1, D-2).
# Deliberately absent rather than stubbed: an empty call that looks wired is
# worse than none at all.
await db.flush()
await db.refresh(run)
return run
+148
View File
@@ -441,3 +441,151 @@ async def test_traceability_travels_with_the_run(
)).json()
assert body["lot_number"] == "LOT-42"
assert body["serial_number"] == "SN-7"
# ---------------------------------------------------------------------------
# Fine produzione: the statistics file (point 6)
# ---------------------------------------------------------------------------
async def _measure(client, user, run_id, subtask_id, version_id, value):
return await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": value,
"lot_number": "LOT-STAT",
"production_run_id": run_id,
},
)
async def _first_subtask(db_session, recipe_id: int):
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
row = await db_session.execute(
select(RecipeSubtask)
.join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
.join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id)
.where(RecipeVersion.recipe_id == recipe_id)
)
return row.scalars().first()
async def test_measurement_is_tied_to_the_run(
client: AsyncClient, measurement_tec_user, admin_user, db_session,
):
"""Without this link 'the measurements of this production' is not a query."""
station = await _station(db_session, admin_user.id, code="ST-LINK")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LINK")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
subtask = await _first_subtask(db_session, recipe.id)
resp = await _measure(
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
)
assert resp.status_code == 200, resp.text
assert resp.json()["production_run_id"] == opened["id"]
async def test_close_emits_the_statistics_file(
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
monkeypatch,
):
from src.backend.config import settings
from src.backend.services import production_export_service
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
station = await _station(db_session, admin_user.id, code="ST-STAT")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-STAT")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
subtask = await _first_subtask(db_session, recipe.id)
await _measure(
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
)
supervisor, password = await _supervisor(db_session, username="capo-stat")
closed = (await client.post(
f"/api/production-runs/{opened['id']}/close",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)).json()
assert closed["statistics_path"], "la chiusura deve produrre il file di statistica"
written = tmp_path / closed["statistics_path"]
assert written.exists()
text = written.read_text(encoding="utf-8-sig")
lines = [line for line in text.splitlines() if line.strip()]
assert len(lines) == 2, "intestazione piu' una misura"
assert "production_run_id" in lines[0]
# The tolerance limits travel with the row: a pass/fail must stay re-derivable.
for column in ("ltl", "lwl", "uwl", "utl", "nominal"):
assert column in lines[0]
assert "LOT-STAT" in lines[1]
assert str(opened["id"]) in lines[1]
async def test_close_marks_measurements_as_exported(
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
monkeypatch,
):
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
station = await _station(db_session, admin_user.id, code="ST-SYNC")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SYNC")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
subtask = await _first_subtask(db_session, recipe.id)
created = (await _measure(
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
)).json()
assert created["synced_to_csv"] is False
supervisor, password = await _supervisor(db_session, username="capo-sync")
await client.post(
f"/api/production-runs/{opened['id']}/close",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)
row = (await db_session.execute(
select(Measurement).where(Measurement.id == created["id"])
)).scalar_one()
await db_session.refresh(row)
assert row.synced_to_csv is True
async def test_close_without_measurements_writes_no_file(
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
monkeypatch,
):
"""An empty file would be noise in the folder, not evidence."""
from src.backend.config import settings
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
station = await _station(db_session, admin_user.id, code="ST-EMPTY")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-EMPTY")
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
supervisor, password = await _supervisor(db_session, username="capo-empty")
closed = (await client.post(
f"/api/production-runs/{opened['id']}/close",
headers=auth_headers(measurement_tec_user),
json={"supervisor_username": supervisor.username, "supervisor_password": password},
)).json()
assert closed["statistics_path"] is None
assert not (tmp_path / "statistics").exists()
+58 -1
View File
@@ -372,9 +372,14 @@ def save_measurement():
"serial_number": data.get("serial_number", session.get("serial_number", "")),
"input_method": data.get("input_method", "manual"),
"input_duration_ms": data.get("input_duration_ms"),
# Attaches the measurement to the production under way, so fine produzione
# can hand the whole run to the statistics file.
"production_run_id": data.get("production_run_id"),
}
resp = api_client.post("/api/measurements", data=payload)
# Trailing slash matters: the route is declared as "/", so omitting it costs
# a 307 redirect on every single measurement saved.
resp = api_client.post("/api/measurements/", data=payload)
if resp.get("error"):
status_code = resp.get("status_code", 500)
@@ -475,6 +480,58 @@ def api_complete_cycle(run_id: int):
return jsonify(resp), 200
def _supervised_action(run_id: int, action: str):
"""Forward an action that needs the capoturno's authorisation.
The credentials go straight to the API, which checks them and records who
authorised what on the run's trace. Validating them separately first would
leave no such record, and is an extra round trip besides.
"""
data = request.get_json(silent=True) or {}
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/production-runs/{run_id}/{action}",
data={
"supervisor_username": username,
"supervisor_password": password,
"note": data.get("note"),
},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_pause_production(run_id: int):
"""Proxy: fermo linea - suspends the run and freezes the countdown."""
return _supervised_action(run_id, "pause")
@measure_bp.route("/api/production/<int:run_id>/resume", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_resume_production(run_id: int):
"""Proxy: restart a stopped line, giving back the time the stop took."""
return _supervised_action(run_id, "resume")
@measure_bp.route("/api/production/<int:run_id>/close", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_close_production(run_id: int):
"""Proxy: fine produzione - closes the run and emits the statistics file."""
return _supervised_action(run_id, "close")
# ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly)
# ---------------------------------------------------------------------------
@@ -116,13 +116,24 @@
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="subtasks.length > 0">
<div class="shrink-0 flex items-center gap-1.5">
<button @click="openSupervisorModal('fermo_linea')"
{# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #}
<button x-show="!isPaused"
@click="openSupervisorModal('fermo_linea')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
<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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Fermo linea') }}
</button>
<button x-show="isPaused" x-cloak
@click="openSupervisorModal('ripresa')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-emerald-500 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40">
<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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"/>
</svg>
{{ _('Riprendi') }}
</button>
<button @click="openSupervisorModal('fine_produzione')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -428,6 +439,30 @@
</div>
</div>
{# ================================================================
LINEA FERMA — the countdown is frozen, not merely hidden. Stated plainly
because a stopped line that looks like a running one is how a measurement
interval gets silently missed.
================================================================ #}
<div x-show="isPaused"
x-transition
x-cloak
class="shrink-0 bg-amber-100 dark:bg-amber-900/40 border-t-2 border-amber-500 px-4 py-2">
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
<svg class="w-5 h-5 text-amber-700 dark:text-amber-300 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
{{ _('Linea ferma') }}
</span>
<span class="text-xs text-amber-800 dark:text-amber-200">
{{ _('Il conto alla rovescia è congelato a') }}
<span class="font-mono font-bold" x-text="timerDisplay"></span>
— {{ _('serve il capoturno per riprendere') }}
</span>
</div>
</div>
{# ================================================================
PRODUCTION ERROR — the server refused to record the production state.
Shown because the fallback keeps the operator working locally, and a timer
@@ -668,12 +703,12 @@
<div>
<label class="tmf-label text-xs">{{ _('Username') }}</label>
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
placeholder="{{ _('Username capoturno') }}" @keydown.enter="validateSupervisor()">
placeholder="{{ _('Username capoturno') }}" @keydown.enter="submitSupervisor()">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Password') }}</label>
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
placeholder="••••••••" @keydown.enter="validateSupervisor()">
placeholder="••••••••" @keydown.enter="submitSupervisor()">
</div>
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
</div>
@@ -683,7 +718,7 @@
class="btn btn-secondary flex-1 text-sm">
{{ _('Annulla') }}
</button>
<button @click="validateSupervisor()"
<button @click="submitSupervisor()"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
class="btn btn-primary flex-1 text-sm gap-1">
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -865,10 +900,17 @@ function taskExecute() {
this.cycleCount = run.cycle_count;
const seconds = run.seconds_to_next_measurement;
if (seconds === null || seconds === undefined || run.status !== 'running') {
if (seconds === null || seconds === undefined) {
this.stopMeasurementTimer();
return;
}
if (run.status !== 'running') {
// Paused: keep the figure on screen, frozen, so the banner can show how
// much of the interval is being held rather than going blank.
this.stopMeasurementTimer();
this.timerRemaining = seconds;
return;
}
// The server hands over seconds already computed rather than a timestamp: a
// naive datetime would be read in the browser's timezone and the countdown
// would be off by the UTC offset.
@@ -922,6 +964,9 @@ function taskExecute() {
serial_number: this.serialNumber,
input_method: inputMethod || 'manual',
input_duration_ms: inputDurationMs,
// Ties the value to the production under way, so fine produzione can
// emit the statistics file for the whole run.
production_run_id: this.productionRun ? this.productionRun.id : null,
}),
});
@@ -1193,14 +1238,30 @@ function taskExecute() {
this.supervisorError = '';
},
// ---- Is the production stopped? ----
get isPaused() {
return !!this.productionRun && this.productionRun.status === 'paused';
},
// ---- Supervisor reason text ----
get supervisorReason() {
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
if (this.supervisorAction === 'ripresa') return '{{ _("Ripresa della produzione") }}';
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
return '';
},
/* Route the modal's confirm to what the action actually does. Fermo linea,
ripresa and fine produzione act on the production run; the out-of-tolerance
gate stays a pure credential check. */
async submitSupervisor() {
if (this.supervisorAction === 'fermo_linea') return this.runSupervisedAction('pause');
if (this.supervisorAction === 'ripresa') return this.runSupervisedAction('resume');
if (this.supervisorAction === 'fine_produzione') return this.runSupervisedAction('close');
return this.validateSupervisor();
},
// ---- Validate supervisor credentials ----
async validateSupervisor() {
this.supervisorError = '';
@@ -1235,7 +1296,6 @@ function taskExecute() {
this.advanceToNext();
}
}
// fermo_linea and fine_produzione are handled by GAIA integration (future)
} catch (err) {
this.supervisorError = '{{ _("Errore di connessione") }}';
@@ -1243,6 +1303,42 @@ function taskExecute() {
}
},
/* Fermo linea, ripresa e fine produzione.
These used to open the modal and then do nothing. They now act on the
production run: the credentials go to the endpoint, which checks them and
records on the run's trace who authorised the stop or the closure. */
async runSupervisedAction(action) {
if (!this.productionRun) {
this.supervisorError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
this.supervisorError = '';
this.supervisorValidating = true;
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
const run = await this.postProduction(base, {
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
});
this.supervisorValidating = false;
if (!run) {
// postProduction already holds the reason; show it inside the modal so the
// capoturno sees why the action was refused, rather than a silent no-op.
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
this.productionError = '';
return;
}
this.closeSupervisorModal();
this.adoptProductionRun(run);
if (run.status === 'closed') {
this.goToSummary();
}
},
// ---- Go to summary ----
goToSummary() {
const recipeId = this.task.recipe_id || 0;
@@ -160,3 +160,88 @@ def test_production_routes_require_login(client, monkeypatch):
):
resp = getattr(client, method)(url)
assert resp.status_code in (302, 401), f"{method} {url} -> {resp.status_code}"
# ---------------------------------------------------------------------------
# Fermo linea, ripresa, fine produzione (point 6)
# ---------------------------------------------------------------------------
CREDS = {"supervisor_username": "capoturno", "supervisor_password": "segreta"}
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
def test_supervised_action_forwards_credentials(logged_in_client, monkeypatch, action):
"""Credentials go to the endpoint itself, which records who authorised what."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {**RUN, "status": "paused"}
resp = logged_in_client.post(f"/measure/api/production/7/{action}", json=CREDS)
assert resp.status_code == 200
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == f"/api/production-runs/7/{action}"
assert kwargs["data"]["supervisor_username"] == "capoturno"
assert kwargs["data"]["supervisor_password"] == "segreta"
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
def test_supervised_action_requires_credentials(logged_in_client, monkeypatch, action):
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
resp = logged_in_client.post(f"/measure/api/production/7/{action}", json={})
assert resp.status_code == 400
mock_api.post.assert_not_called()
@pytest.mark.parametrize("status_code", [401, 403])
def test_supervised_action_propagates_refusal(logged_in_client, monkeypatch, status_code):
"""A non-supervisor must not be able to stop or close a production."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": status_code, "detail": "no",
}
resp = logged_in_client.post("/measure/api/production/7/pause", json=CREDS)
assert resp.status_code == status_code
def test_close_returns_the_statistics_path(logged_in_client, monkeypatch):
"""Fine produzione emits the file, and the path comes back to the client."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
**RUN, "status": "closed",
"statistics_path": "statistics/production_7_20260728_170000.csv",
"seconds_to_next_measurement": None,
}
resp = logged_in_client.post("/measure/api/production/7/close", json=CREDS)
assert resp.status_code == 200
body = resp.get_json()
assert body["status"] == "closed"
assert body["statistics_path"].endswith(".csv")
def test_cycle_route_is_not_shadowed_by_the_supervised_routes(logged_in_client, monkeypatch):
"""/7/cycle must still reach the cycle handler, not a supervised action."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
logged_in_client.post("/measure/api/production/7/cycle", json={})
assert mock_api.post.call_args[0][0] == "/api/production-runs/7/cycle"
def test_save_measurement_attaches_the_run(logged_in_client, monkeypatch):
"""Every value taken during a production must carry its run id."""
measure_mod = _with_station(monkeypatch)
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {"id": 1, "pass_fail": "pass"}
logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 21, "version_id": 5, "value": 10.0,
"production_run_id": 7,
},
)
endpoint, kwargs = mock_api.post.call_args
# Trailing slash: without it every measurement pays a 307 redirect.
assert endpoint[0] == "/api/measurements/"
assert kwargs["data"]["production_run_id"] == 7
@@ -3,7 +3,7 @@ msgid ""
msgstr ""
"Project-Id-Version: TieMeasureFlow 1.0\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-07-28 16:15+0000\n"
"POT-Creation-Date: 2026-07-28 17:30+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
@@ -37,7 +37,7 @@ msgstr "Please log in to continue"
msgid "Inserisci username e password"
msgstr "Enter username and password"
#: blueprints/auth.py:81 blueprints/measure.py:407
#: blueprints/auth.py:81 blueprints/measure.py:412
msgid "Credenziali non valide"
msgstr "Invalid credentials"
@@ -156,20 +156,20 @@ msgstr "Recipe not found"
msgid "Dati mancanti: subtask_id, version_id e value sono obbligatori"
msgstr "Missing data: subtask_id, version_id and value are required"
#: blueprints/measure.py:383 templates/admin/stations.html:549
#: blueprints/measure.py:388 templates/admin/stations.html:549
#: templates/maker/recipe_editor.html:543
msgid "Errore nel salvataggio"
msgstr "Error saving"
#: blueprints/measure.py:402
#: blueprints/measure.py:407 blueprints/measure.py:495
msgid "Username e password richiesti"
msgstr "Username and password required"
#: blueprints/measure.py:412
#: blueprints/measure.py:417
msgid "Utente non autorizzato (richiesto capoturno)"
msgstr "User not authorized (shift supervisor required)"
#: blueprints/measure.py:431 blueprints/measure.py:448
#: blueprints/measure.py:436 blueprints/measure.py:453
#: templates/errors/station_not_configured.html:2
#: templates/errors/station_not_configured.html:16
msgid "Stazione non configurata"
@@ -376,7 +376,7 @@ msgstr "Optional notes"
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
#: templates/measure/select_recipe.html:367
#: templates/measure/task_execute.html:684
#: templates/measure/task_execute.html:719
msgid "Annulla"
msgstr "Cancel"
@@ -392,7 +392,7 @@ msgstr "Create Station"
#: templates/admin/stations.html:241 templates/admin/users.html:299
#: templates/maker/recipe_editor.html:107
#: templates/maker/recipe_editor.html:448 templates/maker/task_drawing.html:61
#: templates/measure/task_execute.html:380
#: templates/measure/task_execute.html:391
msgid "Salvataggio..."
msgstr "Saving..."
@@ -521,7 +521,7 @@ msgstr "New User"
#: templates/admin/users.html:48 templates/admin/users.html:173
#: templates/admin/users.html:179 templates/auth/login.html:35
#: templates/auth/login.html:49 templates/auth/profile.html:36
#: templates/measure/task_execute.html:669
#: templates/measure/task_execute.html:704
msgid "Username"
msgstr "Username"
@@ -573,7 +573,7 @@ msgstr "Username cannot be changed"
#: templates/admin/users.html:206 templates/admin/users.html:214
#: templates/auth/login.html:57 templates/auth/login.html:71
#: templates/measure/task_execute.html:674
#: templates/measure/task_execute.html:709
msgid "Password"
msgstr "Password"
@@ -864,7 +864,7 @@ msgstr "Preview"
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
#: templates/measure/task_complete.html:168
#: templates/measure/task_execute.html:497 templates/measure/task_list.html:2
#: templates/measure/task_execute.html:532 templates/measure/task_list.html:2
#: templates/measure/task_list.html:156
msgid "Task"
msgstr "Task"
@@ -1013,8 +1013,8 @@ msgstr "Error during deletion"
# Recipe Selection Additional
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
#: templates/measure/task_execute.html:1057
#: templates/measure/task_execute.html:1241
#: templates/measure/task_execute.html:1102
#: templates/measure/task_execute.html:1301
msgid "Errore di connessione"
msgstr "Connection Error"
@@ -1144,7 +1144,7 @@ msgid "Apri PDF"
msgstr "Open PDF"
#: templates/maker/recipe_preview.html:254
#: templates/measure/task_execute.html:255
#: templates/measure/task_execute.html:266
msgid "Nessuna immagine allegata"
msgstr "No image attached"
@@ -1155,7 +1155,7 @@ msgstr "Measurement Points"
#: templates/maker/recipe_preview.html:299 templates/maker/task_editor.html:544
#: templates/maker/task_editor.html:686
#: templates/measure/task_complete.html:170
#: templates/measure/task_execute.html:289
#: templates/measure/task_execute.html:300
msgid "Nominale"
msgstr "Nominal"
@@ -1612,7 +1612,7 @@ msgid "Misurazione aggiunta"
msgstr "Measurement added"
#: templates/maker/task_editor.html:1645
#: templates/measure/task_execute.html:931
#: templates/measure/task_execute.html:976
msgid "Errore nel salvataggio della misurazione"
msgstr "Error saving measurement"
@@ -1770,13 +1770,13 @@ msgstr "Search"
#: templates/measure/task_complete.html:3
#: templates/measure/task_complete.html:36
#: templates/measure/task_execute.html:112
#: templates/measure/task_execute.html:605
#: templates/measure/task_execute.html:640
#: templates/statistics/dashboard.html:139
msgid "Riepilogo"
msgstr "Summary"
#: templates/measure/task_complete.html:44
#: templates/measure/task_execute.html:582
#: templates/measure/task_execute.html:617
msgid "Misurazioni Complete"
msgstr "Measurements Complete"
@@ -1797,12 +1797,12 @@ msgid "Totale"
msgstr "Total"
#: templates/measure/task_complete.html:103
#: templates/measure/task_execute.html:590
#: templates/measure/task_execute.html:625
msgid "Conformi"
msgstr "Pass"
#: templates/measure/task_complete.html:120
#: templates/measure/task_execute.html:594
#: templates/measure/task_execute.html:629
msgid "Attenzione"
msgstr "Warning"
@@ -1970,120 +1970,148 @@ msgstr "Measurement task"
msgid "Lista task"
msgstr "Task list"
#: templates/measure/task_execute.html:124
#: templates/measure/task_execute.html:127
msgid "Fermo linea"
msgstr "Line stop"
#: templates/measure/task_execute.html:132
#: templates/measure/task_execute.html:135
msgid "Riprendi"
msgstr "Resume"
#: templates/measure/task_execute.html:143
msgid "Fine Produzione"
msgstr "End Production"
#: templates/measure/task_execute.html:227
#: templates/measure/task_execute.html:238
msgid "Immagine dettaglio misura"
msgstr "Measurement detail image"
#: templates/measure/task_execute.html:275
#: templates/measure/task_execute.html:286
msgid "Misurazione"
msgstr "Measurement"
#: templates/measure/task_execute.html:277
#: templates/measure/task_execute.html:288
msgid "Misura"
msgstr "Measure"
#: templates/measure/task_execute.html:353
#: templates/measure/task_execute.html:364
msgid "Registrata"
msgstr "Recorded"
#: templates/measure/task_execute.html:421
#: templates/measure/task_execute.html:432
msgid "Prossima misurazione tra"
msgstr "Next measurement in"
#: templates/measure/task_execute.html:426
#: templates/measure/task_execute.html:437
msgid "Ciclo"
msgstr "Cycle"
#: templates/measure/task_execute.html:445
#: templates/measure/task_execute.html:456
msgid "Linea ferma"
msgstr "Line stopped"
#: templates/measure/task_execute.html:459
msgid "Il conto alla rovescia è congelato a"
msgstr "The countdown is frozen at"
#: templates/measure/task_execute.html:461
msgid "serve il capoturno per riprendere"
msgstr "the supervisor must authorise the restart"
#: templates/measure/task_execute.html:480
msgid "Produzione non registrata sul server"
msgstr "Production not recorded on the server"
#: templates/measure/task_execute.html:466
#: templates/measure/task_execute.html:632
#: templates/measure/task_execute.html:501
#: templates/measure/task_execute.html:667
msgid "Avvio Produzione"
msgstr "Production Start"
#: templates/measure/task_execute.html:470
#: templates/measure/task_execute.html:505
msgid "Invia segnale al gestionale per avviare il timer della linea"
msgstr "Send a signal to the ERP system to start the line timer"
#: templates/measure/task_execute.html:481
#: templates/measure/task_execute.html:516
msgid "Produzione avviata"
msgstr "Production started"
#: templates/measure/task_execute.html:526
#: templates/measure/task_execute.html:561
msgid "Fine ciclo misura"
msgstr "Measurement cycle complete"
#: templates/measure/task_execute.html:536
#: templates/measure/task_execute.html:550
#: templates/measure/task_execute.html:571
#: templates/measure/task_execute.html:585
msgid "Completato"
msgstr "Completed"
#: templates/measure/task_execute.html:584
#: templates/measure/task_execute.html:619
msgid "Tutte le"
msgstr "All"
#: templates/measure/task_execute.html:584
#: templates/measure/task_execute.html:619
msgid "misurazioni sono state registrate."
msgstr "measurements have been recorded."
#: templates/measure/task_execute.html:598
#: templates/measure/task_execute.html:633
msgid "Non Conf."
msgstr "Fail"
#: templates/measure/task_execute.html:612
#: templates/measure/task_execute.html:647
msgid "Conferma ciclo"
msgstr "Confirm cycle"
#: templates/measure/task_execute.html:622
#: templates/measure/task_execute.html:657
msgid "Task successivo"
msgstr "Next task"
#: templates/measure/task_execute.html:662
#: templates/measure/task_execute.html:697
msgid "Autorizzazione capoturno"
msgstr "Shift supervisor authorization"
#: templates/measure/task_execute.html:671
#: templates/measure/task_execute.html:706
msgid "Username capoturno"
msgstr "Supervisor username"
#: templates/measure/task_execute.html:693
#: templates/measure/task_execute.html:728
msgid "Autorizza"
msgstr "Authorize"
#: templates/measure/task_execute.html:975
#: templates/measure/task_execute.html:1020
msgid "Errore di rete. Riprovare."
msgstr "Network error. Please retry."
#: templates/measure/task_execute.html:1052
#: templates/measure/task_execute.html:1097
msgid "Errore di comunicazione con il server"
msgstr "Error communicating with the server"
#: templates/measure/task_execute.html:1198
#: templates/measure/task_execute.html:1248
msgid "Misurazione fuori tolleranza"
msgstr "Measurement out of tolerance"
#: templates/measure/task_execute.html:1199
#: templates/measure/task_execute.html:1249
msgid "Fermo linea richiesto"
msgstr "Line stop requested"
#: templates/measure/task_execute.html:1200
#: templates/measure/task_execute.html:1250
msgid "Ripresa della produzione"
msgstr "Resuming production"
#: templates/measure/task_execute.html:1251
msgid "Fine produzione richiesta"
msgstr "End of production requested"
#: templates/measure/task_execute.html:1219
#: templates/measure/task_execute.html:1280
msgid "Credenziali non valide o utente non autorizzato"
msgstr "Invalid credentials or unauthorized user"
#: templates/measure/task_execute.html:1312
msgid "Nessuna produzione aperta su questa stazione"
msgstr "No production open at this station"
#: templates/measure/task_execute.html:1329
msgid "Azione non riuscita"
msgstr "Action failed"
#: templates/measure/task_list.html:84
msgid "AVVIA"
msgstr "START"
@@ -3,7 +3,7 @@ msgid ""
msgstr ""
"Project-Id-Version: TieMeasureFlow 1.0\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-07-28 16:15+0000\n"
"POT-Creation-Date: 2026-07-28 17:30+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: it\n"
@@ -37,7 +37,7 @@ msgstr "Effettua il login per continuare"
msgid "Inserisci username e password"
msgstr "Inserisci username e password"
#: blueprints/auth.py:81 blueprints/measure.py:407
#: blueprints/auth.py:81 blueprints/measure.py:412
msgid "Credenziali non valide"
msgstr "Credenziali non valide"
@@ -156,20 +156,20 @@ msgstr "Ricetta non trovata"
msgid "Dati mancanti: subtask_id, version_id e value sono obbligatori"
msgstr "Dati mancanti: subtask_id, version_id e value sono obbligatori"
#: blueprints/measure.py:383 templates/admin/stations.html:549
#: blueprints/measure.py:388 templates/admin/stations.html:549
#: templates/maker/recipe_editor.html:543
msgid "Errore nel salvataggio"
msgstr "Errore nel salvataggio"
#: blueprints/measure.py:402
#: blueprints/measure.py:407 blueprints/measure.py:495
msgid "Username e password richiesti"
msgstr "Username e password richiesti"
#: blueprints/measure.py:412
#: blueprints/measure.py:417
msgid "Utente non autorizzato (richiesto capoturno)"
msgstr "Utente non autorizzato (richiesto capoturno)"
#: blueprints/measure.py:431 blueprints/measure.py:448
#: blueprints/measure.py:436 blueprints/measure.py:453
#: templates/errors/station_not_configured.html:2
#: templates/errors/station_not_configured.html:16
msgid "Stazione non configurata"
@@ -378,7 +378,7 @@ msgstr "Note opzionali"
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
#: templates/measure/select_recipe.html:367
#: templates/measure/task_execute.html:684
#: templates/measure/task_execute.html:719
msgid "Annulla"
msgstr "Annulla"
@@ -394,7 +394,7 @@ msgstr "Crea Stazione"
#: templates/admin/stations.html:241 templates/admin/users.html:299
#: templates/maker/recipe_editor.html:107
#: templates/maker/recipe_editor.html:448 templates/maker/task_drawing.html:61
#: templates/measure/task_execute.html:380
#: templates/measure/task_execute.html:391
msgid "Salvataggio..."
msgstr "Salvataggio..."
@@ -523,7 +523,7 @@ msgstr "Nuovo Utente"
#: templates/admin/users.html:48 templates/admin/users.html:173
#: templates/admin/users.html:179 templates/auth/login.html:35
#: templates/auth/login.html:49 templates/auth/profile.html:36
#: templates/measure/task_execute.html:669
#: templates/measure/task_execute.html:704
msgid "Username"
msgstr "Username"
@@ -575,7 +575,7 @@ msgstr "Il nome utente non può essere modificato"
#: templates/admin/users.html:206 templates/admin/users.html:214
#: templates/auth/login.html:57 templates/auth/login.html:71
#: templates/measure/task_execute.html:674
#: templates/measure/task_execute.html:709
msgid "Password"
msgstr "Password"
@@ -866,7 +866,7 @@ msgstr "Anteprima"
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
#: templates/measure/task_complete.html:168
#: templates/measure/task_execute.html:497 templates/measure/task_list.html:2
#: templates/measure/task_execute.html:532 templates/measure/task_list.html:2
#: templates/measure/task_list.html:156
msgid "Task"
msgstr "Task"
@@ -1015,8 +1015,8 @@ msgstr "Errore durante eliminazione"
# Recipe Selection Additional
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
#: templates/measure/task_execute.html:1057
#: templates/measure/task_execute.html:1241
#: templates/measure/task_execute.html:1102
#: templates/measure/task_execute.html:1301
msgid "Errore di connessione"
msgstr "Errore di connessione"
@@ -1146,7 +1146,7 @@ msgid "Apri PDF"
msgstr "Apri PDF"
#: templates/maker/recipe_preview.html:254
#: templates/measure/task_execute.html:255
#: templates/measure/task_execute.html:266
msgid "Nessuna immagine allegata"
msgstr "Nessuna immagine allegata"
@@ -1157,7 +1157,7 @@ msgstr "Punti di Misura"
#: templates/maker/recipe_preview.html:299 templates/maker/task_editor.html:544
#: templates/maker/task_editor.html:686
#: templates/measure/task_complete.html:170
#: templates/measure/task_execute.html:289
#: templates/measure/task_execute.html:300
msgid "Nominale"
msgstr "Nominale"
@@ -1614,7 +1614,7 @@ msgid "Misurazione aggiunta"
msgstr "Misurazione aggiunta"
#: templates/maker/task_editor.html:1645
#: templates/measure/task_execute.html:931
#: templates/measure/task_execute.html:976
msgid "Errore nel salvataggio della misurazione"
msgstr "Errore nel salvataggio della misurazione"
@@ -1774,13 +1774,13 @@ msgstr "Cerca"
#: templates/measure/task_complete.html:3
#: templates/measure/task_complete.html:36
#: templates/measure/task_execute.html:112
#: templates/measure/task_execute.html:605
#: templates/measure/task_execute.html:640
#: templates/statistics/dashboard.html:139
msgid "Riepilogo"
msgstr "Riepilogo"
#: templates/measure/task_complete.html:44
#: templates/measure/task_execute.html:582
#: templates/measure/task_execute.html:617
msgid "Misurazioni Complete"
msgstr "Misurazioni Complete"
@@ -1801,12 +1801,12 @@ msgid "Totale"
msgstr "Totale"
#: templates/measure/task_complete.html:103
#: templates/measure/task_execute.html:590
#: templates/measure/task_execute.html:625
msgid "Conformi"
msgstr "Conformi"
#: templates/measure/task_complete.html:120
#: templates/measure/task_execute.html:594
#: templates/measure/task_execute.html:629
msgid "Attenzione"
msgstr "Attenzione"
@@ -1974,120 +1974,148 @@ msgstr "Task di misurazione"
msgid "Lista task"
msgstr "Lista task"
#: templates/measure/task_execute.html:124
#: templates/measure/task_execute.html:127
msgid "Fermo linea"
msgstr "Fermo linea"
#: templates/measure/task_execute.html:132
#: templates/measure/task_execute.html:135
msgid "Riprendi"
msgstr "Riprendi"
#: templates/measure/task_execute.html:143
msgid "Fine Produzione"
msgstr "Fine Produzione"
#: templates/measure/task_execute.html:227
#: templates/measure/task_execute.html:238
msgid "Immagine dettaglio misura"
msgstr "Immagine dettaglio misura"
#: templates/measure/task_execute.html:275
#: templates/measure/task_execute.html:286
msgid "Misurazione"
msgstr "Misurazione"
#: templates/measure/task_execute.html:277
#: templates/measure/task_execute.html:288
msgid "Misura"
msgstr "Misura"
#: templates/measure/task_execute.html:353
#: templates/measure/task_execute.html:364
msgid "Registrata"
msgstr "Registrata"
#: templates/measure/task_execute.html:421
#: templates/measure/task_execute.html:432
msgid "Prossima misurazione tra"
msgstr "Prossima misurazione tra"
#: templates/measure/task_execute.html:426
#: templates/measure/task_execute.html:437
msgid "Ciclo"
msgstr "Ciclo"
#: templates/measure/task_execute.html:445
#: templates/measure/task_execute.html:456
msgid "Linea ferma"
msgstr "Linea ferma"
#: templates/measure/task_execute.html:459
msgid "Il conto alla rovescia è congelato a"
msgstr "Il conto alla rovescia è congelato a"
#: templates/measure/task_execute.html:461
msgid "serve il capoturno per riprendere"
msgstr "serve il capoturno per riprendere"
#: templates/measure/task_execute.html:480
msgid "Produzione non registrata sul server"
msgstr "Produzione non registrata sul server"
#: templates/measure/task_execute.html:466
#: templates/measure/task_execute.html:632
#: templates/measure/task_execute.html:501
#: templates/measure/task_execute.html:667
msgid "Avvio Produzione"
msgstr "Avvio Produzione"
#: templates/measure/task_execute.html:470
#: templates/measure/task_execute.html:505
msgid "Invia segnale al gestionale per avviare il timer della linea"
msgstr "Invia segnale al gestionale per avviare il timer della linea"
#: templates/measure/task_execute.html:481
#: templates/measure/task_execute.html:516
msgid "Produzione avviata"
msgstr "Produzione avviata"
#: templates/measure/task_execute.html:526
#: templates/measure/task_execute.html:561
msgid "Fine ciclo misura"
msgstr "Fine ciclo misura"
#: templates/measure/task_execute.html:536
#: templates/measure/task_execute.html:550
#: templates/measure/task_execute.html:571
#: templates/measure/task_execute.html:585
msgid "Completato"
msgstr "Completato"
#: templates/measure/task_execute.html:584
#: templates/measure/task_execute.html:619
msgid "Tutte le"
msgstr "Tutte le"
#: templates/measure/task_execute.html:584
#: templates/measure/task_execute.html:619
msgid "misurazioni sono state registrate."
msgstr "misurazioni sono state registrate."
#: templates/measure/task_execute.html:598
#: templates/measure/task_execute.html:633
msgid "Non Conf."
msgstr "Non Conf."
#: templates/measure/task_execute.html:612
#: templates/measure/task_execute.html:647
msgid "Conferma ciclo"
msgstr "Conferma ciclo"
#: templates/measure/task_execute.html:622
#: templates/measure/task_execute.html:657
msgid "Task successivo"
msgstr "Task successivo"
#: templates/measure/task_execute.html:662
#: templates/measure/task_execute.html:697
msgid "Autorizzazione capoturno"
msgstr "Autorizzazione capoturno"
#: templates/measure/task_execute.html:671
#: templates/measure/task_execute.html:706
msgid "Username capoturno"
msgstr "Username capoturno"
#: templates/measure/task_execute.html:693
#: templates/measure/task_execute.html:728
msgid "Autorizza"
msgstr "Autorizza"
#: templates/measure/task_execute.html:975
#: templates/measure/task_execute.html:1020
msgid "Errore di rete. Riprovare."
msgstr "Errore di rete. Riprovare."
#: templates/measure/task_execute.html:1052
#: templates/measure/task_execute.html:1097
msgid "Errore di comunicazione con il server"
msgstr "Errore di comunicazione con il server"
#: templates/measure/task_execute.html:1198
#: templates/measure/task_execute.html:1248
msgid "Misurazione fuori tolleranza"
msgstr "Misurazione fuori tolleranza"
#: templates/measure/task_execute.html:1199
#: templates/measure/task_execute.html:1249
msgid "Fermo linea richiesto"
msgstr "Fermo linea richiesto"
#: templates/measure/task_execute.html:1200
#: templates/measure/task_execute.html:1250
msgid "Ripresa della produzione"
msgstr "Ripresa della produzione"
#: templates/measure/task_execute.html:1251
msgid "Fine produzione richiesta"
msgstr "Fine produzione richiesta"
#: templates/measure/task_execute.html:1219
#: templates/measure/task_execute.html:1280
msgid "Credenziali non valide o utente non autorizzato"
msgstr "Credenziali non valide o utente non autorizzato"
#: templates/measure/task_execute.html:1312
msgid "Nessuna produzione aperta su questa stazione"
msgstr "Nessuna produzione aperta su questa stazione"
#: templates/measure/task_execute.html:1329
msgid "Azione non riuscita"
msgstr "Azione non riuscita"
#: templates/measure/task_list.html:84
msgid "AVVIA"
msgstr "AVVIA"
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-07-28 16:15+0000\n"
"POT-Creation-Date: 2026-07-28 17:30+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -39,7 +39,7 @@ msgstr ""
msgid "Inserisci username e password"
msgstr ""
#: blueprints/auth.py:81 blueprints/measure.py:407
#: blueprints/auth.py:81 blueprints/measure.py:412
msgid "Credenziali non valide"
msgstr ""
@@ -153,20 +153,20 @@ msgstr ""
msgid "Dati mancanti: subtask_id, version_id e value sono obbligatori"
msgstr ""
#: blueprints/measure.py:383 templates/admin/stations.html:549
#: blueprints/measure.py:388 templates/admin/stations.html:549
#: templates/maker/recipe_editor.html:543
msgid "Errore nel salvataggio"
msgstr ""
#: blueprints/measure.py:402
#: blueprints/measure.py:407 blueprints/measure.py:495
msgid "Username e password richiesti"
msgstr ""
#: blueprints/measure.py:412
#: blueprints/measure.py:417
msgid "Utente non autorizzato (richiesto capoturno)"
msgstr ""
#: blueprints/measure.py:431 blueprints/measure.py:448
#: blueprints/measure.py:436 blueprints/measure.py:453
#: templates/errors/station_not_configured.html:2
#: templates/errors/station_not_configured.html:16
msgid "Stazione non configurata"
@@ -369,7 +369,7 @@ msgstr ""
#: templates/maker/task_editor.html:747 templates/maker/task_editor.html:866
#: templates/maker/task_editor.html:931 templates/maker/task_editor.html:1036
#: templates/measure/select_recipe.html:367
#: templates/measure/task_execute.html:684
#: templates/measure/task_execute.html:719
msgid "Annulla"
msgstr ""
@@ -385,7 +385,7 @@ msgstr ""
#: templates/admin/stations.html:241 templates/admin/users.html:299
#: templates/maker/recipe_editor.html:107
#: templates/maker/recipe_editor.html:448 templates/maker/task_drawing.html:61
#: templates/measure/task_execute.html:380
#: templates/measure/task_execute.html:391
msgid "Salvataggio..."
msgstr ""
@@ -513,7 +513,7 @@ msgstr ""
#: templates/admin/users.html:48 templates/admin/users.html:173
#: templates/admin/users.html:179 templates/auth/login.html:35
#: templates/auth/login.html:49 templates/auth/profile.html:36
#: templates/measure/task_execute.html:669
#: templates/measure/task_execute.html:704
msgid "Username"
msgstr ""
@@ -565,7 +565,7 @@ msgstr ""
#: templates/admin/users.html:206 templates/admin/users.html:214
#: templates/auth/login.html:57 templates/auth/login.html:71
#: templates/measure/task_execute.html:674
#: templates/measure/task_execute.html:709
msgid "Password"
msgstr ""
@@ -843,7 +843,7 @@ msgstr ""
#: templates/maker/recipe_editor.html:128 templates/maker/recipe_list.html:246
#: templates/maker/task_drawing.html:106 templates/maker/task_editor.html:130
#: templates/measure/task_complete.html:168
#: templates/measure/task_execute.html:497 templates/measure/task_list.html:2
#: templates/measure/task_execute.html:532 templates/measure/task_list.html:2
#: templates/measure/task_list.html:156
msgid "Task"
msgstr ""
@@ -990,8 +990,8 @@ msgid "Errore durante eliminazione"
msgstr ""
#: templates/maker/recipe_list.html:55 templates/measure/select_recipe.html:55
#: templates/measure/task_execute.html:1057
#: templates/measure/task_execute.html:1241
#: templates/measure/task_execute.html:1102
#: templates/measure/task_execute.html:1301
msgid "Errore di connessione"
msgstr ""
@@ -1118,7 +1118,7 @@ msgid "Apri PDF"
msgstr ""
#: templates/maker/recipe_preview.html:254
#: templates/measure/task_execute.html:255
#: templates/measure/task_execute.html:266
msgid "Nessuna immagine allegata"
msgstr ""
@@ -1129,7 +1129,7 @@ msgstr ""
#: templates/maker/recipe_preview.html:299 templates/maker/task_editor.html:544
#: templates/maker/task_editor.html:686
#: templates/measure/task_complete.html:170
#: templates/measure/task_execute.html:289
#: templates/measure/task_execute.html:300
msgid "Nominale"
msgstr ""
@@ -1582,7 +1582,7 @@ msgid "Misurazione aggiunta"
msgstr ""
#: templates/maker/task_editor.html:1645
#: templates/measure/task_execute.html:931
#: templates/measure/task_execute.html:976
msgid "Errore nel salvataggio della misurazione"
msgstr ""
@@ -1736,13 +1736,13 @@ msgstr ""
#: templates/measure/task_complete.html:3
#: templates/measure/task_complete.html:36
#: templates/measure/task_execute.html:112
#: templates/measure/task_execute.html:605
#: templates/measure/task_execute.html:640
#: templates/statistics/dashboard.html:139
msgid "Riepilogo"
msgstr ""
#: templates/measure/task_complete.html:44
#: templates/measure/task_execute.html:582
#: templates/measure/task_execute.html:617
msgid "Misurazioni Complete"
msgstr ""
@@ -1762,12 +1762,12 @@ msgid "Totale"
msgstr ""
#: templates/measure/task_complete.html:103
#: templates/measure/task_execute.html:590
#: templates/measure/task_execute.html:625
msgid "Conformi"
msgstr ""
#: templates/measure/task_complete.html:120
#: templates/measure/task_execute.html:594
#: templates/measure/task_execute.html:629
msgid "Attenzione"
msgstr ""
@@ -1933,120 +1933,148 @@ msgstr ""
msgid "Lista task"
msgstr ""
#: templates/measure/task_execute.html:124
#: templates/measure/task_execute.html:127
msgid "Fermo linea"
msgstr ""
#: templates/measure/task_execute.html:132
#: templates/measure/task_execute.html:135
msgid "Riprendi"
msgstr ""
#: templates/measure/task_execute.html:143
msgid "Fine Produzione"
msgstr ""
#: templates/measure/task_execute.html:227
#: templates/measure/task_execute.html:238
msgid "Immagine dettaglio misura"
msgstr ""
#: templates/measure/task_execute.html:275
#: templates/measure/task_execute.html:286
msgid "Misurazione"
msgstr ""
#: templates/measure/task_execute.html:277
#: templates/measure/task_execute.html:288
msgid "Misura"
msgstr ""
#: templates/measure/task_execute.html:353
#: templates/measure/task_execute.html:364
msgid "Registrata"
msgstr ""
#: templates/measure/task_execute.html:421
#: templates/measure/task_execute.html:432
msgid "Prossima misurazione tra"
msgstr ""
#: templates/measure/task_execute.html:426
#: templates/measure/task_execute.html:437
msgid "Ciclo"
msgstr ""
#: templates/measure/task_execute.html:445
#: templates/measure/task_execute.html:456
msgid "Linea ferma"
msgstr ""
#: templates/measure/task_execute.html:459
msgid "Il conto alla rovescia è congelato a"
msgstr ""
#: templates/measure/task_execute.html:461
msgid "serve il capoturno per riprendere"
msgstr ""
#: templates/measure/task_execute.html:480
msgid "Produzione non registrata sul server"
msgstr ""
#: templates/measure/task_execute.html:466
#: templates/measure/task_execute.html:632
#: templates/measure/task_execute.html:501
#: templates/measure/task_execute.html:667
msgid "Avvio Produzione"
msgstr ""
#: templates/measure/task_execute.html:470
#: templates/measure/task_execute.html:505
msgid "Invia segnale al gestionale per avviare il timer della linea"
msgstr ""
#: templates/measure/task_execute.html:481
#: templates/measure/task_execute.html:516
msgid "Produzione avviata"
msgstr ""
#: templates/measure/task_execute.html:526
#: templates/measure/task_execute.html:561
msgid "Fine ciclo misura"
msgstr ""
#: templates/measure/task_execute.html:536
#: templates/measure/task_execute.html:550
#: templates/measure/task_execute.html:571
#: templates/measure/task_execute.html:585
msgid "Completato"
msgstr ""
#: templates/measure/task_execute.html:584
#: templates/measure/task_execute.html:619
msgid "Tutte le"
msgstr ""
#: templates/measure/task_execute.html:584
#: templates/measure/task_execute.html:619
msgid "misurazioni sono state registrate."
msgstr ""
#: templates/measure/task_execute.html:598
#: templates/measure/task_execute.html:633
msgid "Non Conf."
msgstr ""
#: templates/measure/task_execute.html:612
#: templates/measure/task_execute.html:647
msgid "Conferma ciclo"
msgstr ""
#: templates/measure/task_execute.html:622
#: templates/measure/task_execute.html:657
msgid "Task successivo"
msgstr ""
#: templates/measure/task_execute.html:662
#: templates/measure/task_execute.html:697
msgid "Autorizzazione capoturno"
msgstr ""
#: templates/measure/task_execute.html:671
#: templates/measure/task_execute.html:706
msgid "Username capoturno"
msgstr ""
#: templates/measure/task_execute.html:693
#: templates/measure/task_execute.html:728
msgid "Autorizza"
msgstr ""
#: templates/measure/task_execute.html:975
#: templates/measure/task_execute.html:1020
msgid "Errore di rete. Riprovare."
msgstr ""
#: templates/measure/task_execute.html:1052
#: templates/measure/task_execute.html:1097
msgid "Errore di comunicazione con il server"
msgstr ""
#: templates/measure/task_execute.html:1198
#: templates/measure/task_execute.html:1248
msgid "Misurazione fuori tolleranza"
msgstr ""
#: templates/measure/task_execute.html:1199
#: templates/measure/task_execute.html:1249
msgid "Fermo linea richiesto"
msgstr ""
#: templates/measure/task_execute.html:1200
#: templates/measure/task_execute.html:1250
msgid "Ripresa della produzione"
msgstr ""
#: templates/measure/task_execute.html:1251
msgid "Fine produzione richiesta"
msgstr ""
#: templates/measure/task_execute.html:1219
#: templates/measure/task_execute.html:1280
msgid "Credenziali non valide o utente non autorizzato"
msgstr ""
#: templates/measure/task_execute.html:1312
msgid "Nessuna produzione aperta su questa stazione"
msgstr ""
#: templates/measure/task_execute.html:1329
msgid "Azione non riuscita"
msgstr ""
#: templates/measure/task_list.html:84
msgid "AVVIA"
msgstr ""