feat(production): la misura torna a cercare l'operatore
Il conto alla rovescia non si ferma piu' a zero. Oltre la scadenza continua nell'altro senso e dice da quanto la linea e' in ritardo, in rosso, su tutte le schermate del percorso di misura: lista task, riepilogo, scelta ricetta. Un ritardo va letto, non dedotto. Allo scadere dell'intervallo la misura si ripropone da sola: cicalino, un avviso di cinque secondi, e l'operatore torna al primo task di misura da qualunque schermata si trovi. Un ciclo copre tutti i task di misura della ricetta, quindi solo l'ultimo lo chiude e fa ripartire l'intervallo. Farlo ripartire al primo avrebbe fatto misurare una ricetta con tre task di misura tre volte piu' spesso di come e' configurata. E' il server a decidere quale task chiude il ciclo, perche' e' lui a conoscere la sequenza: la lista dei task di misura viaggia con la produzione (measurement_task_ids), che e' anche cio' che permette a una schermata qualsiasi di sapere dove riportare l'operatore. Aggiunta la rimisura: si gira il pezzo e si misura di nuovo dentro lo stesso ciclo, senza chiudere niente e senza guadagnare tempo sulla scadenza. Le due letture restano entrambe in statistica, che e' il motivo per cui si prendono. Chi sta gia' misurando quando l'intervallo scade e' in ritardo, non perso: la banda diventa rossa e lo si lascia lavorare. Portarlo altrove a meta' ciclo cancellerebbe quote che ha davanti agli occhi senza guadagnare nulla. Sistemato anche il pulsante "Conferma ciclo" dell'overlay: alzava una bandierina locale e basta, quindi il ciclo non veniva mai registrato sul server da quella strada. Ora passa da confirmCycle come il pulsante della barra. Migrazione 008: il registro eventi della produzione impara task_measured e remeasure, e la colonna task_id — un task_measured che non dice quale task non registra niente di utile. I valori nuovi entrano tutti insieme perche' allargare una enum MySQL riscrive la tabella, stesso ragionamento dei tipi di task in 007. Verificata su SQLite (batch mode) e in MySQL con --sql. La logica dell'orologio — come si legge un ritardo, quando suona, dove sta la misura — vive in un solo posto (production-clock.js) e la schermata di misura la usa invece di riscriverla. Test: +10 (265). Coprono il ciclo che non riparte a meta', la rimisura che non sposta la scadenza, il rifiuto a linea ferma, la sequenza dei task di misura esposta dalla produzione, i proxy Flask e la validita' JS della lista task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from src.backend.models.api.production import (
|
||||
ProductionRunCreate,
|
||||
ProductionRunResponse,
|
||||
ProductionRunWithEventsResponse,
|
||||
RemeasurePayload,
|
||||
SupervisorAction,
|
||||
)
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
@@ -23,13 +24,19 @@ from src.backend.services import production_service
|
||||
|
||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||
|
||||
_DERIVED = {
|
||||
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
||||
}
|
||||
|
||||
def _as_response(run: ProductionRun) -> ProductionRunResponse:
|
||||
|
||||
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
|
||||
"""The stored run plus everything the client cannot work out on its own."""
|
||||
return ProductionRunResponse(
|
||||
**ProductionRunResponse.model_validate(run).model_dump(
|
||||
exclude={"seconds_to_next_measurement", "overdue", "server_time"}
|
||||
),
|
||||
**ProductionRunResponse.model_validate(run).model_dump(exclude=_DERIVED),
|
||||
**production_service.describe(run),
|
||||
measurement_task_ids=await production_service.measurement_task_ids(
|
||||
db, run.version_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +48,7 @@ async def open_production_run(
|
||||
):
|
||||
"""Start a production at a station."""
|
||||
run = await production_service.open_run(db, data, user)
|
||||
return _as_response(run)
|
||||
return await _as_response(db, run)
|
||||
|
||||
|
||||
@router.get("/current", response_model=ProductionRunResponse | None)
|
||||
@@ -56,7 +63,7 @@ async def get_current_production_run(
|
||||
"""
|
||||
station = await production_service.get_station_by_code(db, station_code)
|
||||
run = await production_service.get_open_run_for_station(db, station.id)
|
||||
return _as_response(run) if run is not None else None
|
||||
return await _as_response(db, run) if run is not None else None
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||
@@ -69,7 +76,7 @@ async def get_production_run(
|
||||
run = await production_service.get_run(db, run_id)
|
||||
events = await production_service.list_run_events(db, run_id)
|
||||
return ProductionRunWithEventsResponse(
|
||||
**_as_response(run).model_dump(),
|
||||
**(await _as_response(db, run)).model_dump(),
|
||||
events=[ProductionEventResponse.model_validate(e) for e in events],
|
||||
)
|
||||
|
||||
@@ -81,12 +88,31 @@ async def complete_measurement_cycle(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a finished measurement cycle and restart the interval."""
|
||||
"""Record a finished measurement task; the last one restarts the interval."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
run = await production_service.complete_cycle(
|
||||
db, run, user, note=payload.note if payload else None,
|
||||
db, run, user,
|
||||
task_id=payload.task_id if payload else None,
|
||||
note=payload.note if payload else None,
|
||||
)
|
||||
return _as_response(run)
|
||||
return await _as_response(db, run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/remeasure", response_model=ProductionRunResponse)
|
||||
async def remeasure_in_cycle(
|
||||
run_id: int,
|
||||
payload: RemeasurePayload | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Turn the piece over and measure again, without closing the cycle."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
run = await production_service.remeasure(
|
||||
db, run, user,
|
||||
task_id=payload.task_id if payload else None,
|
||||
note=payload.note if payload else None,
|
||||
)
|
||||
return await _as_response(db, run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
|
||||
@@ -102,7 +128,7 @@ async def pause_production_run(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||
return _as_response(run)
|
||||
return await _as_response(db, run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
|
||||
@@ -118,7 +144,7 @@ async def resume_production_run(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||
return _as_response(run)
|
||||
return await _as_response(db, run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
|
||||
@@ -134,4 +160,4 @@ async def close_production_run(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||
return _as_response(run)
|
||||
return await _as_response(db, run)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""widen the production event trace for the measurement loop
|
||||
|
||||
The loop needs to distinguish two things the trace could not say before: a
|
||||
measurement task finished while the cycle is still running (task_measured), and the
|
||||
piece turned over and measured again without closing the cycle (remeasure). Only
|
||||
the last measurement task of a recipe still produces cycle_completed, because only
|
||||
that one restarts the interval.
|
||||
|
||||
Both values are added in one go. Widening a MySQL enum rewrites the table, so the
|
||||
values that are already planned go in now rather than one migration at a time -
|
||||
the same reasoning as the task types in 007.
|
||||
|
||||
The events that happen inside a cycle also need to say which task they are about,
|
||||
so the column comes with them: a task_measured that does not name its task records
|
||||
nothing usable.
|
||||
|
||||
Revision ID: 008_loop_events
|
||||
Revises: 007_task_type
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '008_loop_events'
|
||||
down_revision: Union[str, None] = '007_task_type'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
OLD_TYPES = ('start', 'cycle_completed', 'line_stop', 'resume', 'close')
|
||||
NEW_TYPES = (
|
||||
'start', 'cycle_completed', 'task_measured', 'remeasure',
|
||||
'line_stop', 'resume', 'close',
|
||||
)
|
||||
|
||||
|
||||
def _alter(values: Sequence[str]) -> None:
|
||||
"""Restate the column with a new set of allowed values.
|
||||
|
||||
On MySQL this is the ALTER that rewrites the enum. On SQLite, where an enum is
|
||||
stored as a plain VARCHAR, batch mode rebuilds the table instead of emitting an
|
||||
ALTER the engine does not support - the tests run there.
|
||||
"""
|
||||
with op.batch_alter_table('production_events') as batch:
|
||||
batch.alter_column(
|
||||
'event_type',
|
||||
existing_type=sa.Enum(*OLD_TYPES, name='production_event_type_enum'),
|
||||
type_=sa.Enum(*values, name='production_event_type_enum'),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_alter(NEW_TYPES)
|
||||
with op.batch_alter_table('production_events') as batch:
|
||||
batch.add_column(sa.Column('task_id', sa.Integer(), nullable=True))
|
||||
batch.create_foreign_key(
|
||||
'fk_production_events_task_id', 'recipe_tasks', ['task_id'], ['id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('production_events') as batch:
|
||||
batch.drop_constraint('fk_production_events_task_id', type_='foreignkey')
|
||||
batch.drop_column('task_id')
|
||||
# The new kinds of event have no equivalent in the old vocabulary, and a row
|
||||
# left with a value the column no longer accepts would fail the rebuild.
|
||||
op.execute(
|
||||
"DELETE FROM production_events "
|
||||
"WHERE event_type IN ('task_measured', 'remeasure')"
|
||||
)
|
||||
_alter(OLD_TYPES)
|
||||
@@ -19,6 +19,7 @@ class ProductionEventResponse(BaseModel):
|
||||
event_type: str
|
||||
user_id: int
|
||||
supervisor_id: Optional[int]
|
||||
task_id: Optional[int] = None
|
||||
note: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
@@ -50,6 +51,11 @@ class ProductionRunResponse(BaseModel):
|
||||
overdue: bool = False
|
||||
server_time: Optional[datetime] = None
|
||||
|
||||
# The measurement tasks of this run's version, in execution order. The client
|
||||
# needs them to bring the operator back to the measurement when the interval
|
||||
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||
measurement_task_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||
events: list[ProductionEventResponse] = Field(default_factory=list)
|
||||
@@ -64,9 +70,20 @@ class SupervisorAction(BaseModel):
|
||||
|
||||
|
||||
class CycleCompletePayload(BaseModel):
|
||||
# Which measurement task was just finished. The interval restarts only on the
|
||||
# last one of the recipe; omitting it closes the cycle outright.
|
||||
task_id: Optional[int] = Field(default=None, gt=0)
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class RemeasurePayload(BaseModel):
|
||||
"""The piece was turned over: measure it again inside the same cycle."""
|
||||
|
||||
task_id: Optional[int] = Field(default=None, gt=0)
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
ProductionEventType = Literal[
|
||||
"start", "cycle_completed", "line_stop", "resume", "close",
|
||||
"start", "cycle_completed", "task_measured", "remeasure",
|
||||
"line_stop", "resume", "close",
|
||||
]
|
||||
|
||||
@@ -31,7 +31,13 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
RUN_STATUSES = ("running", "paused", "closed")
|
||||
EVENT_TYPES = ("start", "cycle_completed", "line_stop", "resume", "close")
|
||||
# task_measured marks a measurement task finished inside a cycle that is not over
|
||||
# yet; only the last one produces cycle_completed and restarts the interval.
|
||||
# remeasure is the piece being turned and measured again without closing the cycle.
|
||||
EVENT_TYPES = (
|
||||
"start", "cycle_completed", "task_measured", "remeasure",
|
||||
"line_stop", "resume", "close",
|
||||
)
|
||||
|
||||
|
||||
class ProductionRun(Base):
|
||||
@@ -139,6 +145,12 @@ class ProductionEvent(Base):
|
||||
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
# Which task the event is about, for the ones that happen inside a cycle. A
|
||||
# task_measured or a remeasure that does not say which task is not a record of
|
||||
# anything; null for the events that concern the run as a whole.
|
||||
task_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id"), nullable=True
|
||||
)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), index=True
|
||||
|
||||
@@ -24,6 +24,7 @@ from src.backend.models.api.production import ProductionRunCreate
|
||||
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.task import MEASURING_TASK_TYPES, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import auth_service, production_export_service
|
||||
|
||||
@@ -103,6 +104,28 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
||||
return station
|
||||
|
||||
|
||||
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||
"""Ids of the measurement tasks of a version, in the order they are executed.
|
||||
|
||||
This is the loop the operator lives in once production starts: the client uses
|
||||
the first of these to bring the measurement back on screen when the interval
|
||||
expires, and the last to know which task closes a cycle.
|
||||
|
||||
The type is read from the declared task_type (point 2), not from whether quotes
|
||||
happen to be present: a measurement task with no quotes yet is still where the
|
||||
operator has to be taken.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(RecipeTask.id)
|
||||
.where(
|
||||
RecipeTask.version_id == version_id,
|
||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||
)
|
||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
|
||||
result = await db.execute(
|
||||
select(ProductionEvent)
|
||||
@@ -149,6 +172,7 @@ def _add_event(
|
||||
user: User,
|
||||
supervisor: Optional[User] = None,
|
||||
note: Optional[str] = None,
|
||||
task_id: Optional[int] = None,
|
||||
) -> ProductionEvent:
|
||||
"""Append to the run's trace.
|
||||
|
||||
@@ -161,6 +185,7 @@ def _add_event(
|
||||
user_id=user.id,
|
||||
supervisor_id=supervisor.id if supervisor else None,
|
||||
note=note,
|
||||
task_id=task_id,
|
||||
created_at=_now(),
|
||||
)
|
||||
db.add(event)
|
||||
@@ -254,10 +279,7 @@ def _require_open(run: ProductionRun) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def complete_cycle(
|
||||
db: AsyncSession, run: ProductionRun, user: User, note: Optional[str] = None,
|
||||
) -> ProductionRun:
|
||||
"""Record a finished measurement cycle and restart the interval."""
|
||||
def _require_measurable(run: ProductionRun) -> None:
|
||||
_require_open(run)
|
||||
if run.status == "paused":
|
||||
raise HTTPException(
|
||||
@@ -265,6 +287,36 @@ async def complete_cycle(
|
||||
detail="Production run is paused: resume it before measuring",
|
||||
)
|
||||
|
||||
|
||||
async def complete_cycle(
|
||||
db: AsyncSession,
|
||||
run: ProductionRun,
|
||||
user: User,
|
||||
task_id: Optional[int] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> ProductionRun:
|
||||
"""Record a finished measurement task, and restart the interval if it was the last.
|
||||
|
||||
A cycle spans every measurement task of the recipe, so only the last one closes
|
||||
it. Restarting the interval at the first would make a recipe with three
|
||||
measurement tasks measure three times as often as it was set up to.
|
||||
|
||||
An unrecognised task_id - or none at all - closes the cycle: a caller that does
|
||||
not know about the sequence gets the behaviour that existed before it.
|
||||
"""
|
||||
_require_measurable(run)
|
||||
|
||||
measure_ids = await measurement_task_ids(db, run.version_id)
|
||||
closes_cycle = (
|
||||
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
|
||||
)
|
||||
|
||||
if not closes_cycle:
|
||||
_add_event(db, run, "task_measured", user, note=note, task_id=task_id)
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
now = _now()
|
||||
run.cycle_count += 1
|
||||
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
|
||||
@@ -273,7 +325,29 @@ async def complete_cycle(
|
||||
run.next_measurement_at = now + timedelta(
|
||||
minutes=run.measurement_interval_minutes
|
||||
)
|
||||
_add_event(db, run, "cycle_completed", user, note=note)
|
||||
_add_event(db, run, "cycle_completed", user, note=note, task_id=task_id)
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
async def remeasure(
|
||||
db: AsyncSession,
|
||||
run: ProductionRun,
|
||||
user: User,
|
||||
task_id: Optional[int] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> ProductionRun:
|
||||
"""Measure the same task again without closing the cycle.
|
||||
|
||||
This is the piece being turned over. The deadline and the cycle count are left
|
||||
exactly as they are - a second reading of the same part is not a new cycle, and
|
||||
it must not buy the operator another interval's worth of time. The values
|
||||
themselves are ordinary measurements: both readings stay in the statistics,
|
||||
which is the point of taking them.
|
||||
"""
|
||||
_require_measurable(run)
|
||||
_add_event(db, run, "remeasure", user, note=note, task_id=task_id)
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
@@ -243,6 +243,10 @@ async def create_test_recipe(
|
||||
version_id=version.id,
|
||||
order_index=0,
|
||||
title="Test Task",
|
||||
# Declared, like any task since the type stopped being deduced: this one has
|
||||
# a quote under it, so a fixture that called it a note would be modelling
|
||||
# exactly the inconsistency point 2 removed.
|
||||
task_type="measure",
|
||||
directive="Measure the part",
|
||||
description="First measurement task",
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy import select
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.services import auth_service, production_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
@@ -63,6 +64,29 @@ async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None:
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def _only_task(db_session, version_id: int):
|
||||
"""The single task the fixture recipe comes with - a measurement one."""
|
||||
row = await db_session.execute(
|
||||
select(RecipeTask)
|
||||
.where(RecipeTask.version_id == version_id)
|
||||
.order_by(RecipeTask.order_index)
|
||||
)
|
||||
return row.scalars().first()
|
||||
|
||||
|
||||
async def _add_task(
|
||||
db_session, version_id: int, order_index: int, title: str, task_type: str = "measure",
|
||||
) -> RecipeTask:
|
||||
"""A further task in the sequence, of a declared type."""
|
||||
task = RecipeTask(
|
||||
version_id=version_id, order_index=order_index, title=title, task_type=task_type,
|
||||
)
|
||||
db_session.add(task)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def _open(client, user, station, recipe, **extra):
|
||||
return await client.post(
|
||||
"/api/production-runs",
|
||||
@@ -209,6 +233,138 @@ async def test_cycle_restarts_the_interval_and_counts(
|
||||
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
|
||||
|
||||
|
||||
async def test_measurement_tasks_travel_with_the_run(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""Where the measurement is cannot be guessed by the client: the run says so.
|
||||
|
||||
This is what lets a screen bring the operator back to the measurement when the
|
||||
interval expires, whatever page they wandered off to.
|
||||
"""
|
||||
station = await _station(db_session, admin_user.id, code="ST-SEQ")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SEQ")
|
||||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||
first = await _only_task(db_session, opened["version_id"])
|
||||
# A drawing between the two measurements, to prove it is left out.
|
||||
await _add_task(db_session, opened["version_id"], 1, "Disegno", "drawing")
|
||||
second = await _add_task(db_session, opened["version_id"], 2, "Seconda misura")
|
||||
|
||||
body = (await client.get(
|
||||
"/api/production-runs/current",
|
||||
params={"station_code": station.code},
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)).json()
|
||||
assert body["measurement_task_ids"] == [first.id, second.id]
|
||||
|
||||
|
||||
async def test_cycle_closes_only_on_the_last_measurement_task(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""Restarting at the first would measure three times as often as configured."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-MULTI")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-MULTI")
|
||||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||
run_id = opened["id"]
|
||||
first = await _only_task(db_session, opened["version_id"])
|
||||
second = await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
|
||||
headers = auth_headers(measurement_tec_user)
|
||||
|
||||
# Four minutes of the interval have gone by when the first task is finished.
|
||||
await _advance_clock(db_session, run_id, timedelta(minutes=4))
|
||||
|
||||
mid = (await client.post(
|
||||
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": first.id},
|
||||
)).json()
|
||||
assert mid["cycle_count"] == 0, "il ciclo non e' ancora finito"
|
||||
assert 5 * 60 <= mid["seconds_to_next_measurement"] <= 6 * 60, (
|
||||
"l'intervallo non deve ripartire a meta' ciclo"
|
||||
)
|
||||
|
||||
end = (await client.post(
|
||||
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": second.id},
|
||||
)).json()
|
||||
assert end["cycle_count"] == 1
|
||||
assert 9 * 60 <= end["seconds_to_next_measurement"] <= 10 * 60
|
||||
|
||||
events = (await client.get(
|
||||
f"/api/production-runs/{run_id}", headers=headers,
|
||||
)).json()["events"]
|
||||
by_type = {e["event_type"]: e for e in events}
|
||||
assert by_type["task_measured"]["task_id"] == first.id
|
||||
assert by_type["cycle_completed"]["task_id"] == second.id
|
||||
|
||||
|
||||
async def test_cycle_without_a_task_closes_as_before(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""A caller that knows nothing of the sequence keeps the old behaviour."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-PLAIN")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PLAIN")
|
||||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||
await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
|
||||
|
||||
body = (await client.post(
|
||||
f"/api/production-runs/{opened['id']}/cycle",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)).json()
|
||||
assert body["cycle_count"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rimisura - turning the piece over
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_remeasure_does_not_buy_another_interval(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""A second reading of the same part is not a new cycle."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-AGAIN")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGAIN")
|
||||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||
run_id = opened["id"]
|
||||
task = await _only_task(db_session, opened["version_id"])
|
||||
headers = auth_headers(measurement_tec_user)
|
||||
|
||||
await _advance_clock(db_session, run_id, timedelta(minutes=7))
|
||||
|
||||
body = (await client.post(
|
||||
f"/api/production-runs/{run_id}/remeasure", headers=headers,
|
||||
json={"task_id": task.id},
|
||||
)).json()
|
||||
assert body["cycle_count"] == 0
|
||||
assert 2 * 60 <= body["seconds_to_next_measurement"] <= 3 * 60, (
|
||||
"rigirare il pezzo non deve spostare la scadenza"
|
||||
)
|
||||
|
||||
events = (await client.get(
|
||||
f"/api/production-runs/{run_id}", headers=headers,
|
||||
)).json()["events"]
|
||||
remeasured = [e for e in events if e["event_type"] == "remeasure"]
|
||||
assert len(remeasured) == 1
|
||||
assert remeasured[0]["task_id"] == task.id
|
||||
|
||||
|
||||
async def test_remeasure_refused_while_the_line_is_stopped(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-AGPAUSE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGPAUSE")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-again")
|
||||
|
||||
await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/production-runs/{run_id}/remeasure",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_overdue_run_reports_negative_seconds(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user