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:
@@ -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')
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user