From 3f03ec8ab17161cd0bb880f1dc9a1788a6af23e1 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Tue, 28 Jul 2026 14:32:35 +0200 Subject: [PATCH] feat(measure): memorizza tempo di inserimento misura (input_duration_ms) Colonna nullable su measurements + migration 003, campo opzionale negli schemi API (create singola e batch), passthrough nel proxy Flask e timer lato client in task_execute: parte all'attivazione del subtask, si resetta su auto-advance e navigazione manuale. Co-Authored-By: Claude Fable 5 --- src/backend/api/routers/measurements.py | 2 + .../versions/003_add_input_duration_ms.py | 27 ++++++++++ src/backend/models/api/measurement.py | 2 + src/backend/models/orm/measurement.py | 5 ++ src/backend/services/measurement_service.py | 2 + src/backend/tests/test_measurements.py | 53 +++++++++++++++++++ src/frontend/flask_app/blueprints/measure.py | 1 + .../templates/measure/task_execute.html | 10 ++++ src/frontend/flask_app/tests/test_measure.py | 39 ++++++++++++++ 9 files changed, 141 insertions(+) create mode 100644 src/backend/migrations/versions/003_add_input_duration_ms.py diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 13ceab0..5e5de1a 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -46,6 +46,7 @@ async def create_measurement( lot_number=data.lot_number, serial_number=data.serial_number, input_method=data.input_method, + input_duration_ms=data.input_duration_ms, ) return MeasurementResponse.model_validate(measurement) except ValueError as e: @@ -74,6 +75,7 @@ async def create_measurement_batch( lot_number=measurement_data.lot_number, serial_number=measurement_data.serial_number, input_method=measurement_data.input_method, + input_duration_ms=measurement_data.input_duration_ms, ) measurements.append(measurement) return [MeasurementResponse.model_validate(m) for m in measurements] diff --git a/src/backend/migrations/versions/003_add_input_duration_ms.py b/src/backend/migrations/versions/003_add_input_duration_ms.py new file mode 100644 index 0000000..6bafea1 --- /dev/null +++ b/src/backend/migrations/versions/003_add_input_duration_ms.py @@ -0,0 +1,27 @@ +"""add input_duration_ms to measurements + +Revision ID: 003_input_duration +Revises: 002_add_stations +Create Date: 2026-07-28 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '003_input_duration' +down_revision: Union[str, None] = '002_add_stations' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'measurements', + sa.Column('input_duration_ms', sa.Integer, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('measurements', 'input_duration_ms') diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index 7a45a2a..873cc51 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -13,6 +13,7 @@ class MeasurementCreate(BaseModel): lot_number: Optional[str] = Field(None, max_length=100) 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) class MeasurementBatchCreate(BaseModel): @@ -34,6 +35,7 @@ class MeasurementResponse(BaseModel): lot_number: Optional[str] = None serial_number: Optional[str] = None input_method: str + input_duration_ms: Optional[int] = None measured_at: datetime synced_to_csv: bool diff --git a/src/backend/models/orm/measurement.py b/src/backend/models/orm/measurement.py index 28b0354..54757b9 100644 --- a/src/backend/models/orm/measurement.py +++ b/src/backend/models/orm/measurement.py @@ -43,6 +43,11 @@ class Measurement(Base): default="manual", ) + # Time spent by the operator entering this value (client-reported) + input_duration_ms: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True + ) + # Timestamp measured_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), index=True diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index 38055a1..e4aef6e 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -48,6 +48,7 @@ async def save_measurement( lot_number: str | None = None, serial_number: str | None = None, input_method: str = "manual", + input_duration_ms: int | None = None, ) -> Measurement: """Save a single measurement with auto-calculated pass/fail.""" # Get subtask for tolerance values @@ -70,6 +71,7 @@ async def save_measurement( lot_number=lot_number, serial_number=serial_number, input_method=input_method, + input_duration_ms=input_duration_ms, ) db.add(measurement) await db.flush() diff --git a/src/backend/tests/test_measurements.py b/src/backend/tests/test_measurements.py index fbdce25..b387890 100644 --- a/src/backend/tests/test_measurements.py +++ b/src/backend/tests/test_measurements.py @@ -55,6 +55,59 @@ class TestCreateMeasurement: assert data["pass_fail"] in ("pass", "warning", "fail") assert data["measured_by"] == measurement_tec_user.id + async def test_create_measurement_with_input_duration( + self, + client: AsyncClient, + measurement_tec_user: User, + db_session: AsyncSession, + ): + """input_duration_ms is stored and returned when provided.""" + recipe = await create_test_recipe( + db_session, measurement_tec_user.id + ) + subtask_id, version_id = await _get_subtask_and_version( + client, measurement_tec_user, recipe.id + ) + + resp = await client.post( + "/api/measurements/", + headers=auth_headers(measurement_tec_user), + json={ + "subtask_id": subtask_id, + "version_id": version_id, + "value": 10.0, + "input_duration_ms": 4250, + }, + ) + assert resp.status_code == 200 + assert resp.json()["input_duration_ms"] == 4250 + + async def test_create_measurement_without_input_duration( + self, + client: AsyncClient, + measurement_tec_user: User, + db_session: AsyncSession, + ): + """input_duration_ms defaults to null when omitted.""" + recipe = await create_test_recipe( + db_session, measurement_tec_user.id + ) + subtask_id, version_id = await _get_subtask_and_version( + client, measurement_tec_user, recipe.id + ) + + resp = await client.post( + "/api/measurements/", + headers=auth_headers(measurement_tec_user), + json={ + "subtask_id": subtask_id, + "version_id": version_id, + "value": 10.0, + }, + ) + assert resp.status_code == 200 + assert resp.json()["input_duration_ms"] is None + async def test_measurement_requires_auth(self, client: AsyncClient): """Creating measurement without auth returns 401.""" resp = await client.post( diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 29d5ae4..026483c 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -311,6 +311,7 @@ def save_measurement(): "lot_number": data.get("lot_number", session.get("lot_number", "")), "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"), } resp = api_client.post("/api/measurements", data=payload) diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 191dd22..5137c94 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -729,6 +729,9 @@ function taskExecute() { // ---- Value from numpad / caliper ---- currentValue: null, + // ---- Input timing (ms spent on the active subtask) ---- + inputStartedAt: null, + // ---- Image switching logic ---- get currentSubtaskImage() { return this.currentSubtask?.image_path || null; @@ -808,6 +811,7 @@ function taskExecute() { // ---- Init ---- init() { this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); + this.inputStartedAt = Date.now(); }, // ---- Check if a subtask has been measured ---- @@ -834,6 +838,9 @@ function taskExecute() { const pf = this.passFailStatus; const dev = this.deviation; + const inputDurationMs = this.inputStartedAt !== null + ? Math.max(0, Math.round(Date.now() - this.inputStartedAt)) + : null; this.saving = true; @@ -853,6 +860,7 @@ function taskExecute() { lot_number: this.lotNumber, serial_number: this.serialNumber, input_method: inputMethod || 'manual', + input_duration_ms: inputDurationMs, }), }); @@ -911,6 +919,7 @@ function taskExecute() { // ---- Advance to next unmeasured subtask ---- advanceToNext() { this.currentValue = null; + this.inputStartedAt = Date.now(); for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) { if (!this.isMeasured(this.subtasks[i].id)) { @@ -932,6 +941,7 @@ function taskExecute() { this.currentIndex = index; this.currentValue = null; this.errorMessage = ''; + this.inputStartedAt = Date.now(); } }, diff --git a/src/frontend/flask_app/tests/test_measure.py b/src/frontend/flask_app/tests/test_measure.py index 2eaa664..5ef5baa 100644 --- a/src/frontend/flask_app/tests/test_measure.py +++ b/src/frontend/flask_app/tests/test_measure.py @@ -80,6 +80,45 @@ class TestSaveMeasurement: assert data["id"] == 1 assert data["pass_fail"] == "pass" + def test_save_measurement_forwards_input_duration( + self, logged_in_client, mock_api_client + ): + """input_duration_ms is forwarded to the backend payload.""" + mock_api_client.post.return_value = {"id": 1, "pass_fail": "pass"} + + resp = logged_in_client.post( + "/measure/save-measurement", + json={ + "subtask_id": 10, + "version_id": 3, + "value": 9.95, + "input_duration_ms": 4250, + }, + content_type="application/json", + ) + assert resp.status_code == 201 + payload = mock_api_client.post.call_args.kwargs["data"] + assert payload["input_duration_ms"] == 4250 + + def test_save_measurement_omits_input_duration_when_absent( + self, logged_in_client, mock_api_client + ): + """Without input_duration_ms the payload sends null (backend default).""" + mock_api_client.post.return_value = {"id": 1, "pass_fail": "pass"} + + resp = logged_in_client.post( + "/measure/save-measurement", + json={ + "subtask_id": 10, + "version_id": 3, + "value": 9.95, + }, + content_type="application/json", + ) + assert resp.status_code == 201 + payload = mock_api_client.post.call_args.kwargs["data"] + assert payload.get("input_duration_ms") is None + def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client): """Missing required fields return 400.""" resp = logged_in_client.post(