diff --git a/src/backend/api/routers/production.py b/src/backend/api/routers/production.py new file mode 100644 index 0000000..5132542 --- /dev/null +++ b/src/backend/api/routers/production.py @@ -0,0 +1,137 @@ +"""Production runs router - open, read, and drive the life of a production. + +Deliberately stateless: nothing lives in process memory. With one station app +installed per PC the database is the only shared place, and any station app must be +able to ask "what is going on here?" and get the same answer. +""" +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.backend.api.middleware.api_key import get_current_user +from src.backend.database import get_db +from src.backend.models.api.production import ( + CycleCompletePayload, + ProductionEventResponse, + ProductionRunCreate, + ProductionRunResponse, + ProductionRunWithEventsResponse, + SupervisorAction, +) +from src.backend.models.orm.production import ProductionRun +from src.backend.models.orm.user import User +from src.backend.services import production_service + +router = APIRouter(prefix="/api/production-runs", tags=["production"]) + + +def _as_response(run: ProductionRun) -> ProductionRunResponse: + return ProductionRunResponse( + **ProductionRunResponse.model_validate(run).model_dump( + exclude={"seconds_to_next_measurement", "overdue", "server_time"} + ), + **production_service.describe(run), + ) + + +@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED) +async def open_production_run( + data: ProductionRunCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Start a production at a station.""" + run = await production_service.open_run(db, data, user) + return _as_response(run) + + +@router.get("/current", response_model=ProductionRunResponse | None) +async def get_current_production_run( + station_code: str = Query(..., min_length=1), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """The run open at this station, or null. + + This is what every page asks on load instead of keeping the timer in memory. + """ + 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 + + +@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse) +async def get_production_run( + run_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """A run with its full trace - the history a production did not have before.""" + 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(), + events=[ProductionEventResponse.model_validate(e) for e in events], + ) + + +@router.post("/{run_id}/cycle", response_model=ProductionRunResponse) +async def complete_measurement_cycle( + run_id: int, + payload: CycleCompletePayload | None = None, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Record a finished measurement cycle and restart 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, + ) + return _as_response(run) + + +@router.post("/{run_id}/pause", response_model=ProductionRunResponse) +async def pause_production_run( + run_id: int, + action: SupervisorAction, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Fermo linea - requires a supervisor.""" + run = await production_service.get_run(db, run_id) + supervisor = await production_service.authorise_supervisor( + db, action.supervisor_username, action.supervisor_password, + ) + run = await production_service.pause_run(db, run, user, supervisor, note=action.note) + return _as_response(run) + + +@router.post("/{run_id}/resume", response_model=ProductionRunResponse) +async def resume_production_run( + run_id: int, + action: SupervisorAction, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Restart a stopped line - requires a supervisor.""" + run = await production_service.get_run(db, run_id) + supervisor = await production_service.authorise_supervisor( + db, action.supervisor_username, action.supervisor_password, + ) + run = await production_service.resume_run(db, run, user, supervisor, note=action.note) + return _as_response(run) + + +@router.post("/{run_id}/close", response_model=ProductionRunResponse) +async def close_production_run( + run_id: int, + action: SupervisorAction, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Fine produzione - requires a supervisor. Stops the timer for good.""" + run = await production_service.get_run(db, run_id) + supervisor = await production_service.authorise_supervisor( + db, action.supervisor_username, action.supervisor_password, + ) + run = await production_service.close_run(db, run, user, supervisor, note=action.note) + return _as_response(run) diff --git a/src/backend/main.py b/src/backend/main.py index 8ddda45..a1dc9f7 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -21,6 +21,7 @@ from src.backend.api.routers.reports import router as reports_router from src.backend.api.routers.statistics import router as statistics_router from src.backend.api.routers.setup import router as setup_router from src.backend.api.routers.stations import router as stations_router +from src.backend.api.routers.production import router as production_router @asynccontextmanager @@ -73,6 +74,7 @@ app.include_router(statistics_router) app.include_router(reports_router) app.include_router(setup_router) app.include_router(stations_router) +app.include_router(production_router) @app.get("/api/health") diff --git a/src/backend/migrations/env.py b/src/backend/migrations/env.py index a1c32de..bdb9553 100644 --- a/src/backend/migrations/env.py +++ b/src/backend/migrations/env.py @@ -36,6 +36,8 @@ from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401 from src.backend.models.orm.measurement import Measurement # noqa: F401 from src.backend.models.orm.access_log import AccessLog # noqa: F401 from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401 +from src.backend.models.orm.station import Station, StationRecipeAssignment # noqa: F401 +from src.backend.models.orm.production import ProductionRun, ProductionEvent # noqa: F401 target_metadata = Base.metadata diff --git a/src/backend/migrations/versions/005_add_production_runs.py b/src/backend/migrations/versions/005_add_production_runs.py new file mode 100644 index 0000000..5c87f08 --- /dev/null +++ b/src/backend/migrations/versions/005_add_production_runs.py @@ -0,0 +1,100 @@ +"""add production_runs and production_events + +Gives a production a life of its own: before this its state lived in the Alpine +component of task_execute.html, so changing task - a full page load - lost the timer, +the cycle count and the "production started" flag. + +Revision ID: 005_production_runs +Revises: 004_input_duration +Create Date: 2026-07-28 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '005_production_runs' +down_revision: Union[str, None] = '004_input_duration' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'production_runs', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id'), nullable=False), + sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False), + sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False), + sa.Column('operator_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('lot_number', sa.String(100), nullable=True), + sa.Column('serial_number', sa.String(100), nullable=True), + sa.Column( + 'status', + sa.Enum('running', 'paused', 'closed', name='production_run_status_enum'), + nullable=False, + server_default='running', + ), + sa.Column('measurement_interval_minutes', sa.SmallInteger, nullable=True), + sa.Column('next_measurement_at', sa.DateTime, nullable=True), + sa.Column('cycle_count', sa.Integer, nullable=False, server_default='0'), + sa.Column('started_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.Column('paused_at', sa.DateTime, nullable=True), + sa.Column('closed_at', sa.DateTime, nullable=True), + sa.Column('closed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True), + # Mirrors station_id while open, NULL once closed. The unique constraint makes + # "one open run per station" a database guarantee rather than a race; repeated + # NULLs do not collide, so closed runs are free to pile up. + sa.Column('active_station_id', sa.Integer, nullable=True), + # Declared inline rather than added afterwards: adding a constraint is an ALTER, + # which SQLite cannot do, and the test databases are SQLite. + sa.UniqueConstraint('active_station_id', name='uq_production_runs_active_station'), + mysql_engine='InnoDB', + mysql_charset='utf8mb4', + ) + op.create_index('ix_production_runs_station_id', 'production_runs', ['station_id']) + op.create_index('ix_production_runs_recipe_id', 'production_runs', ['recipe_id']) + op.create_index('ix_production_runs_version_id', 'production_runs', ['version_id']) + op.create_index('ix_production_runs_operator_id', 'production_runs', ['operator_id']) + op.create_index('ix_production_runs_lot_number', 'production_runs', ['lot_number']) + op.create_index('ix_production_runs_serial_number', 'production_runs', ['serial_number']) + op.create_index('ix_production_runs_status', 'production_runs', ['status']) + op.create_index( + 'ix_production_runs_next_measurement_at', 'production_runs', ['next_measurement_at'], + ) + op.create_index( + 'ix_production_runs_station_status', 'production_runs', ['station_id', 'status'], + ) + + op.create_table( + 'production_events', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + 'run_id', sa.Integer, + sa.ForeignKey('production_runs.id', ondelete='CASCADE'), nullable=False, + ), + sa.Column( + 'event_type', + sa.Enum( + 'start', 'cycle_completed', 'line_stop', 'resume', 'close', + name='production_event_type_enum', + ), + nullable=False, + ), + sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('supervisor_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True), + sa.Column('note', sa.Text, nullable=True), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + mysql_engine='InnoDB', + mysql_charset='utf8mb4', + ) + op.create_index('ix_production_events_run_id', 'production_events', ['run_id']) + op.create_index('ix_production_events_event_type', 'production_events', ['event_type']) + op.create_index('ix_production_events_user_id', 'production_events', ['user_id']) + op.create_index('ix_production_events_created_at', 'production_events', ['created_at']) + + +def downgrade() -> None: + op.drop_table('production_events') + op.drop_table('production_runs') diff --git a/src/backend/models/api/production.py b/src/backend/models/api/production.py new file mode 100644 index 0000000..cb67ffa --- /dev/null +++ b/src/backend/models/api/production.py @@ -0,0 +1,71 @@ +"""Pydantic schemas for production runs and their events.""" +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ProductionRunCreate(BaseModel): + station_code: str = Field(..., min_length=1, max_length=100) + recipe_id: int = Field(..., gt=0) + version_id: Optional[int] = Field(default=None, gt=0) + lot_number: Optional[str] = Field(default=None, max_length=100) + serial_number: Optional[str] = Field(default=None, max_length=100) + + +class ProductionEventResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + event_type: str + user_id: int + supervisor_id: Optional[int] + note: Optional[str] + created_at: datetime + + +class ProductionRunResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + station_id: int + recipe_id: int + version_id: int + operator_id: int + lot_number: Optional[str] + serial_number: Optional[str] + status: str + measurement_interval_minutes: Optional[int] + next_measurement_at: Optional[datetime] + cycle_count: int + started_at: datetime + paused_at: Optional[datetime] + closed_at: Optional[datetime] + closed_by: Optional[int] + + # Derived server-side so every client agrees on the countdown regardless of + # clock skew. Negative once the interval has elapsed: how long the run has been + # overdue is a fact the operator must see, not deduce. + seconds_to_next_measurement: Optional[int] = None + overdue: bool = False + server_time: Optional[datetime] = None + + +class ProductionRunWithEventsResponse(ProductionRunResponse): + events: list[ProductionEventResponse] = Field(default_factory=list) + + +class SupervisorAction(BaseModel): + """Credentials of the supervisor authorising a line stop or a close.""" + + supervisor_username: str = Field(..., min_length=1) + supervisor_password: str = Field(..., min_length=1) + note: Optional[str] = None + + +class CycleCompletePayload(BaseModel): + note: Optional[str] = None + + +ProductionEventType = Literal[ + "start", "cycle_completed", "line_stop", "resume", "close", +] diff --git a/src/backend/models/orm/__init__.py b/src/backend/models/orm/__init__.py index 5753c76..92bdc04 100644 --- a/src/backend/models/orm/__init__.py +++ b/src/backend/models/orm/__init__.py @@ -6,6 +6,7 @@ from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.access_log import AccessLog from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit from src.backend.models.orm.station import Station, StationRecipeAssignment +from src.backend.models.orm.production import ProductionRun, ProductionEvent __all__ = [ "User", @@ -19,4 +20,6 @@ __all__ = [ "RecipeVersionAudit", "Station", "StationRecipeAssignment", + "ProductionRun", + "ProductionEvent", ] diff --git a/src/backend/models/orm/production.py b/src/backend/models/orm/production.py new file mode 100644 index 0000000..0440af4 --- /dev/null +++ b/src/backend/models/orm/production.py @@ -0,0 +1,152 @@ +"""ProductionRun and ProductionEvent models. + +A production run is what an operator opens when production starts and a supervisor +closes when it ends. It is the thing that outlives a page: before this, the timer, +the cycle count and the "production started" flag were Alpine variables in +task_execute.html, and navigating between tasks - a full page load - destroyed them. + +The measurement deadline is stored as an absolute timestamp (next_measurement_at) +rather than a remaining count. Any client can then derive the countdown from it, get +the same answer, survive a reload, and tell how far *past* the interval it is by +letting the difference go negative. + +With one station app installed per PC (see the install architecture of 28/07) the +database is the only place this state can live: the API keeps nothing in process +memory. +""" +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ( + DateTime, Enum, ForeignKey, Index, Integer, SmallInteger, String, Text, + UniqueConstraint, func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.backend.database import Base + +if TYPE_CHECKING: + from src.backend.models.orm.recipe import Recipe, RecipeVersion + from src.backend.models.orm.station import Station + + +RUN_STATUSES = ("running", "paused", "closed") +EVENT_TYPES = ("start", "cycle_completed", "line_stop", "resume", "close") + + +class ProductionRun(Base): + __tablename__ = "production_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + station_id: Mapped[int] = mapped_column( + Integer, ForeignKey("stations.id"), nullable=False, index=True + ) + recipe_id: Mapped[int] = mapped_column( + Integer, ForeignKey("recipes.id"), nullable=False, index=True + ) + version_id: Mapped[int] = mapped_column( + Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True + ) + operator_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + + # Traceability travels with the run instead of the Flask session, so it stays + # attached to every measurement taken during it. + lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True) + serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True) + + status: Mapped[str] = mapped_column( + Enum(*RUN_STATUSES, name="production_run_status_enum"), + nullable=False, + default="running", + index=True, + ) + + # Snapshot of the recipe interval at start: editing the recipe mid-run must not + # move the deadline of a production already under way. + measurement_interval_minutes: Mapped[Optional[int]] = mapped_column( + SmallInteger, nullable=True + ) + # When the next measurement falls due. Null when the recipe has no interval. + # Past this instant the run is overdue, and by how much is simply now - this. + next_measurement_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True, index=True + ) + cycle_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + started_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + closed_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + + # Mirrors station_id while the run is open and goes NULL when it closes. A unique + # index on it lets the database - not a check-then-insert race - guarantee that a + # station never has two open runs. Repeated NULLs do not collide in a unique index. + active_station_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + station: Mapped["Station"] = relationship(lazy="selectin") + recipe: Mapped["Recipe"] = relationship(lazy="selectin") + version: Mapped["RecipeVersion"] = relationship(lazy="selectin") + events: Mapped[list["ProductionEvent"]] = relationship( + back_populates="run", + cascade="all, delete-orphan", + order_by="ProductionEvent.created_at", + ) + + __table_args__ = ( + UniqueConstraint("active_station_id", name="uq_production_runs_active_station"), + Index("ix_production_runs_station_status", "station_id", "status"), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + @property + def is_open(self) -> bool: + return self.status in ("running", "paused") + + def __repr__(self) -> str: + return f"" + + +class ProductionEvent(Base): + """Append-only trace of what happened during a run. + + This is the record that did not exist before: without it there is no history of + a production, and nothing for the statistics file or the ERP hand-off to stand on. + """ + + __tablename__ = "production_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_id: Mapped[int] = mapped_column( + Integer, ForeignKey("production_runs.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + event_type: Mapped[str] = mapped_column( + Enum(*EVENT_TYPES, name="production_event_type_enum"), nullable=False, index=True + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + # Who authorised it, when the action needed a supervisor (fermo linea, chiusura). + supervisor_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.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 + ) + + run: Mapped["ProductionRun"] = relationship(back_populates="events") + + __table_args__ = ( + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/backend/services/production_service.py b/src/backend/services/production_service.py new file mode 100644 index 0000000..7845542 --- /dev/null +++ b/src/backend/services/production_service.py @@ -0,0 +1,349 @@ +"""Business logic for production runs. + +Routers must call into these functions rather than manipulating models directly. +All functions are async and accept an AsyncSession; they flush but do NOT commit +(commit is handled by the FastAPI get_db dependency). + +Clock convention +---------------- +Every timestamp this module writes comes from ``_now()``, so the deadline and the +comparison against it are always on the same clock. Clients are handed +``seconds_to_next_measurement`` already computed rather than the raw deadline: a +naive datetime crossing the wire would be parsed in the browser's own timezone, and +the countdown would be off by the UTC offset. The number is the contract; the +timestamp is only there for the audit trail. +""" +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +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.user import User +from src.backend.services import auth_service + + +def _now() -> datetime: + """Single source of time for runs. + + Naive local time, matching what the database writes for ``func.now()`` defaults, + so a run's started_at and its deadline are comparable. + """ + return datetime.now() + + +# --------------------------------------------------------------------------- +# Derived view +# --------------------------------------------------------------------------- + + +def seconds_to_next_measurement(run: ProductionRun, now: Optional[datetime] = None) -> Optional[int]: + """Seconds until the next measurement is due; negative once it is overdue. + + Letting the value go negative is deliberate: the operator has to see how long + the line has been past the interval, not merely that it elapsed. + While the run is paused the countdown is frozen at what was left when the line + stopped - a fermo linea must not eat into the measurement interval. + """ + if run.next_measurement_at is None: + return None + reference = run.paused_at if (run.status == "paused" and run.paused_at) else (now or _now()) + return int(round((run.next_measurement_at - reference).total_seconds())) + + +def describe(run: ProductionRun) -> dict: + """Fields the API adds on top of the stored columns.""" + now = _now() + remaining = seconds_to_next_measurement(run, now) + return { + "seconds_to_next_measurement": remaining, + "overdue": remaining is not None and remaining < 0, + "server_time": now, + } + + +# --------------------------------------------------------------------------- +# Lookups +# --------------------------------------------------------------------------- + + +async def get_run(db: AsyncSession, run_id: int) -> ProductionRun: + result = await db.execute(select(ProductionRun).where(ProductionRun.id == run_id)) + run = result.scalar_one_or_none() + if run is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found", + ) + return run + + +async def get_open_run_for_station( + db: AsyncSession, station_id: int, +) -> Optional[ProductionRun]: + """The run currently open at a station, running or paused.""" + result = await db.execute( + select(ProductionRun).where(ProductionRun.active_station_id == station_id) + ) + return result.scalar_one_or_none() + + +async def get_station_by_code(db: AsyncSession, code: str) -> Station: + result = await db.execute(select(Station).where(Station.code == code)) + station = result.scalar_one_or_none() + if station is None or not station.active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Station '{code}' not found or inactive", + ) + return station + + +async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]: + result = await db.execute( + select(ProductionEvent) + .where(ProductionEvent.run_id == run_id) + .order_by(ProductionEvent.created_at, ProductionEvent.id) + ) + return list(result.scalars().all()) + + +# --------------------------------------------------------------------------- +# Supervisor authorisation +# --------------------------------------------------------------------------- + + +async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User: + """Check the credentials of the supervisor authorising a stop or a close. + + Uses authenticate_user rather than a full login: logging in would rotate the + supervisor's API key and knock out whatever session they have open elsewhere. + """ + supervisor = await auth_service.authenticate_user(db, username, password) + if supervisor is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid supervisor credentials", + ) + if not supervisor.has_role("Supervisor") and not supervisor.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is not authorised as supervisor", + ) + return supervisor + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +def _add_event( + db: AsyncSession, + run: ProductionRun, + event_type: str, + user: User, + supervisor: Optional[User] = None, + note: Optional[str] = None, +) -> ProductionEvent: + """Append to the run's trace. + + Added through the session rather than run.events: touching the collection would + trigger a lazy load, which raises under async SQLAlchemy. + """ + event = ProductionEvent( + run_id=run.id, + event_type=event_type, + user_id=user.id, + supervisor_id=supervisor.id if supervisor else None, + note=note, + created_at=_now(), + ) + db.add(event) + return event + + +async def open_run( + db: AsyncSession, data: ProductionRunCreate, operator: User, +) -> ProductionRun: + """Start a production at a station. + + Refuses if the station already has one open: two runs on the same station would + mean two timers and two histories for one physical line. + """ + station = await get_station_by_code(db, data.station_code) + + existing = await get_open_run_for_station(db, station.id) + if existing is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Station '{station.code}' already has an open production run", + ) + + recipe_row = await db.execute(select(Recipe).where(Recipe.id == data.recipe_id)) + recipe = recipe_row.scalar_one_or_none() + if recipe is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found", + ) + + if data.version_id is not None: + version_row = await db.execute( + select(RecipeVersion).where( + RecipeVersion.id == data.version_id, + RecipeVersion.recipe_id == recipe.id, + ) + ) + version = version_row.scalar_one_or_none() + if version is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Recipe version not found for this recipe", + ) + else: + version_row = await db.execute( + select(RecipeVersion).where( + RecipeVersion.recipe_id == recipe.id, + RecipeVersion.is_current == True, # noqa: E712 + ) + ) + version = version_row.scalar_one_or_none() + if version is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Recipe has no current version", + ) + + now = _now() + # The interval is copied, not referenced: editing the recipe mid-production must + # not move the deadline of a run already under way. + interval = recipe.measurement_interval_minutes + run = ProductionRun( + station_id=station.id, + recipe_id=recipe.id, + version_id=version.id, + operator_id=operator.id, + lot_number=data.lot_number, + serial_number=data.serial_number, + status="running", + measurement_interval_minutes=interval, + next_measurement_at=( + now + timedelta(minutes=interval) if interval and interval > 0 else None + ), + cycle_count=0, + started_at=now, + active_station_id=station.id, + ) + db.add(run) + await db.flush() + _add_event(db, run, "start", operator) + await db.flush() + await db.refresh(run) + return run + + +def _require_open(run: ProductionRun) -> None: + if run.status == "closed": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Production run is already closed", + ) + + +async def complete_cycle( + db: AsyncSession, run: ProductionRun, user: User, note: Optional[str] = None, +) -> ProductionRun: + """Record a finished measurement cycle and restart the interval.""" + _require_open(run) + if run.status == "paused": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Production run is paused: resume it before measuring", + ) + + now = _now() + run.cycle_count += 1 + if run.measurement_interval_minutes and run.measurement_interval_minutes > 0: + # Measured from now, not from the previous deadline: a late measurement must + # not compress the following interval. + run.next_measurement_at = now + timedelta( + minutes=run.measurement_interval_minutes + ) + _add_event(db, run, "cycle_completed", user, note=note) + await db.flush() + await db.refresh(run) + return run + + +async def pause_run( + db: AsyncSession, + run: ProductionRun, + user: User, + supervisor: User, + note: Optional[str] = None, +) -> ProductionRun: + """Fermo linea: suspend the run and freeze the countdown.""" + _require_open(run) + if run.status == "paused": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Production run is already paused", + ) + run.status = "paused" + run.paused_at = _now() + _add_event(db, run, "line_stop", user, supervisor=supervisor, note=note) + await db.flush() + await db.refresh(run) + return run + + +async def resume_run( + db: AsyncSession, + run: ProductionRun, + user: User, + supervisor: User, + note: Optional[str] = None, +) -> ProductionRun: + """Restart a paused run, pushing the deadline out by the length of the stop.""" + _require_open(run) + if run.status != "paused": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Production run is not paused", + ) + now = _now() + if run.next_measurement_at is not None and run.paused_at is not None: + # Shift rather than recompute: whatever was left when the line stopped is + # what the operator gets back, so a stop neither grants nor costs time. + run.next_measurement_at = run.next_measurement_at + (now - run.paused_at) + run.status = "running" + run.paused_at = None + _add_event(db, run, "resume", user, supervisor=supervisor, note=note) + await db.flush() + await db.refresh(run) + return run + + +async def close_run( + db: AsyncSession, + run: ProductionRun, + user: User, + supervisor: User, + note: Optional[str] = None, +) -> ProductionRun: + """Fine produzione: close the run for good and stop the timer.""" + _require_open(run) + run.status = "closed" + run.closed_at = _now() + run.closed_by = supervisor.id + run.next_measurement_at = None + run.paused_at = None + # Releasing the slot lets the station open a new run; the unique index on this + # column is what keeps exactly one open at a time. + run.active_station_id = None + _add_event(db, run, "close", user, supervisor=supervisor, note=note) + await db.flush() + await db.refresh(run) + return run diff --git a/src/backend/tests/test_production_runs.py b/src/backend/tests/test_production_runs.py new file mode 100644 index 0000000..1d41705 --- /dev/null +++ b/src/backend/tests/test_production_runs.py @@ -0,0 +1,443 @@ +"""Integration tests for /api/production-runs. + +The point of these tables is that a production outlives the page it was started +from, so the tests lean on what must survive: the deadline, the cycle count, the +trace of what happened, and the rule that a station has one production at a time. +""" +from datetime import datetime, timedelta + +import pytest +from httpx import AsyncClient +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.services import auth_service, production_service +from src.backend.tests.conftest import auth_headers, create_test_recipe + + +async def _station(db_session, user_id: int, code: str = "ST-PROD") -> Station: + station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id) + db_session.add(station) + await db_session.commit() + await db_session.refresh(station) + return station + + +async def _recipe_with_interval(db_session, user_id: int, minutes, code="REC-PROD") -> Recipe: + recipe = await create_test_recipe(db_session, user_id=user_id, code=code) + recipe.measurement_interval_minutes = minutes + await db_session.commit() + await db_session.refresh(recipe) + return recipe + + +async def _supervisor(db_session, username="capoturno", password="TurnoPwd1"): + user = await auth_service.create_user( + db_session, + username=username, + password=password, + display_name="Capo Turno", + roles=["Supervisor"], + ) + await db_session.commit() + return user, password + + +async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None: + """Simulate wall-clock time passing, without sleeping in a test. + + Winding every instant stored on the run back by D is indistinguishable from D + having elapsed. Moving only one of them would not be time passing - it would be + rewriting history, and would measure the wrong thing. + """ + run = (await db_session.execute( + select(ProductionRun).where(ProductionRun.id == run_id) + )).scalar_one() + run.started_at = run.started_at - delta + if run.next_measurement_at is not None: + run.next_measurement_at = run.next_measurement_at - delta + if run.paused_at is not None: + run.paused_at = run.paused_at - delta + await db_session.commit() + + +async def _open(client, user, station, recipe, **extra): + return await client.post( + "/api/production-runs", + headers=auth_headers(user), + json={"station_code": station.code, "recipe_id": recipe.id, **extra}, + ) + + +# --------------------------------------------------------------------------- +# Opening +# --------------------------------------------------------------------------- + + +async def test_open_run_requires_auth(client: AsyncClient): + resp = await client.post("/api/production-runs", json={"station_code": "X", "recipe_id": 1}) + assert resp.status_code == 401 + + +async def test_open_run_starts_the_countdown( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id) + recipe = await _recipe_with_interval(db_session, admin_user.id, minutes=30) + + resp = await _open(client, measurement_tec_user, station, recipe) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["status"] == "running" + assert body["cycle_count"] == 0 + assert body["measurement_interval_minutes"] == 30 + # About half an hour out, allowing for the round trip. + assert 29 * 60 <= body["seconds_to_next_measurement"] <= 30 * 60 + assert body["overdue"] is False + + +async def test_open_run_without_interval_has_no_deadline( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-NOINT") + recipe = await _recipe_with_interval(db_session, admin_user.id, None, code="REC-NOINT") + + body = (await _open(client, measurement_tec_user, station, recipe)).json() + assert body["next_measurement_at"] is None + assert body["seconds_to_next_measurement"] is None + assert body["overdue"] is False + + +async def test_open_run_snapshots_the_interval( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + """Editing the recipe mid-production must not move a running deadline.""" + station = await _station(db_session, admin_user.id, code="ST-SNAP") + recipe = await _recipe_with_interval(db_session, admin_user.id, 20, code="REC-SNAP") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + + recipe.measurement_interval_minutes = 90 + await db_session.commit() + + resp = await client.get( + f"/api/production-runs/{run_id}", headers=auth_headers(measurement_tec_user), + ) + assert resp.json()["measurement_interval_minutes"] == 20 + + +async def test_station_cannot_have_two_open_runs( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-ONE") + recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ONE") + first = await _open(client, measurement_tec_user, station, recipe) + assert first.status_code == 201 + + second = await _open(client, measurement_tec_user, station, recipe) + assert second.status_code == 409 + + +async def test_open_run_rejects_unknown_station( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-NOSTAT") + resp = await client.post( + "/api/production-runs", + headers=auth_headers(measurement_tec_user), + json={"station_code": "ST-GHOST", "recipe_id": recipe.id}, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Reading the current run - what replaces the in-page state +# --------------------------------------------------------------------------- + + +async def test_current_run_is_null_when_nothing_is_running( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-IDLE") + resp = await client.get( + "/api/production-runs/current", + params={"station_code": station.code}, + headers=auth_headers(measurement_tec_user), + ) + assert resp.status_code == 200 + assert resp.json() is None + + +async def test_current_run_survives_and_keeps_counting_down( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + """The deadline is stored, so a fresh page load rejoins the same countdown.""" + station = await _station(db_session, admin_user.id, code="ST-KEEP") + recipe = await _recipe_with_interval(db_session, admin_user.id, 45, code="REC-KEEP") + opened = (await _open(client, measurement_tec_user, station, recipe)).json() + + later = await client.get( + "/api/production-runs/current", + params={"station_code": station.code}, + headers=auth_headers(measurement_tec_user), + ) + body = later.json() + assert body["id"] == opened["id"] + assert body["next_measurement_at"] == opened["next_measurement_at"] + assert body["seconds_to_next_measurement"] <= opened["seconds_to_next_measurement"] + + +# --------------------------------------------------------------------------- +# Cycles +# --------------------------------------------------------------------------- + + +async def test_cycle_restarts_the_interval_and_counts( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-CYC") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CYC") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + + resp = await client.post( + f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user), + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["cycle_count"] == 1 + assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60 + + +async def test_overdue_run_reports_negative_seconds( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + """Past the interval the countdown keeps going the other way, so the delay shows.""" + station = await _station(db_session, admin_user.id, code="ST-LATE") + recipe = await _recipe_with_interval(db_session, admin_user.id, 5, code="REC-LATE") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + + await _advance_clock(db_session, run_id, timedelta(minutes=8)) + + body = (await client.get( + "/api/production-runs/current", + params={"station_code": station.code}, + headers=auth_headers(measurement_tec_user), + )).json() + assert body["overdue"] is True + assert -190 <= body["seconds_to_next_measurement"] <= -170 + + +# --------------------------------------------------------------------------- +# Fermo linea / ripresa / chiusura +# --------------------------------------------------------------------------- + + +async def test_pause_requires_supervisor_credentials( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-PAUSE1") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE1") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + + resp = await client.post( + f"/api/production-runs/{run_id}/pause", + headers=auth_headers(measurement_tec_user), + json={"supervisor_username": "nobody", "supervisor_password": "wrong"}, + ) + assert resp.status_code == 401 + + +async def test_pause_rejects_non_supervisor( + client: AsyncClient, measurement_tec_user, maker_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-PAUSE2") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE2") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + + resp = await client.post( + f"/api/production-runs/{run_id}/pause", + headers=auth_headers(measurement_tec_user), + json={ + "supervisor_username": maker_user.username, + "supervisor_password": "testpassword123", + }, + ) + assert resp.status_code in (401, 403) + + +async def test_pause_freezes_the_countdown( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + """A fermo linea must not eat the measurement interval.""" + station = await _station(db_session, admin_user.id, code="ST-FREEZE") + recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-FREEZE") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + supervisor, password = await _supervisor(db_session, username="capo-freeze") + + paused = (await client.post( + f"/api/production-runs/{run_id}/pause", + headers=auth_headers(measurement_tec_user), + json={"supervisor_username": supervisor.username, "supervisor_password": password}, + )).json() + assert paused["status"] == "paused" + frozen = paused["seconds_to_next_measurement"] + + # An hour goes by with the line stopped: were the countdown still ticking, the + # run would now be deeply overdue. + await _advance_clock(db_session, run_id, timedelta(hours=1)) + + still = (await client.get( + "/api/production-runs/current", + params={"station_code": station.code}, + headers=auth_headers(measurement_tec_user), + )).json() + assert still["status"] == "paused" + assert still["seconds_to_next_measurement"] == frozen + assert still["overdue"] is False + + +async def test_resume_gives_back_the_time_the_stop_took( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-RESUME") + recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-RESUME") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + supervisor, password = await _supervisor(db_session, username="capo-resume") + creds = {"supervisor_username": supervisor.username, "supervisor_password": password} + + frozen = (await client.post( + f"/api/production-runs/{run_id}/pause", + headers=auth_headers(measurement_tec_user), json=creds, + )).json()["seconds_to_next_measurement"] + + # The line stays down for ten minutes. + await _advance_clock(db_session, run_id, timedelta(minutes=10)) + + resumed = (await client.post( + f"/api/production-runs/{run_id}/resume", + headers=auth_headers(measurement_tec_user), json=creds, + )).json() + assert resumed["status"] == "running" + # The ten minutes of stop were handed back, not charged to the operator. + assert abs(resumed["seconds_to_next_measurement"] - frozen) <= 2 + + +async def test_cycle_refused_while_paused( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-PCYC") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PCYC") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + supervisor, password = await _supervisor(db_session, username="capo-pcyc") + + 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}/cycle", headers=auth_headers(measurement_tec_user), + ) + assert resp.status_code == 409 + + +async def test_close_stops_the_timer_and_frees_the_station( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-CLOSE") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CLOSE") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + supervisor, password = await _supervisor(db_session, username="capo-close") + + closed = (await client.post( + f"/api/production-runs/{run_id}/close", + headers=auth_headers(measurement_tec_user), + json={"supervisor_username": supervisor.username, "supervisor_password": password}, + )).json() + assert closed["status"] == "closed" + assert closed["closed_by"] == supervisor.id + assert closed["seconds_to_next_measurement"] is None + + # Nothing is open at the station any more... + current = await client.get( + "/api/production-runs/current", + params={"station_code": station.code}, + headers=auth_headers(measurement_tec_user), + ) + assert current.json() is None + + # ...and a new production can start there. + again = await _open(client, measurement_tec_user, station, recipe) + assert again.status_code == 201 + + +async def test_closed_run_refuses_further_actions( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-DEAD") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-DEAD") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + supervisor, password = await _supervisor(db_session, username="capo-dead") + creds = {"supervisor_username": supervisor.username, "supervisor_password": password} + + await client.post( + f"/api/production-runs/{run_id}/close", + headers=auth_headers(measurement_tec_user), json=creds, + ) + cycle = await client.post( + f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user), + ) + assert cycle.status_code == 409 + pause = await client.post( + f"/api/production-runs/{run_id}/pause", + headers=auth_headers(measurement_tec_user), json=creds, + ) + assert pause.status_code == 409 + + +# --------------------------------------------------------------------------- +# The trace +# --------------------------------------------------------------------------- + + +async def test_run_records_what_happened( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + """The history a production never had: start, cycles, stop, resume, close.""" + station = await _station(db_session, admin_user.id, code="ST-TRACE") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-TRACE") + run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"] + supervisor, password = await _supervisor(db_session, username="capo-trace") + creds = {"supervisor_username": supervisor.username, "supervisor_password": password} + headers = auth_headers(measurement_tec_user) + + await client.post(f"/api/production-runs/{run_id}/cycle", headers=headers) + await client.post(f"/api/production-runs/{run_id}/pause", headers=headers, json=creds) + await client.post(f"/api/production-runs/{run_id}/resume", headers=headers, json=creds) + await client.post(f"/api/production-runs/{run_id}/close", headers=headers, json=creds) + + events = (await client.get( + f"/api/production-runs/{run_id}", headers=headers, + )).json()["events"] + assert [e["event_type"] for e in events] == [ + "start", "cycle_completed", "line_stop", "resume", "close", + ] + # Who authorised what is on the record. + by_type = {e["event_type"]: e for e in events} + assert by_type["line_stop"]["supervisor_id"] == supervisor.id + assert by_type["close"]["supervisor_id"] == supervisor.id + assert by_type["cycle_completed"]["supervisor_id"] is None + + +async def test_traceability_travels_with_the_run( + client: AsyncClient, measurement_tec_user, admin_user, db_session, +): + station = await _station(db_session, admin_user.id, code="ST-LOT") + recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LOT") + body = (await _open( + client, measurement_tec_user, station, recipe, + lot_number="LOT-42", serial_number="SN-7", + )).json() + assert body["lot_number"] == "LOT-42" + assert body["serial_number"] == "SN-7" diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 6d6d669..e096e72 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -414,6 +414,67 @@ def validate_supervisor(): return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200 +# --------------------------------------------------------------------------- +# Routes: Production run (state that must outlive the page) +# --------------------------------------------------------------------------- +@measure_bp.route("/api/production/current", methods=["GET"]) +@login_required +@role_required("MeasurementTec") +def api_current_production(): + """Proxy: the production open at this station, or null. + + Every page asks this on load. Before, the timer and the cycle count lived in the + Alpine component and a change of task - a full page load - wiped them. + """ + station_code, _overridden = _current_station() + if not station_code: + return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503 + + resp = api_client.get( + "/api/production-runs/current", params={"station_code": station_code}, + ) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + +@measure_bp.route("/api/production/start", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_start_production(): + """Proxy: open a production at this station.""" + station_code, _overridden = _current_station() + if not station_code: + return jsonify({"error": True, "detail": _("Stazione non configurata")}), 503 + + data = request.get_json(silent=True) or {} + payload = { + "station_code": station_code, + "recipe_id": data.get("recipe_id"), + "version_id": data.get("version_id"), + "lot_number": data.get("lot_number") or session.get("lot_number") or None, + "serial_number": data.get("serial_number") or session.get("serial_number") or None, + } + resp = api_client.post("/api/production-runs", data=payload) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 201 + + +@measure_bp.route("/api/production//cycle", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_complete_cycle(run_id: int): + """Proxy: record a finished measurement cycle and restart the interval.""" + data = request.get_json(silent=True) or {} + resp = api_client.post( + f"/api/production-runs/{run_id}/cycle", data={"note": data.get("note")}, + ) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + # --------------------------------------------------------------------------- # Route: File proxy (browser can't send X-API-Key directly) # --------------------------------------------------------------------------- diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index ffc8abd..d17140a 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -428,6 +428,26 @@ + {# ================================================================ + PRODUCTION ERROR — the server refused to record the production state. + Shown because the fallback keeps the operator working locally, and a timer + that is not backed by the server must never look like one that is. + ================================================================ #} +
+
+ + + + + {{ _('Produzione non registrata sul server') }}: + + +
+
+ {# ================================================================ AVVIO PRODUZIONE — visible after first cycle, before production started ================================================================ #} @@ -709,6 +729,8 @@ function taskExecute() { showCompletionOverlay: false, // ---- Measurement timer ---- + // The interval is only used for display: the deadline itself lives on the server, + // so it survives the page. See loadProductionRun(). measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }}, timerActive: false, timerRemaining: 0, @@ -716,6 +738,12 @@ function taskExecute() { cycleCount: 0, productionStarted: false, + // ---- Production run (server-side state) ---- + // Navigating between tasks is a full page load, so anything kept only here dies. + // This is read back from the server on every load instead. + productionRun: null, + productionError: '', + // ---- Cycle & workflow state ---- cycleConfirmed: false, showSupervisorModal: false, @@ -812,6 +840,39 @@ function taskExecute() { init() { this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); this.inputStartedAt = Date.now(); + // Rejoin whatever production is already open at this station. + this.loadProductionRun(); + }, + + // ---- Production run: read the state back from the server ---- + + async loadProductionRun() { + try { + const resp = await fetch('{{ url_for("measure.api_current_production") }}'); + if (!resp.ok) return; + const run = await resp.json(); + if (run && run.id) this.adoptProductionRun(run); + } catch (e) { + // Offline or server down: leave the page usable, just without the timer. + } + }, + + /* Take the server's word for the state of the production. Called on load and + after every action that changes it, so the page never drifts from the truth. */ + adoptProductionRun(run) { + this.productionRun = run; + this.productionStarted = run.status !== 'closed'; + this.cycleCount = run.cycle_count; + + const seconds = run.seconds_to_next_measurement; + if (seconds === null || seconds === undefined || run.status !== 'running') { + this.stopMeasurementTimer(); + return; + } + // The server hands over seconds already computed rather than a timestamp: a + // naive datetime would be read in the browser's timezone and the countdown + // would be off by the UTC offset. + this.startCountdownFrom(seconds); }, // ---- Check if a subtask has been measured ---- @@ -953,21 +1014,57 @@ function taskExecute() { }, // ---- Confirm measurement cycle (Fine ciclo misura) ---- - confirmCycle() { + async confirmCycle() { this.cycleConfirmed = true; this.showCompletionOverlay = false; - this.cycleCount++; - // Start measurement timer if recipe has an interval + // Recorded server-side so the count and the next deadline outlive this page. + if (this.productionRun) { + const run = await this.postProduction( + '{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'), + ); + if (run) { + this.adoptProductionRun(run); + return; + } + } + + // No production open (recipe run outside a production): keep the old local + // behaviour rather than leaving the operator without a timer. + this.cycleCount++; if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) { - this.startMeasurementTimer(); + this.startCountdownFrom(this.measurementIntervalMinutes * 60); + } + }, + + /* POST to a production endpoint, returning the updated run or null on failure. */ + async postProduction(url, body) { + this.productionError = ''; + try { + const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || ''; + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, + body: JSON.stringify(body || {}), + }); + const data = await resp.json().catch(() => null); + if (!resp.ok) { + this.productionError = (data && data.detail) || '{{ _("Errore di comunicazione con il server") }}'; + return null; + } + return data; + } catch (e) { + this.productionError = '{{ _("Errore di connessione") }}'; + return null; } }, // ---- Measurement timer ---- - startMeasurementTimer() { + /* Ticks locally for a smooth display, but the number it starts from always comes + from the server, and every page load resynchronises it. */ + startCountdownFrom(seconds) { this.stopMeasurementTimer(); - this.timerRemaining = this.measurementIntervalMinutes * 60; + this.timerRemaining = seconds; this.timerActive = true; var self = this; this._timerInterval = setInterval(function () { @@ -1014,11 +1111,26 @@ function taskExecute() { } catch (_) {} }, - // ---- Avvio Produzione (GAIA placeholder) ---- - startProduction() { - this.productionStarted = true; - // TODO: integrazione GAIA — inviare segnale per avviare timer linea - // await fetch('/measure/api/gaia/start-production', { method: 'POST', ... }); + // ---- Avvio Produzione ---- + /* Opens a production run on the server. That row is what makes the timer, the + cycle count and the history survive a change of task. + The hand-off to the ERP (GAIA) plugs in on top of this, once the protocol is + agreed: everything before it works without waiting for that. */ + async startProduction() { + const run = await this.postProduction( + '{{ url_for("measure.api_start_production") }}', + { + recipe_id: this.task.recipe_id, + version_id: this.task.version_id, + lot_number: this.lotNumber || null, + serial_number: this.serialNumber || null, + }, + ); + if (run) { + this.adoptProductionRun(run); + return true; + } + return false; }, // Is the current task the last one in the recipe sequence? @@ -1030,14 +1142,22 @@ function taskExecute() { // Start production from the completion overlay (last START task) and begin // the measurement-interval cycle if the recipe defines one. - startProductionFromOverlay() { + async startProductionFromOverlay() { this.showCompletionOverlay = false; - this.startProduction(); - this.cycleConfirmed = true; - this.cycleCount++; - if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) { - this.startMeasurementTimer(); + const opened = await this.startProduction(); + if (!opened) { + // Server refused: keep the operator working rather than stranding them + // mid-shift, but productionError is on screen so it is not silent. + this.cycleConfirmed = true; + this.cycleCount++; + if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) { + this.startCountdownFrom(this.measurementIntervalMinutes * 60); + } + return; } + // The START-phase measurements have just been taken: record them as the first + // cycle, which is also what starts the interval running. + await this.confirmCycle(); }, get timerDisplay() { diff --git a/src/frontend/flask_app/tests/test_measure_production.py b/src/frontend/flask_app/tests/test_measure_production.py new file mode 100644 index 0000000..641d625 --- /dev/null +++ b/src/frontend/flask_app/tests/test_measure_production.py @@ -0,0 +1,162 @@ +"""Tests for the production-run proxy routes. + +These are what let task_execute.html read its state back from the server instead of +keeping the timer in an Alpine variable that a change of task destroys. +""" +import importlib +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def _restore_measure_module(): + """Reload config/measure after each test so env tweaks don't leak.""" + yield + import config + importlib.reload(config) + import blueprints.measure + importlib.reload(blueprints.measure) + + +def _with_station(monkeypatch, station_code="ST-PROD"): + if station_code is None: + monkeypatch.delenv("STATION_CODE", raising=False) + else: + monkeypatch.setenv("STATION_CODE", station_code) + monkeypatch.setenv("STATION_SWITCH_ENABLED", "0") + import config + importlib.reload(config) + import blueprints.measure + importlib.reload(blueprints.measure) + from blueprints import measure as measure_mod + return measure_mod + + +RUN = { + "id": 7, + "station_id": 1, + "recipe_id": 3, + "version_id": 5, + "operator_id": 2, + "lot_number": "LOT-1", + "serial_number": None, + "status": "running", + "measurement_interval_minutes": 30, + "next_measurement_at": "2026-07-28T17:00:00", + "cycle_count": 2, + "started_at": "2026-07-28T16:00:00", + "paused_at": None, + "closed_at": None, + "closed_by": None, + "seconds_to_next_measurement": 1500, + "overdue": False, + "server_time": "2026-07-28T16:35:00", +} + + +def test_current_production_passes_the_station(logged_in_client, monkeypatch): + measure_mod = _with_station(monkeypatch) + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.return_value = RUN + resp = logged_in_client.get("/measure/api/production/current") + assert resp.status_code == 200 + assert resp.get_json()["id"] == 7 + mock_api.get.assert_called_once_with( + "/api/production-runs/current", params={"station_code": "ST-PROD"}, + ) + + +def test_current_production_returns_null_when_idle(logged_in_client, monkeypatch): + """No production open is a normal answer, not an error.""" + measure_mod = _with_station(monkeypatch) + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.get.return_value = None + resp = logged_in_client.get("/measure/api/production/current") + assert resp.status_code == 200 + assert resp.get_json() is None + + +def test_current_production_without_station_is_503(logged_in_client, monkeypatch): + _with_station(monkeypatch, station_code=None) + resp = logged_in_client.get("/measure/api/production/current") + assert resp.status_code == 503 + + +def test_start_production_sends_station_and_recipe(logged_in_client, monkeypatch): + measure_mod = _with_station(monkeypatch) + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = RUN + resp = logged_in_client.post( + "/measure/api/production/start", + json={"recipe_id": 3, "version_id": 5, "lot_number": "LOT-1"}, + ) + assert resp.status_code == 201 + endpoint, kwargs = mock_api.post.call_args + assert endpoint[0] == "/api/production-runs" + sent = kwargs["data"] + assert sent["station_code"] == "ST-PROD" + assert sent["recipe_id"] == 3 + assert sent["version_id"] == 5 + assert sent["lot_number"] == "LOT-1" + + +def test_start_production_falls_back_to_session_traceability(logged_in_client, monkeypatch): + """Lot and serial already captured in the session must reach the run.""" + measure_mod = _with_station(monkeypatch) + with logged_in_client.session_transaction() as sess: + sess["lot_number"] = "LOT-SESSION" + sess["serial_number"] = "SN-SESSION" + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = RUN + logged_in_client.post("/measure/api/production/start", json={"recipe_id": 3}) + sent = mock_api.post.call_args[1]["data"] + assert sent["lot_number"] == "LOT-SESSION" + assert sent["serial_number"] == "SN-SESSION" + + +def test_start_production_propagates_conflict(logged_in_client, monkeypatch): + """A station already running a production must not silently open a second.""" + measure_mod = _with_station(monkeypatch) + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = { + "error": True, "status_code": 409, + "detail": "Station 'ST-PROD' already has an open production run", + } + resp = logged_in_client.post( + "/measure/api/production/start", json={"recipe_id": 3}, + ) + assert resp.status_code == 409 + + +def test_complete_cycle_hits_the_run(logged_in_client, monkeypatch): + measure_mod = _with_station(monkeypatch) + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = {**RUN, "cycle_count": 3} + resp = logged_in_client.post("/measure/api/production/7/cycle", json={}) + assert resp.status_code == 200 + assert resp.get_json()["cycle_count"] == 3 + endpoint, kwargs = mock_api.post.call_args + assert endpoint[0] == "/api/production-runs/7/cycle" + + +def test_complete_cycle_propagates_paused_conflict(logged_in_client, monkeypatch): + measure_mod = _with_station(monkeypatch) + with patch.object(measure_mod, "api_client") as mock_api: + mock_api.post.return_value = { + "error": True, "status_code": 409, + "detail": "Production run is paused: resume it before measuring", + } + resp = logged_in_client.post("/measure/api/production/7/cycle", json={}) + assert resp.status_code == 409 + + +def test_production_routes_require_login(client, monkeypatch): + _with_station(monkeypatch) + for method, url in ( + ("get", "/measure/api/production/current"), + ("post", "/measure/api/production/start"), + ("post", "/measure/api/production/7/cycle"), + ): + resp = getattr(client, method)(url) + assert resp.status_code in (302, 401), f"{method} {url} -> {resp.status_code}" diff --git a/src/frontend/flask_app/tests/test_template_js_syntax.py b/src/frontend/flask_app/tests/test_template_js_syntax.py index 2764ed0..2f8ca05 100644 --- a/src/frontend/flask_app/tests/test_template_js_syntax.py +++ b/src/frontend/flask_app/tests/test_template_js_syntax.py @@ -79,10 +79,8 @@ def _alpine_attribute_expressions(html: str): yield name, value -def _node_check(source: str, label: str) -> None: - """Fail the test if `node --check` rejects `source`.""" - if not source.strip(): - return +def _node_syntax_error(source: str) -> str | None: + """Return node's complaint about `source`, or None if it parses.""" node = shutil.which("node") if node is None: pytest.skip("node binary not found on PATH; cannot validate JS syntax") @@ -100,31 +98,56 @@ def _node_check(source: str, label: str) -> None: finally: os.unlink(path) - if result.returncode != 0: - snippet = source.strip() - if len(snippet) > 600: - snippet = snippet[:600] + "\n…(truncated)…" - pytest.fail( - f"{label}: node rejected this code as invalid JS.\n" - f"--- node stderr ---\n{result.stderr.strip()}\n" - f"--- source (first 600 chars) ---\n{snippet}" - ) + return None if result.returncode == 0 else result.stderr.strip() + + +def _fail(label: str, source: str, stderr: str) -> None: + snippet = source.strip() + if len(snippet) > 600: + snippet = snippet[:600] + "\n…(truncated)…" + pytest.fail( + f"{label}: node rejected this code as invalid JS.\n" + f"--- node stderr ---\n{stderr}\n" + f"--- source (first 600 chars) ---\n{snippet}" + ) + + +def _node_check(source: str, label: str) -> None: + """Fail the test if `node --check` rejects `source`.""" + if not source.strip(): + return + stderr = _node_syntax_error(source) + if stderr is not None: + _fail(label, source, stderr) def _check_alpine_attributes(html: str, page_label: str) -> None: """Validate every Alpine expression attribute on the page. - Wraps each value in `void (…)` so node parses it as an expression rather - than a statement. Function-body forms like `async () => { … }` parse fine - inside that wrapper too. + Alpine accepts an attribute either as a single expression or as a sequence of + statements, so the check accepts a value that parses as either and only fails + when both readings are rejected. Neither form alone is enough: + + :class="{ 'dark': $store.theme.dark }" only as an expression + @click="showOverlay = false; confirmed = true" only as statements + + An unterminated string literal - the bug this file exists for - is a syntax + error under both, so the guard is unchanged. """ for name, value in _alpine_attribute_expressions(html): - # Some Alpine attrs accept a function call shorthand (e.g. - # x-data="myComponent(window.__x)"); those parse fine as expressions. - wrapper = f"void ({value});\n" - _node_check( - wrapper, - f"{page_label} attribute {name}=\"…\" did not parse as JS", + as_expression = f"void (\n{value}\n);\n" + as_statements = f"(() => {{\n{value}\n}});\n" + + expression_error = _node_syntax_error(as_expression) + if expression_error is None: + continue + if _node_syntax_error(as_statements) is None: + continue + _fail( + f"{page_label} attribute {name}=\"…\" parsed neither as an expression " + "nor as statements", + as_expression, + expression_error, ) @@ -192,3 +215,52 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api): _node_check(body, f"/admin/users script[{i}]") _check_alpine_attributes(html, "/admin/users") + + +@pytest.fixture +def mock_measure_api(): + """Patch api_client used inside the measure blueprint.""" + mock = MagicMock() + with patch("blueprints.measure.api_client", mock): + yield mock + + +def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api): + """The measurement screen carries the most inline JS in the app. + + It drives the numpad, the caliper burst detection, the supervisor modal and - + since the production run moved server-side - the countdown. A broken literal + here kills every binding on the screen the operator actually works in. + """ + _force_italian(logged_in_client) + task = { + "id": 11, + "recipe_id": 3, + "version_id": 5, + "title": "Quota d'ingresso", + "description": "Misura l'altezza", + "file_path": None, + "file_type": None, + "annotations_json": None, + "subtasks": [{ + "id": 21, "marker_number": 1, "name": "Altezza", "order_index": 0, + "nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5, + "unit": "mm", + }], + } + mock_measure_api.get.side_effect = [ + task, # /api/tasks/11 + [{"id": 11, "order_index": 0}], # /api/recipes/3/tasks + {"id": 3, "measurement_interval_minutes": 30}, # /api/recipes/3 + ] + + resp = logged_in_client.get("/measure/execute/11") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + + scripts = _INLINE_SCRIPT_RX.findall(html) + assert scripts, "expected at least one inline