From 1d93eded27f2b8c31f4197ea45c9e2d478bd24dc Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Tue, 6 Oct 2026 11:31:57 +0200 Subject: [PATCH] =?UTF-8?q?Ripristino=20delle=20quote=20gi=C3=A0=20misurat?= =?UTF-8?q?e=20nel=20ciclo=20in=20corso=20dopo=20un=20reload=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/measurements.py | 19 ++ src/backend/models/api/measurement.py | 15 ++ src/backend/services/measurement_service.py | 68 +++++++ src/backend/tests/test_current_cycle.py | 172 ++++++++++++++++++ src/frontend/flask_app/blueprints/measure.py | 34 ++++ .../templates/measure/task_execute.html | 37 ++++ src/frontend/flask_app/tests/test_measure.py | 61 +++++++ 7 files changed, 406 insertions(+) create mode 100644 src/backend/tests/test_current_cycle.py diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 38e1342..5a6432f 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -19,6 +19,7 @@ from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.setting import SystemSetting from src.backend.models.orm.user import User from src.backend.models.api.measurement import ( + CurrentCycleResponse, MeasurementAuthorisation, MeasurementBatchCreate, MeasurementCreate, @@ -97,6 +98,24 @@ async def get_task_progress( return TaskProgressListResponse(tasks=tasks) +@router.get("/current-cycle", response_model=CurrentCycleResponse) +async def get_current_cycle( + production_run_id: int = Query(..., gt=0), + task_id: int = Query(..., gt=0), + user: User = Depends(require_measurement_tec), + db: AsyncSession = Depends(get_db), +): + """Quotes of this task already measured in the cycle under way. + + Asked on load, so that a reload mid-cycle does not present quotes again that + were taken a moment ago. + """ + rows = await measurement_service.current_cycle_measurements( + db, production_run_id, task_id, + ) + return CurrentCycleResponse(measurements=rows) + + @router.post("/{measurement_id}/authorise", response_model=MeasurementResponse) async def authorise_measurement( measurement_id: int, diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index 0f8a5f9..f578463 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -91,6 +91,21 @@ class TaskProgressListResponse(BaseModel): tasks: list[TaskProgressResponse] +class CycleMeasurementResponse(BaseModel): + """One quote already measured in the cycle under way.""" + + subtask_id: int + value: float + pass_fail: str + measured_at: datetime + + +class CurrentCycleResponse(BaseModel): + """The quotes to restore on the screen after a reload; empty if the task is closed.""" + + measurements: list[CycleMeasurementResponse] + + class MeasurementQuery(BaseModel): """Schema for measurement query filters.""" recipe_id: Optional[int] = None diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index c31fbe9..f94db53 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -9,6 +9,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.production import ProductionEvent from src.backend.models.orm.recipe import Recipe, RecipeVersion from src.backend.models.orm.task import ( MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask, @@ -241,6 +242,73 @@ async def task_progress( return progress +async def current_cycle_measurements( + db: AsyncSession, run_id: int, task_id: int, +) -> list[dict]: + """The quotes of a task already measured in the cycle that is under way. + + A reload, a timer redirect or an auto-logout throws away what the page held in + memory; the operator would measure the same quotes again and the duplicates + would reach the SPC statistics. The cycle needs no column of its own: it starts + at the latest event of the run that opens one for this task - the run start, a + cycle_completed, or a remeasure of this task. + + If a task_measured of this task follows that boundary the task is already + closed in this cycle and there is nothing to restore. Where a quote was measured + more than once, the last reading stands. + """ + boundary = (await db.execute( + select(ProductionEvent.id, ProductionEvent.created_at) + .where( + ProductionEvent.run_id == run_id, + (ProductionEvent.event_type.in_(("start", "cycle_completed"))) + | ( + (ProductionEvent.event_type == "remeasure") + & (ProductionEvent.task_id == task_id) + ), + ) + .order_by(ProductionEvent.id.desc()) + .limit(1) + )).first() + + event_filter = [ProductionEvent.run_id == run_id] + measurement_filter = [Measurement.production_run_id == run_id] + if boundary is not None: + boundary_id, boundary_at = boundary + event_filter.append(ProductionEvent.id > boundary_id) + measurement_filter.append(Measurement.measured_at > boundary_at) + + closed = (await db.execute( + select(ProductionEvent.id).where( + *event_filter, + ProductionEvent.event_type == "task_measured", + ProductionEvent.task_id == task_id, + ).limit(1) + )).first() + if closed is not None: + return [] + + rows = (await db.execute( + select(Measurement) + .join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id) + .where(*measurement_filter, RecipeSubtask.task_id == task_id) + .order_by(Measurement.measured_at, Measurement.id) + )).scalars().all() + + latest: dict[int, Measurement] = {} + for m in rows: + latest[m.subtask_id] = m + return [ + { + "subtask_id": m.subtask_id, + "value": float(m.value), + "pass_fail": m.pass_fail, + "measured_at": m.measured_at, + } + for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id)) + ] + + async def validate_run_for_version( db: AsyncSession, run_id: int, version_id: int, ) -> "ProductionRun": diff --git a/src/backend/tests/test_current_cycle.py b/src/backend/tests/test_current_cycle.py new file mode 100644 index 0000000..814eec3 --- /dev/null +++ b/src/backend/tests/test_current_cycle.py @@ -0,0 +1,172 @@ +"""#40: after a reload, the quotes already measured in the cycle come back. + +The cycle has no column of its own: it opens at the latest start, cycle_completed +or remeasure-of-this-task event of the run, and a task_measured of the task after +that boundary means the task is closed in this cycle. +""" +from datetime import datetime, timedelta + +from httpx import AsyncClient +from sqlalchemy import select + +from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.production import ProductionEvent, ProductionRun +from src.backend.models.orm.recipe import RecipeVersion +from src.backend.models.orm.station import Station +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.tests.conftest import auth_headers, create_test_recipe + +T0 = datetime(2026, 10, 1, 8, 0, 0) + + +async def _setup(db, user): + recipe = await create_test_recipe(db, user.id) + version = (await db.execute( + select(RecipeVersion).where( + RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712 + ) + )).scalar_one() + task = (await db.execute( + select(RecipeTask).where(RecipeTask.version_id == version.id) + )).scalars().first() + first = (await db.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().first() + second = RecipeSubtask( + task_id=task.id, marker_number=2, description="Quota 2", + nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm", + ) + db.add(second) + station = Station(code="ST-CYC", name="St", active=True, created_by=user.id) + db.add(station) + await db.commit() + run = ProductionRun( + station_id=station.id, recipe_id=recipe.id, version_id=version.id, + operator_id=user.id, status="running", active_station_id=station.id, + ) + db.add(run) + await db.commit() + return run, version, task, first, second + + +async def _event(db, run, user, kind, minutes, task_id=None): + db.add(ProductionEvent( + run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id, + created_at=T0 + timedelta(minutes=minutes), + )) + await db.commit() + + +async def _meas(db, run, version, subtask, user, minutes, value=10.0): + db.add(Measurement( + subtask_id=subtask.id, version_id=version.id, measured_by=user.id, + value=value, pass_fail="pass", production_run_id=run.id, + measured_at=T0 + timedelta(minutes=minutes), + )) + await db.commit() + + +async def _get(client, user, run, task): + resp = await client.get( + "/api/measurements/current-cycle", headers=auth_headers(user), + params={"production_run_id": run.id, "task_id": task.id}, + ) + assert resp.status_code == 200 + return resp.json()["measurements"] + + +async def test_only_second_cycle_after_cycle_completed( + client: AsyncClient, db_session, measurement_tec_user, +): + u = measurement_tec_user + run, version, task, q1, q2 = await _setup(db_session, u) + await _event(db_session, run, u, "start", 0) + await _meas(db_session, run, version, q1, u, 1, 10.1) + await _meas(db_session, run, version, q2, u, 2, 10.2) + await _event(db_session, run, u, "cycle_completed", 3) + await _meas(db_session, run, version, q1, u, 4, 10.3) + + rows = await _get(client, u, run, task) + assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)] + assert rows[0]["pass_fail"] == "pass" + + +async def test_last_reading_of_a_quote_wins( + client: AsyncClient, db_session, measurement_tec_user, +): + u = measurement_tec_user + run, version, task, q1, q2 = await _setup(db_session, u) + await _event(db_session, run, u, "start", 0) + await _meas(db_session, run, version, q1, u, 1, 10.1) + await _meas(db_session, run, version, q1, u, 2, 10.4) + + rows = await _get(client, u, run, task) + assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)] + + +async def test_remeasure_of_the_task_empties_the_list( + client: AsyncClient, db_session, measurement_tec_user, +): + u = measurement_tec_user + run, version, task, q1, q2 = await _setup(db_session, u) + await _event(db_session, run, u, "start", 0) + await _meas(db_session, run, version, q1, u, 1) + await _event(db_session, run, u, "remeasure", 2, task_id=task.id) + + assert await _get(client, u, run, task) == [] + + +async def test_remeasure_of_another_task_does_not_move_the_boundary( + client: AsyncClient, db_session, measurement_tec_user, +): + u = measurement_tec_user + run, version, task, q1, q2 = await _setup(db_session, u) + other = RecipeTask( + version_id=version.id, title="Altro", task_type=task.task_type, order_index=9, + ) + db_session.add(other) + await db_session.commit() + await _event(db_session, run, u, "start", 0) + await _meas(db_session, run, version, q1, u, 1) + await _event(db_session, run, u, "remeasure", 2, task_id=other.id) + + assert len(await _get(client, u, run, task)) == 1 + + +async def test_task_measured_after_boundary_means_closed( + client: AsyncClient, db_session, measurement_tec_user, +): + u = measurement_tec_user + run, version, task, q1, q2 = await _setup(db_session, u) + await _event(db_session, run, u, "start", 0) + await _meas(db_session, run, version, q1, u, 1) + await _meas(db_session, run, version, q2, u, 2) + await _event(db_session, run, u, "task_measured", 3, task_id=task.id) + + assert await _get(client, u, run, task) == [] + + +async def test_task_measured_before_boundary_does_not_close_next_cycle( + client: AsyncClient, db_session, measurement_tec_user, +): + u = measurement_tec_user + run, version, task, q1, q2 = await _setup(db_session, u) + await _event(db_session, run, u, "start", 0) + await _event(db_session, run, u, "task_measured", 1, task_id=task.id) + await _event(db_session, run, u, "cycle_completed", 2) + await _meas(db_session, run, version, q1, u, 3) + + assert len(await _get(client, u, run, task)) == 1 + + +async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user): + run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user) + params = {"production_run_id": run.id, "task_id": task.id} + + no_key = await client.get("/api/measurements/current-cycle", params=params) + assert no_key.status_code in (401, 403) + + forbidden = await client.get( + "/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params, + ) + assert forbidden.status_code == 403 diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 256f14b..fd2ed4b 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -595,6 +595,40 @@ def api_task_progress(task_id: int): return jsonify(row), 200 +# --------------------------------------------------------------------------- +# Route: quotes already measured in the current cycle (#40) +# --------------------------------------------------------------------------- +@measure_bp.route("/api/current-cycle/", methods=["GET"]) +@login_required +@role_required("MeasurementTec") +def api_current_cycle(task_id: int): + """Proxy: quotes of this task already measured in the cycle under way. + + The run comes from _current_run_id(), like the rest of the page. Outside a + production there is no cycle to restore and the answer is an empty list. + """ + task_resp = api_client.get(f"/api/tasks/{task_id}") + if isinstance(task_resp, dict) and task_resp.get("error"): + return jsonify(task_resp), task_resp.get("status_code", 500) + + recipe_id = task_resp.get("recipe_id") + if recipe_id and not _recipe_allowed_at_station(recipe_id): + abort(403, description=_("Ricetta non assegnata a questa stazione")) + + run_id = _current_run_id() + if not run_id: + return jsonify({"measurements": []}), 200 + + resp = api_client.get( + "/api/measurements/current-cycle", + params={"production_run_id": run_id, "task_id": task_id}, + ) + if not isinstance(resp, dict) or resp.get("error"): + # Never fatal: without the restore the screen behaves as it always did. + return jsonify({"measurements": []}), 200 + return jsonify(resp), 200 + + # --------------------------------------------------------------------------- # Routes: Out-of-tolerance authorisation (point 5) # --------------------------------------------------------------------------- diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 0bc349d..edb6f80 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -1100,6 +1100,7 @@ function taskExecute() { this.inputStartedAt = Date.now(); // Rejoin whatever production is already open at this station. await this.loadProductionRun(); + await this.loadCurrentCycle(); await this.loadPendingAuthorisation(); // A tablet left to sleep drifts (#37.7): catch the countdown back up to // the server's own clock, the same source adoptProductionRun already @@ -1110,6 +1111,42 @@ function taskExecute() { }); }, + /* Quotes already measured in the cycle under way (#40). + + A reload, a timer redirect or an auto-logout used to bring every quote back + and measuring them again put duplicates in the SPC statistics. Only inside + a production: outside one there is no cycle, and the screen starts empty as + it always did. */ + async loadCurrentCycle() { + if (!this.isMeasureTask || !this.subtasks.length || !this.productionRun) return; + try { + const resp = await fetch( + '{{ url_for("measure.api_current_cycle", task_id=task.id) }}', + ); + if (!resp.ok) return; + const data = await resp.json(); + const known = new Set(this.subtasks.map(s => s.id)); + const restored = (data.measurements || []).filter(m => known.has(m.subtask_id)); + if (!restored.length) return; + this.measurements = restored.map(m => { + const st = this.subtasks.find(s => s.id === m.subtask_id); + const nominal = st ? (st.nominal || 0) : 0; + return { + subtask_id: m.subtask_id, + value: m.value, + pass_fail: m.pass_fail, + deviation: m.value - nominal, + }; + }); + const next = this.subtasks.findIndex( + s => !this.measurements.some(m => m.subtask_id === s.id), + ); + this.currentIndex = next !== -1 ? next : this.subtasks.length - 1; + } catch (e) { + // Offline: the screen starts empty, as before. + } + }, + /* Is a quote already waiting for the capoturno? Reloading the page was the simplest way past the old gate. It is not one diff --git a/src/frontend/flask_app/tests/test_measure.py b/src/frontend/flask_app/tests/test_measure.py index 680387a..8188050 100644 --- a/src/frontend/flask_app/tests/test_measure.py +++ b/src/frontend/flask_app/tests/test_measure.py @@ -248,6 +248,67 @@ class TestApiTaskProgress: assert resp.status_code in (302, 401) +class TestApiCurrentCycle: + """GET /measure/api/current-cycle/ (gitea#40).""" + + @pytest.fixture(autouse=True) + def _station_configured(self, monkeypatch): + monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") + + def test_forwards_run_and_task(self, logged_in_client, mock_api_client): + mock_api_client.get_station_recipes.return_value = [{"id": 1}] + payload = {"measurements": [ + {"subtask_id": 3, "value": 10.1, "pass_fail": "pass", + "measured_at": "2026-10-01T08:01:00"}, + ]} + mock_api_client.get.side_effect = api_get_router({ + "/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42}, + "/api/production-runs/current": {"id": 5}, + "/api/measurements/current-cycle": payload, + }) + + resp = logged_in_client.get("/measure/api/current-cycle/7") + + assert resp.status_code == 200 + assert resp.get_json() == payload + call = next( + c for c in mock_api_client.get.call_args_list + if c.args[0] == "/api/measurements/current-cycle" + ) + assert call.kwargs["params"] == {"production_run_id": 5, "task_id": 7} + + def test_no_run_means_empty_and_no_backend_call( + self, logged_in_client, mock_api_client, + ): + mock_api_client.get_station_recipes.return_value = [{"id": 1}] + mock_api_client.get.side_effect = api_get_router({ + "/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42}, + "/api/production-runs/current": None, + }) + + resp = logged_in_client.get("/measure/api/current-cycle/7") + + assert resp.get_json() == {"measurements": []} + assert not any( + c.args[0] == "/api/measurements/current-cycle" + for c in mock_api_client.get.call_args_list + ) + + def test_refuses_a_recipe_not_assigned_to_this_station( + self, logged_in_client, mock_api_client, + ): + mock_api_client.get_station_recipes.return_value = [{"id": 99}] + mock_api_client.get.side_effect = api_get_router({ + "/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42}, + }) + + assert logged_in_client.get("/measure/api/current-cycle/7").status_code == 403 + + def test_requires_login(self, client): + resp = client.get("/measure/api/current-cycle/7") + assert resp.status_code in (302, 401) + + class TestStartSequence: """GET /measure/start/ — the operator's way in."""