From 7bc3c1f9380aad560a0f6daaa3e61bf25af91d20 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 28 Jul 2026 17:32:43 +0000 Subject: [PATCH] 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) --- src/backend/api/routers/measurements.py | 2 + .../006_link_measurements_to_production.py | 63 +++++++ src/backend/models/api/measurement.py | 3 + src/backend/models/api/production.py | 1 + src/backend/models/orm/measurement.py | 11 ++ src/backend/models/orm/production.py | 2 + src/backend/services/measurement_service.py | 2 + .../services/production_export_service.py | 161 ++++++++++++++++++ src/backend/services/production_service.py | 19 ++- src/backend/tests/test_production_runs.py | 148 ++++++++++++++++ src/frontend/flask_app/blueprints/measure.py | 59 ++++++- .../templates/measure/task_execute.html | 108 +++++++++++- .../tests/test_measure_production.py | 85 +++++++++ .../translations/en/LC_MESSAGES/messages.po | 128 ++++++++------ .../translations/it/LC_MESSAGES/messages.po | 128 ++++++++------ .../flask_app/translations/messages.pot | 128 ++++++++------ 16 files changed, 889 insertions(+), 159 deletions(-) create mode 100644 src/backend/migrations/versions/006_link_measurements_to_production.py create mode 100644 src/backend/services/production_export_service.py diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 5e5de1a..66534a1 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -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] diff --git a/src/backend/migrations/versions/006_link_measurements_to_production.py b/src/backend/migrations/versions/006_link_measurements_to_production.py new file mode 100644 index 0000000..459f477 --- /dev/null +++ b/src/backend/migrations/versions/006_link_measurements_to_production.py @@ -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') diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index 873cc51..240e4e0 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -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 diff --git a/src/backend/models/api/production.py b/src/backend/models/api/production.py index cb67ffa..c911539 100644 --- a/src/backend/models/api/production.py +++ b/src/backend/models/api/production.py @@ -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 diff --git a/src/backend/models/orm/measurement.py b/src/backend/models/orm/measurement.py index 54757b9..2bf1187 100644 --- a/src/backend/models/orm/measurement.py +++ b/src/backend/models/orm/measurement.py @@ -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 diff --git a/src/backend/models/orm/production.py b/src/backend/models/orm/production.py index 0440af4..23b679c 100644 --- a/src/backend/models/orm/production.py +++ b/src/backend/models/orm/production.py @@ -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 diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index e4aef6e..e63f968 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -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() diff --git a/src/backend/services/production_export_service.py b/src/backend/services/production_export_service.py new file mode 100644 index 0000000..9c37c9c --- /dev/null +++ b/src/backend/services/production_export_service.py @@ -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}" diff --git a/src/backend/services/production_service.py b/src/backend/services/production_service.py index 7845542..f504c3a 100644 --- a/src/backend/services/production_service.py +++ b/src/backend/services/production_service.py @@ -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 diff --git a/src/backend/tests/test_production_runs.py b/src/backend/tests/test_production_runs.py index 1d41705..de8c6a4 100644 --- a/src/backend/tests/test_production_runs.py +++ b/src/backend/tests/test_production_runs.py @@ -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() diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index e096e72..ef1b672 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -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//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//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//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) # --------------------------------------------------------------------------- diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index d17140a..4d59d22 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -116,13 +116,24 @@ {# Fermo linea + Fine produzione (measurement tasks only) #}