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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 14:32:35 +02:00
parent edb7eb382c
commit 3f03ec8ab1
9 changed files with 141 additions and 0 deletions
+2
View File
@@ -46,6 +46,7 @@ async def create_measurement(
lot_number=data.lot_number, lot_number=data.lot_number,
serial_number=data.serial_number, serial_number=data.serial_number,
input_method=data.input_method, input_method=data.input_method,
input_duration_ms=data.input_duration_ms,
) )
return MeasurementResponse.model_validate(measurement) return MeasurementResponse.model_validate(measurement)
except ValueError as e: except ValueError as e:
@@ -74,6 +75,7 @@ async def create_measurement_batch(
lot_number=measurement_data.lot_number, lot_number=measurement_data.lot_number,
serial_number=measurement_data.serial_number, serial_number=measurement_data.serial_number,
input_method=measurement_data.input_method, input_method=measurement_data.input_method,
input_duration_ms=measurement_data.input_duration_ms,
) )
measurements.append(measurement) measurements.append(measurement)
return [MeasurementResponse.model_validate(m) for m in measurements] return [MeasurementResponse.model_validate(m) for m in measurements]
@@ -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')
+2
View File
@@ -13,6 +13,7 @@ class MeasurementCreate(BaseModel):
lot_number: Optional[str] = Field(None, max_length=100) lot_number: Optional[str] = Field(None, max_length=100)
serial_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_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
input_duration_ms: Optional[int] = Field(None, ge=0)
class MeasurementBatchCreate(BaseModel): class MeasurementBatchCreate(BaseModel):
@@ -34,6 +35,7 @@ class MeasurementResponse(BaseModel):
lot_number: Optional[str] = None lot_number: Optional[str] = None
serial_number: Optional[str] = None serial_number: Optional[str] = None
input_method: str input_method: str
input_duration_ms: Optional[int] = None
measured_at: datetime measured_at: datetime
synced_to_csv: bool synced_to_csv: bool
+5
View File
@@ -43,6 +43,11 @@ class Measurement(Base):
default="manual", default="manual",
) )
# Time spent by the operator entering this value (client-reported)
input_duration_ms: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
)
# Timestamp # Timestamp
measured_at: Mapped[datetime] = mapped_column( measured_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True DateTime, nullable=False, server_default=func.now(), index=True
@@ -48,6 +48,7 @@ async def save_measurement(
lot_number: str | None = None, lot_number: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
input_method: str = "manual", input_method: str = "manual",
input_duration_ms: int | None = None,
) -> Measurement: ) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail.""" """Save a single measurement with auto-calculated pass/fail."""
# Get subtask for tolerance values # Get subtask for tolerance values
@@ -70,6 +71,7 @@ async def save_measurement(
lot_number=lot_number, lot_number=lot_number,
serial_number=serial_number, serial_number=serial_number,
input_method=input_method, input_method=input_method,
input_duration_ms=input_duration_ms,
) )
db.add(measurement) db.add(measurement)
await db.flush() await db.flush()
+53
View File
@@ -55,6 +55,59 @@ class TestCreateMeasurement:
assert data["pass_fail"] in ("pass", "warning", "fail") assert data["pass_fail"] in ("pass", "warning", "fail")
assert data["measured_by"] == measurement_tec_user.id 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): async def test_measurement_requires_auth(self, client: AsyncClient):
"""Creating measurement without auth returns 401.""" """Creating measurement without auth returns 401."""
resp = await client.post( resp = await client.post(
@@ -311,6 +311,7 @@ def save_measurement():
"lot_number": data.get("lot_number", session.get("lot_number", "")), "lot_number": data.get("lot_number", session.get("lot_number", "")),
"serial_number": data.get("serial_number", session.get("serial_number", "")), "serial_number": data.get("serial_number", session.get("serial_number", "")),
"input_method": data.get("input_method", "manual"), "input_method": data.get("input_method", "manual"),
"input_duration_ms": data.get("input_duration_ms"),
} }
resp = api_client.post("/api/measurements", data=payload) resp = api_client.post("/api/measurements", data=payload)
@@ -729,6 +729,9 @@ function taskExecute() {
// ---- Value from numpad / caliper ---- // ---- Value from numpad / caliper ----
currentValue: null, currentValue: null,
// ---- Input timing (ms spent on the active subtask) ----
inputStartedAt: null,
// ---- Image switching logic ---- // ---- Image switching logic ----
get currentSubtaskImage() { get currentSubtaskImage() {
return this.currentSubtask?.image_path || null; return this.currentSubtask?.image_path || null;
@@ -808,6 +811,7 @@ function taskExecute() {
// ---- Init ---- // ---- Init ----
init() { init() {
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
this.inputStartedAt = Date.now();
}, },
// ---- Check if a subtask has been measured ---- // ---- Check if a subtask has been measured ----
@@ -834,6 +838,9 @@ function taskExecute() {
const pf = this.passFailStatus; const pf = this.passFailStatus;
const dev = this.deviation; const dev = this.deviation;
const inputDurationMs = this.inputStartedAt !== null
? Math.max(0, Math.round(Date.now() - this.inputStartedAt))
: null;
this.saving = true; this.saving = true;
@@ -853,6 +860,7 @@ function taskExecute() {
lot_number: this.lotNumber, lot_number: this.lotNumber,
serial_number: this.serialNumber, serial_number: this.serialNumber,
input_method: inputMethod || 'manual', input_method: inputMethod || 'manual',
input_duration_ms: inputDurationMs,
}), }),
}); });
@@ -911,6 +919,7 @@ function taskExecute() {
// ---- Advance to next unmeasured subtask ---- // ---- Advance to next unmeasured subtask ----
advanceToNext() { advanceToNext() {
this.currentValue = null; this.currentValue = null;
this.inputStartedAt = Date.now();
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) { for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
if (!this.isMeasured(this.subtasks[i].id)) { if (!this.isMeasured(this.subtasks[i].id)) {
@@ -932,6 +941,7 @@ function taskExecute() {
this.currentIndex = index; this.currentIndex = index;
this.currentValue = null; this.currentValue = null;
this.errorMessage = ''; this.errorMessage = '';
this.inputStartedAt = Date.now();
} }
}, },
@@ -80,6 +80,45 @@ class TestSaveMeasurement:
assert data["id"] == 1 assert data["id"] == 1
assert data["pass_fail"] == "pass" 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): def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
"""Missing required fields return 400.""" """Missing required fields return 400."""
resp = logged_in_client.post( resp = logged_in_client.post(