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) #}