feat(recipes): la ricetta decide cosa pretendere e come si misura
Tre cose che prima non erano di nessuno diventano regole della ricetta, decise da chi la scrive e fatte valere dal server. Punto 8 — tracciabilita' obbligatoria. Lotto e seriale si dichiarano obbligatori sulla ricetta. L'operatore li inserisce alla selezione, dove il pulsante non si attiva finche' mancano, e la stessa regola vale sulla lista task, raggiungibile anche per link diretto, e sul barcode: uno sbarramento che dura solo finche' qualcuno prende in mano il lettore non e' uno sbarramento. La produzione non si apre e la misura non si salva senza cio' che la ricetta pretende, perche' un valore senza il suo lotto non e' riconducibile a niente, e accorgersene dopo significa accorgersene tardi. Punto 9 — inserimento manuale. Una ricetta puo' vietare i valori digitati, ed e' il valore predefinito: il calibro e' lo strumento, digitare e' cio' che va concesso. Dove e' vietato il tastierino non viene disegnato (non nascosto con i CSS: il markup nascosto e' markup che si puo' rimostrare) e restano correzione e conferma, perche' una lettura sbagliata va cancellata. Il controllo vero e' sul server: una regola che vive solo nel frontend e' un consiglio. Migliorato al passaggio il riconoscimento del calibro. Contava solo la raffica di cifre, cosi' una lettura corta come "9.5" — tre battute — finiva registrata come digitata a mano; ora conta anche l'Invio che il wedge manda dentro la stessa raffica. Senza questa correzione il divieto avrebbe respinto misure legittime. Punto 11 — formattazione delle descrizioni. A capo e grassetto sopravvivono: chi scrive le ricette incolla dal PDF della scheda tecnica e il testo arrivava appiattito, da risistemare a mano ogni volta. Nessun HTML viene accettato o salvato — il testo viene escapato e gli unici tag nel risultato sono quelli prodotti dal renderer. La sanificazione e' questa: non c'e' niente da sanificare perche' non si accetta niente. Le stesse due regole in Jinja e in JS, cosi' una descrizione si legge uguale ovunque. E la descrizione ora si vede anche in esecuzione: era scritta per l'operatore e la vedeva solo chi la scriveva. Migrazione 009: tre colonne sulla ricetta. Le due di tracciabilita' partono false, che e' il comportamento di oggi; l'inserimento manuale parte *vero* sulle ricette gia' esistenti — il default della colonna e' falso, quindi le ricette nuove sono solo-calibro, ma spegnerlo d'ufficio su quelle in uso fermerebbe una linea alla misura successiva. Chi possiede la ricetta lo decide dall'editor. Test: +26 (291). Coprono il rifiuto sul server per lotto, seriale e valore digitato, il calibro sempre ammesso, le regole che sopravvivono alla nuova versione, il tastierino assente in pagina, l'Avvia sbarrato, e il renderer delle descrizioni compreso il caso in cui si prova a farci passare un tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""traceability and manual input become rules of the recipe
|
||||
|
||||
Three settings that were nowhere: whether the lot and the serial are compulsory,
|
||||
and whether a value may be typed instead of read from the caliper. Until now lot
|
||||
and serial were optional everywhere and the keypad was always available, so a
|
||||
value that happens to be in tolerance could simply be entered by hand.
|
||||
|
||||
Backfill: the two traceability flags start false, which is exactly today's
|
||||
behaviour. Manual input starts *true* on the recipes that already exist - the
|
||||
column default is false, so recipes written from now on are caliper-only, but
|
||||
flipping the ones already in use would stop a running line at the next
|
||||
measurement. Turning them off is a decision for whoever owns the recipe, taken in
|
||||
the editor, not a side effect of an upgrade.
|
||||
|
||||
Revision ID: 009_recipe_rules
|
||||
Revises: 008_loop_events
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '009_recipe_rules'
|
||||
down_revision: Union[str, None] = '008_loop_events'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'requires_lot', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'requires_serial', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'allow_manual_input', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
# Recipes already in production keep the behaviour they were written under.
|
||||
op.execute("UPDATE recipes SET allow_manual_input = 1")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('recipes', 'allow_manual_input')
|
||||
op.drop_column('recipes', 'requires_serial')
|
||||
op.drop_column('recipes', 'requires_lot')
|
||||
@@ -15,6 +15,11 @@ class RecipeCreate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
# Rules of the recipe: what the operator must supply, and how a value may be
|
||||
# entered. Manual input defaults to forbidden - the caliper is the instrument.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
# Optional task-level fields for the initial technical drawing
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
@@ -27,6 +32,9 @@ class RecipeUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
requires_lot: Optional[bool] = None
|
||||
requires_serial: Optional[bool] = None
|
||||
allow_manual_input: Optional[bool] = None
|
||||
change_notes: Optional[str] = None
|
||||
# Task-level fields: saved to the first task of the new version
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
@@ -58,6 +66,11 @@ class RecipeResponse(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
measurement_interval_minutes: Optional[int] = None
|
||||
# The operator's screen reads these to know whether to ask for lot and serial
|
||||
# before starting, and whether to offer the keypad at all.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
active: bool
|
||||
|
||||
@@ -53,6 +53,10 @@ class RecipeSummary(BaseModel):
|
||||
active: bool
|
||||
image_path: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
# The selection screen has to know before the operator starts: what a recipe
|
||||
# demands is the difference between an enabled Avvia and a disabled one.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
|
||||
@@ -26,6 +26,22 @@ class Recipe(Base):
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||
|
||||
# Traceability, decided when the recipe is written rather than left to whoever
|
||||
# is at the machine: a measurement without the lot it belongs to cannot be
|
||||
# traced back afterwards, and tracing back is half of what the system is for.
|
||||
requires_lot: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
requires_serial: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
# Whether a value may be typed instead of read from the caliper. Forbidden by
|
||||
# default: without the rule, a value that happens to be in tolerance can simply
|
||||
# be entered by hand, which is the case this setting exists to prevent.
|
||||
allow_manual_input: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
|
||||
@@ -39,6 +41,48 @@ def calculate_pass_fail(
|
||||
return "pass", deviation
|
||||
|
||||
|
||||
async def recipe_of_version(db: AsyncSession, version_id: int) -> Recipe | None:
|
||||
"""The recipe a version belongs to - where the rules of measurement live."""
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.join(RecipeVersion, RecipeVersion.recipe_id == Recipe.id)
|
||||
.where(RecipeVersion.id == version_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _enforce_recipe_rules(
|
||||
recipe: Recipe | None,
|
||||
lot_number: str | None,
|
||||
serial_number: str | None,
|
||||
input_method: str,
|
||||
) -> None:
|
||||
"""Refuse a measurement the recipe does not allow.
|
||||
|
||||
Checked here rather than only on the screen because a rule that lives in the
|
||||
frontend is a suggestion: the keypad can be hidden and the same request still
|
||||
sent. This is the one place every measurement passes through.
|
||||
"""
|
||||
if recipe is None:
|
||||
return
|
||||
|
||||
if recipe.requires_lot and not (lot_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a lot number",
|
||||
)
|
||||
if recipe.requires_serial and not (serial_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a serial number",
|
||||
)
|
||||
if input_method == "manual" and not recipe.allow_manual_input:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe does not allow values typed by hand: use the caliper",
|
||||
)
|
||||
|
||||
|
||||
async def save_measurement(
|
||||
db: AsyncSession,
|
||||
subtask_id: int,
|
||||
@@ -60,6 +104,9 @@ async def save_measurement(
|
||||
if subtask is None:
|
||||
raise ValueError(f"Subtask {subtask_id} not found")
|
||||
|
||||
recipe = await recipe_of_version(db, version_id)
|
||||
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
||||
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
|
||||
@@ -243,6 +243,20 @@ async def open_run(
|
||||
detail="Recipe has no current version",
|
||||
)
|
||||
|
||||
# Traceability the recipe declares compulsory has to be there before the first
|
||||
# measurement, not after it: a lot entered halfway through leaves the values
|
||||
# taken up to that point unattributable.
|
||||
if recipe.requires_lot and not (data.lot_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a lot number to start a production",
|
||||
)
|
||||
if recipe.requires_serial and not (data.serial_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a serial number to start a production",
|
||||
)
|
||||
|
||||
now = _now()
|
||||
# The interval is copied, not referenced: editing the recipe mid-production must
|
||||
# not move the deadline of a run already under way.
|
||||
|
||||
@@ -19,6 +19,30 @@ from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Columns of the recipe header itself, as opposed to the versioned content. Both
|
||||
# update paths - copy-on-write and in-place - write exactly these, so the list
|
||||
# lives once: a field added to one and forgotten in the other would appear to save
|
||||
# and then quietly not, depending on whether the version had measurements.
|
||||
HEADER_FIELDS = (
|
||||
"name",
|
||||
"description",
|
||||
"image_path",
|
||||
"measurement_interval_minutes",
|
||||
"requires_lot",
|
||||
"requires_serial",
|
||||
"allow_manual_input",
|
||||
)
|
||||
|
||||
|
||||
def _header_fields(data: RecipeUpdate) -> dict:
|
||||
"""The header columns the caller actually asked to change."""
|
||||
return {
|
||||
field: getattr(data, field)
|
||||
for field in HEADER_FIELDS
|
||||
if getattr(data, field) is not None
|
||||
}
|
||||
|
||||
|
||||
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||
"""Return a recipe or raise 404."""
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
@@ -138,6 +162,9 @@ async def create_recipe(
|
||||
description=data.description,
|
||||
image_path=data.image_path,
|
||||
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||
requires_lot=data.requires_lot,
|
||||
requires_serial=data.requires_serial,
|
||||
allow_manual_input=data.allow_manual_input,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
@@ -271,15 +298,7 @@ async def create_new_version(
|
||||
await db.flush()
|
||||
|
||||
# Apply header updates
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -368,16 +387,7 @@ async def update_current_version(
|
||||
data: RecipeUpdate,
|
||||
) -> RecipeVersion:
|
||||
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||
# Apply header updates (name, description, image_path)
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
|
||||
@@ -224,6 +224,10 @@ async def create_test_recipe(
|
||||
code=code,
|
||||
name=name,
|
||||
description="A recipe for testing",
|
||||
# Typing is allowed here so that tests about something else can still save
|
||||
# a measurement. The rule itself is exercised by test_recipe_rules.py, on
|
||||
# recipes that declare it explicitly.
|
||||
allow_manual_input=True,
|
||||
created_by=user_id,
|
||||
)
|
||||
session.add(recipe)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""What a recipe is allowed to demand of the operator (points 8 and 9).
|
||||
|
||||
Two rules that used to be nobody's: whether the lot and the serial are compulsory,
|
||||
and whether a value may be typed rather than read from the caliper. Both are
|
||||
declared on the recipe and enforced on the server, because a rule enforced only by
|
||||
the screen is a suggestion - the keypad can be hidden and the same request still
|
||||
sent by hand.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.recipe import Recipe, 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
|
||||
|
||||
|
||||
async def _rules(db_session, recipe: Recipe, **flags) -> Recipe:
|
||||
for key, value in flags.items():
|
||||
setattr(recipe, key, value)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(recipe)
|
||||
return recipe
|
||||
|
||||
|
||||
async def _first_subtask(db_session, recipe_id: int) -> RecipeSubtask:
|
||||
row = await db_session.execute(
|
||||
select(RecipeSubtask)
|
||||
.join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||
.join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id)
|
||||
.where(RecipeVersion.recipe_id == recipe_id)
|
||||
)
|
||||
return row.scalars().first()
|
||||
|
||||
|
||||
async def _current_version_id(db_session, recipe_id: int) -> int:
|
||||
row = await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
return row.scalar_one().id
|
||||
|
||||
|
||||
async def _measure(client, user, subtask_id, version_id, **extra):
|
||||
return await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(user),
|
||||
json={
|
||||
"subtask_id": subtask_id, "version_id": version_id, "value": 10.0, **extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The rules are part of the recipe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_new_recipe_forbids_typing_by_default(client: AsyncClient, maker_user):
|
||||
"""The caliper is the instrument: typing is what has to be asked for."""
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"code": "REG-DEF", "name": "Regole di default"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["allow_manual_input"] is False
|
||||
assert body["requires_lot"] is False
|
||||
assert body["requires_serial"] is False
|
||||
|
||||
|
||||
async def test_recipe_carries_the_rules_it_was_created_with(
|
||||
client: AsyncClient, maker_user,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"code": "REG-SET", "name": "Con regole",
|
||||
"requires_lot": True, "requires_serial": True, "allow_manual_input": True,
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert (body["requires_lot"], body["requires_serial"], body["allow_manual_input"]) \
|
||||
== (True, True, True)
|
||||
|
||||
|
||||
async def test_rules_can_be_switched_off_again(
|
||||
client: AsyncClient, maker_user, db_session,
|
||||
):
|
||||
"""False must reach the database: a flag that only ever turns on is a trap."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-OFF")
|
||||
await _rules(db_session, recipe, requires_lot=True, allow_manual_input=True)
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"requires_lot": False, "allow_manual_input": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["requires_lot"] is False
|
||||
assert resp.json()["allow_manual_input"] is False
|
||||
|
||||
|
||||
async def test_rules_survive_a_new_version(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Editing a recipe with measurements copies it; the rules must come along."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-VER")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
# A measurement on the current version forces copy-on-write on the next edit.
|
||||
await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number="L-1",
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"name": "Rinominata"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["requires_lot"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 8 - traceability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_measurement_without_the_required_lot_is_refused(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-LOT")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
refused = await _measure(client, measurement_tec_user, subtask.id, version_id)
|
||||
assert refused.status_code == 422
|
||||
|
||||
# Blank is not a lot number either.
|
||||
blank = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number=" ",
|
||||
)
|
||||
assert blank.status_code == 422
|
||||
|
||||
accepted = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number="LOT-9",
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
|
||||
|
||||
async def test_measurement_without_the_required_serial_is_refused(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-SER")
|
||||
await _rules(db_session, recipe, requires_serial=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 422
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, serial_number="SN-1",
|
||||
)).status_code == 200
|
||||
|
||||
|
||||
async def test_optional_traceability_still_accepts_nothing(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Recipes that do not ask for a lot must go on working as they did."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-OPT")
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 200
|
||||
|
||||
|
||||
async def test_production_cannot_start_without_the_required_lot(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""The lot has to be there before the first value, not after it."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-RUN")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
station = Station(
|
||||
code="ST-TRC", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
|
||||
refused = await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)
|
||||
assert refused.status_code == 422
|
||||
|
||||
started = await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"station_code": station.code, "recipe_id": recipe.id, "lot_number": "L-7",
|
||||
},
|
||||
)
|
||||
assert started.status_code == 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 9 - typed values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_typed_value_is_refused_when_the_recipe_forbids_it(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-NO")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
refused = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
|
||||
)
|
||||
assert refused.status_code == 422
|
||||
assert "caliper" in refused.json()["detail"].lower()
|
||||
|
||||
|
||||
async def test_the_caliper_is_always_welcome(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-USB")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
input_method="usb_caliper",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["input_method"] == "usb_caliper"
|
||||
|
||||
|
||||
async def test_typing_is_allowed_where_the_recipe_says_so(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-YES")
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_the_default_input_method_is_covered_by_the_rule(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""A request that says nothing about how the value was entered counts as typed.
|
||||
|
||||
Otherwise omitting the field would be the way around the rule.
|
||||
"""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-DEF")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 422
|
||||
Reference in New Issue
Block a user