Compare commits
35 Commits
55e5e0153f
..
V3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a6f62089 | |||
| f20b92bafb | |||
| 6bfe3a5d1a | |||
| 5717b3e7aa | |||
| 85d7976f4e | |||
| a72e3f11af | |||
| 46566ebfa5 | |||
| 0bd95367bb | |||
| 38ef0587f5 | |||
| 533edc5be8 | |||
| 3870cc3b0f | |||
| 398af016f8 | |||
| b86eeada86 | |||
| ddf7788d77 | |||
| 05f2937611 | |||
| 71da162e1f | |||
| 9387e7c306 | |||
| 0a3d1f5222 | |||
| c5f3366dd1 | |||
| 9546ded1e8 | |||
| b578ac9e1b | |||
| cb0fab8c92 | |||
| 767bede43b | |||
| 245975791f | |||
| e0f32fe8a9 | |||
| 2101b9b2c9 | |||
| c4a429d952 | |||
| 78816dfe8c | |||
| 98b1c5ae85 | |||
| 919ee7ac79 | |||
| e2aae88858 | |||
| 5aa3d595ad | |||
| bde8fafd77 | |||
| 6fbff2fe76 | |||
| 7bc3c1f938 |
@@ -30,6 +30,15 @@ STATION_CODE=ST-DEFAULT
|
|||||||
# another station's recipes would silently break traceability.
|
# another station's recipes would silently break traceability.
|
||||||
STATION_SWITCH_ENABLED=0
|
STATION_SWITCH_ENABLED=0
|
||||||
|
|
||||||
|
# --- Vision ---
|
||||||
|
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
||||||
|
VISION_WORKER_URL=http://vision:8100
|
||||||
|
# The pinned VisionSuite commit, stamped into the vision image at build time
|
||||||
|
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
|
||||||
|
# immediately without it. Set with:
|
||||||
|
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
|
||||||
|
VISION_ENGINE_VERSION=
|
||||||
|
|
||||||
# --- File Storage ---
|
# --- File Storage ---
|
||||||
# Resolved against the project root in src/backend/config.py.
|
# Resolved against the project root in src/backend/config.py.
|
||||||
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ uploads/pdfs/*
|
|||||||
uploads/logos/*
|
uploads/logos/*
|
||||||
uploads/reports/*
|
uploads/reports/*
|
||||||
uploads/general/
|
uploads/general/
|
||||||
|
# Statistics files emitted when a production is closed: shop-floor evidence, not
|
||||||
|
# source. They live on the mounted volume in production.
|
||||||
|
uploads/statistics/
|
||||||
!uploads/images/.gitkeep
|
!uploads/images/.gitkeep
|
||||||
!uploads/pdfs/.gitkeep
|
!uploads/pdfs/.gitkeep
|
||||||
!uploads/logos/.gitkeep
|
!uploads/logos/.gitkeep
|
||||||
@@ -74,3 +77,6 @@ nul
|
|||||||
# Competitor analysis (local only)
|
# Competitor analysis (local only)
|
||||||
Concorrente/
|
Concorrente/
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/visionsuite"]
|
||||||
|
path = vendor/visionsuite
|
||||||
|
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
||||||
+7
-1
@@ -20,8 +20,14 @@ RUN uv sync --frozen --no-dev --extra client
|
|||||||
COPY src/frontend/flask_app/ ./flask_app/
|
COPY src/frontend/flask_app/ ./flask_app/
|
||||||
|
|
||||||
# Build TailwindCSS (one-shot; no watcher in production image).
|
# Build TailwindCSS (one-shot; no watcher in production image).
|
||||||
|
#
|
||||||
|
# Versione esatta, non `tailwindcss@3`. Con il vincolo aperto ogni ricostruzione
|
||||||
|
# dell'immagine poteva prendere una minor diversa e produrre un CSS diverso senza
|
||||||
|
# che nessuno l'avesse validato: per un sistema che produce evidenze per audit
|
||||||
|
# ISO 9001 / IATF 16949 è lo stesso problema che il punto 12 risolve per le
|
||||||
|
# librerie del browser. Aggiornarla è una decisione, non un effetto collaterale.
|
||||||
WORKDIR /app/flask_app
|
WORKDIR /app/flask_app
|
||||||
RUN npm install tailwindcss@3 && \
|
RUN npm install tailwindcss@3.4.19 && \
|
||||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
|
||||||
# Compile Flask-Babel translation catalogs.
|
# Compile Flask-Babel translation catalogs.
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# opencv-python (pulled in transitively by vs-core/vs-task/vs-measure/vs-pm2d)
|
||||||
|
# needs libGL and glib at runtime even though it is never imported by name here.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
RUN uv sync --extra vision-worker --frozen --no-dev
|
||||||
|
|
||||||
|
COPY src/vision/ ./src/vision/
|
||||||
|
COPY src/vision_worker/ ./src/vision_worker/
|
||||||
|
|
||||||
|
# Ruling R8: the version is stamped at build time, not discovered at runtime.
|
||||||
|
# COPY vendor/ above does not bring vendor/visionsuite/.git along - it is a
|
||||||
|
# gitlink file pointing at ../../.git/modules/vendor/visionsuite, which lives
|
||||||
|
# outside the build context - so `git rev-parse HEAD` cannot work in here.
|
||||||
|
# Pass the submodule's commit in from the host, which does have git:
|
||||||
|
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
# docker compose build vision
|
||||||
|
ARG VISION_ENGINE_VERSION
|
||||||
|
# I4: a build without the commit must fail now, at build time - not later, at
|
||||||
|
# every request including /health, with a container Docker still reports as
|
||||||
|
# healthy because no healthcheck existed to say otherwise.
|
||||||
|
RUN test -n "$VISION_ENGINE_VERSION" || { \
|
||||||
|
echo "VISION_ENGINE_VERSION is required. Build with:"; \
|
||||||
|
echo ' VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) docker compose build vision'; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
|
||||||
|
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Two workers, not four: each holds the vision stack in memory.
|
||||||
|
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
|
||||||
|
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||||
|
|
||||||
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
> **Versione corrente:** V3.0.0 (in sviluppo) — branch `V3.0.0`.
|
||||||
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||||
|
> V3.0.0 lavora sui quindici punti di `TieMeasureFlow_modifiche_2026-07-28.md`: vedi [Novità di V3.0.0](#novità-di-v300).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,6 +26,32 @@ Caratteristiche principali:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Novità di V3.0.0
|
||||||
|
|
||||||
|
V3.0.0 lavora sui quindici punti raccolti in `TieMeasureFlow_modifiche_2026-07-28.md`,
|
||||||
|
dal sopralluogo del 28/07. Quanto segue è **fatto e in esercizio**.
|
||||||
|
|
||||||
|
| # | Punto | Cosa cambia |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 · 6 | **Produzione con una vita propria** | `production_runs` e `production_events` sul server: avvio, fermo linea, ripresa e fine produzione esistono come stato, non come pulsanti. La fine produzione emette il file di statistica dell'intera produzione |
|
||||||
|
| 2 | **Tipo di task dichiarato** | `task_type` (`note`, `measure`, `drawing`, `xf_compare`, `camera_measure`) al posto di «ha subtask, quindi è una misura»: un task di misura senza quote non si comporta più come una nota |
|
||||||
|
| 3 | **Ciclo di misura** | L'intervallo della ricetta vive sul server, sopravvive al cambio pagina, conta anche il ritardo (in rosso, oltre lo zero) e riporta l'operatore alla misura. Cicalino via WebAudio |
|
||||||
|
| 5 | **Fuori tolleranza vincolante** | Una quota fuori tolleranza blocca l'avanzamento finché il capoturno non autorizza — o finché quella stessa quota non viene rimisurata dentro i limiti. L'autorizzazione resta scritta sulla misura e nel CSV |
|
||||||
|
| 7 | **Stazioni** | Ricette per stazione, cambio stazione da URL per il collaudo, reset per riga |
|
||||||
|
| 8 | **Tracciabilità obbligatoria** | `requires_lot` / `requires_serial` per ricetta, verificati sul server: nessuna porta d'ingresso li aggira, barcode compreso |
|
||||||
|
| 9 | **Blocco inserimento manuale** | `allow_manual_input`: con la ricetta a solo calibro il tastierino non viene disegnato affatto, e il server rifiuta comunque un valore digitato a mano |
|
||||||
|
| 10 | **Sequenza per l'operatore** | La ricetta si apre sul primo task, non su un elenco. La lista scende a secondo livello e dice quali task sono rimasti incompiuti (`2/3`). «Fine ciclo misura» è visibile da subito e spento finché mancano quote |
|
||||||
|
| 11 | **Descrizioni formattate** | `**grassetto**` e a capo nelle descrizioni dei task, con marcatura e non HTML: la sanificazione è per costruzione |
|
||||||
|
| 12 | **Funzionamento senza internet** | Tutte le librerie e i font nell'installazione, versioni congelate e verificate per impronta, Content-Security-Policy a sola origine locale |
|
||||||
|
| 14 | **Layout stabile** | Una cornice sola per tutte le viste, allineata alla navbar; spazio della barra di scorrimento sempre riservato; schermata di misura su `100dvh`. Vedi [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) |
|
||||||
|
| 15 | **Statistica separata** | Si registra sempre, si consulta a parte: nessun percorso dell'operatore porta alla statistica (verificato da test) |
|
||||||
|
|
||||||
|
**Aperto**: il punto 4 (numero di tentativi prima del capoturno) attende le risposte
|
||||||
|
del cliente su modalità di autorizzazione e numero di tentativi; il punto 13
|
||||||
|
(generazione task dalla scheda tecnica con l'AI) è fuori offerta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -67,13 +94,22 @@ Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al bac
|
|||||||
| Flask | 3.x | Framework web server-side |
|
| Flask | 3.x | Framework web server-side |
|
||||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||||
| Jinja2 | incluso in Flask | Template engine |
|
| Jinja2 | incluso in Flask | Template engine |
|
||||||
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
| Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
|
||||||
| TailwindCSS | 3.x | CSS utility-first |
|
| TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
|
||||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
| Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
|
||||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
| PDF.js | 3.11.174 (locale) | Visualizzazione disegni PDF, worker incluso |
|
||||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
| Fabric.js | 5.3.1 (locale) | Editor annotazioni disegni tecnici |
|
||||||
|
| Inter + JetBrains Mono | woff2 locali | Font UI e numeri |
|
||||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||||
|
|
||||||
|
**Nessuna libreria arriva dalla rete.** Tutte stanno in
|
||||||
|
[`src/frontend/flask_app/static/vendor/`](src/frontend/flask_app/static/vendor/VERSIONS.md)
|
||||||
|
con versione nel nome e impronta SHA-256 verificata da un test: l'installazione a
|
||||||
|
Tràfilo è su rete di produzione isolata, dove una pagina che aspetta un CDN è una
|
||||||
|
pagina bianca. Una Content-Security-Policy a sola origine locale (`app.py`) fa sì che
|
||||||
|
un tag verso l'esterno aggiunto in futuro venga rifiutato alla scrivania, non in
|
||||||
|
reparto.
|
||||||
|
|
||||||
### Tooling
|
### Tooling
|
||||||
|
|
||||||
| Componente | Ruolo |
|
| Componente | Ruolo |
|
||||||
@@ -211,7 +247,8 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
| **Supervisor** (capoturno) | Autorizza ciò che l'operatore non può decidere da solo: una quota fuori tolleranza che deve restare, il fermo linea, la fine produzione. L'autorizzazione resta scritta sulla misura (`supervisor_id`, `authorised_at`) e finisce nel file di statistica |
|
||||||
|
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart. **L'operatore non raggiunge la statistica da nessun percorso** |
|
||||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -229,6 +266,7 @@ TieMeasureFlow/
|
|||||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||||
├── nginx/ # Config Nginx (dev)
|
├── nginx/ # Config Nginx (dev)
|
||||||
├── uploads/ # Volume Docker file caricati
|
├── uploads/ # Volume Docker file caricati
|
||||||
|
├── scripts/ # Script del progetto (seed ricette di collaudo)
|
||||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||||
└── src/
|
└── src/
|
||||||
├── backend/ # FastAPI Backend
|
├── backend/ # FastAPI Backend
|
||||||
@@ -245,11 +283,14 @@ TieMeasureFlow/
|
|||||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||||
│ │ │ # AccessLog, SystemSetting,
|
│ │ │ # AccessLog, SystemSetting,
|
||||||
│ │ │ # RecipeVersionAudit, Station,
|
│ │ │ # RecipeVersionAudit, Station,
|
||||||
│ │ │ # StationRecipeAssignment
|
│ │ │ # StationRecipeAssignment,
|
||||||
|
│ │ │ # ProductionRun, ProductionEvent
|
||||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||||
│ ├── services/ # recipe_service, measurement_service,
|
│ ├── services/ # recipe_service, measurement_service,
|
||||||
│ │ # spc_service, report_service,
|
│ │ # spc_service, report_service,
|
||||||
│ │ # auth_service, station_service
|
│ │ # auth_service, station_service,
|
||||||
|
│ │ # production_service,
|
||||||
|
│ │ # production_export_service
|
||||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||||
│ ├── templates/ # Pagina setup (Jinja2)
|
│ ├── templates/ # Pagina setup (Jinja2)
|
||||||
│ └── tests/ # pytest + httpx + aiosqlite
|
│ └── tests/ # pytest + httpx + aiosqlite
|
||||||
@@ -262,9 +303,13 @@ TieMeasureFlow/
|
|||||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||||
├── templates/ # Jinja2 + Alpine.js
|
├── templates/ # Jinja2 + Alpine.js
|
||||||
├── static/
|
├── static/
|
||||||
│ ├── css/ # TailwindCSS compilato
|
│ ├── css/ # TailwindCSS compilato + themes.css
|
||||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
│ │ # (cornice pagina, temi, scrollbar)
|
||||||
│ # spc-charts, annotation-editor/viewer
|
│ ├── js/ # numpad, caliper, barcode, csv-export,
|
||||||
|
│ │ # spc-charts, annotation-editor/viewer,
|
||||||
|
│ │ # production-clock, rich-text
|
||||||
|
│ └── vendor/ # Alpine, Plotly, PDF.js (+worker), Fabric,
|
||||||
|
│ # font woff2 — vedi VERSIONS.md
|
||||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||||
└── tests/
|
└── tests/
|
||||||
```
|
```
|
||||||
@@ -341,7 +386,98 @@ uv run pytest --cov src/backend
|
|||||||
uv run pytest src/frontend/flask_app/tests/
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
Stato corrente: **183 pass, 0 fail** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
Il modulo di visione (`src/vision/`) è un albero a parte, con un vincolo di
|
||||||
|
versione diverso dal resto del monorepo: le librerie di VisionSuite da cui
|
||||||
|
dipende richiedono Python 3.13 o superiore, mentre backend e frontend restano
|
||||||
|
su Python 3.11. Per questo motivo l'esecuzione di `uv run pytest` con
|
||||||
|
l'interprete di default mostra i test di `src/vision/tests/` come *skipped*,
|
||||||
|
non come falliti: il pacchetto viene rilevato ma la sua esecuzione reale
|
||||||
|
richiede un ambiente Python 3.13 dedicato, creato con il proprio extra `uv`.
|
||||||
|
Per eseguirli davvero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prepara l'ambiente Python 3.13 con le dipendenze di visione
|
||||||
|
uv sync --extra vision --extra dev --python 3.13
|
||||||
|
|
||||||
|
# Esegue i test del runner di visione in quell'ambiente
|
||||||
|
uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/vision_worker/tests/` eredita lo stesso vincolo su Python 3.13, perché
|
||||||
|
il worker dipende da `src.vision.runner`, ma aggiunge dipendenze proprie -
|
||||||
|
FastAPI, Uvicorn, Pillow, python-multipart - dichiarate in un extra
|
||||||
|
separato, `vision-worker`, tenuto distinto da `vision` apposta: un
|
||||||
|
consumatore che incorpora solo il runner (come un futuro agente di stazione,
|
||||||
|
che non è un servizio web) non deve trascinarsi dietro un server che non gli
|
||||||
|
serve. `vision-worker` include comunque `vision`, quindi un solo extra basta
|
||||||
|
per avere un worker funzionante:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Per eseguire entrambe le suite insieme, nominando esplicitamente entrambi
|
||||||
|
gli extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.13 --extra vision --extra vision-worker --extra dev pytest src/vision/tests src/vision_worker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Il worker di visione (`Dockerfile.vision`)
|
||||||
|
|
||||||
|
Il worker gira in un container separato dal server FastAPI principale, così
|
||||||
|
che l'immagine dell'API resti leggera e un aggiornamento di VisionSuite non
|
||||||
|
richieda di riavviare il traffico di produzione. Il container non pubblica
|
||||||
|
porte verso l'esterno: lo raggiunge solo il server, all'indirizzo interno
|
||||||
|
`http://vision:8100` sulla rete `tmflow-net`.
|
||||||
|
|
||||||
|
Ogni misura riporta il commit di VisionSuite che l'ha prodotta
|
||||||
|
(`engine_version`), perché una stazione che misura con un motore diverso da
|
||||||
|
quello atteso deve poter essere identificata. Il container, però, non ha
|
||||||
|
accesso al repository Git del progetto principale — `vendor/visionsuite` è un
|
||||||
|
submodule, e la copia `.git` che lo collega al repository ospitante non
|
||||||
|
viene inclusa nel contesto di build — quindi la versione non può essere
|
||||||
|
scoperta al volo dentro l'immagine. Va invece **stampata al momento del
|
||||||
|
build**, leggendo il commit dalla macchina che esegue `docker compose build`,
|
||||||
|
dove il repository Git è presente per intero:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||||
|
docker compose -f docker-compose.dev.yml build vision
|
||||||
|
```
|
||||||
|
|
||||||
|
La variabile viene passata come build argument (`ARG VISION_ENGINE_VERSION`
|
||||||
|
in `Dockerfile.vision`) e fissata nell'immagine come variabile d'ambiente, in
|
||||||
|
modo che il worker la trovi già pronta a ogni avvio senza doverla ricalcolare.
|
||||||
|
Fuori da un container, in un checkout di sviluppo locale, la stessa funzione
|
||||||
|
ricade su `git rev-parse` se la variabile non è impostata — comodo per
|
||||||
|
lavorare sul runner senza Docker. Ma se un'immagine viene costruita senza
|
||||||
|
passare `VISION_ENGINE_VERSION`, quel ripiego non ha nulla su cui appoggiarsi:
|
||||||
|
il `.git` del submodule non arriva nel contesto di build, e il worker
|
||||||
|
risponde con un errore esplicito su `/health` invece di indovinare una
|
||||||
|
versione o restituire `"unknown"`. La build va quindi sempre lanciata con la
|
||||||
|
variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
|
||||||
|
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
|
||||||
|
in produzione).
|
||||||
|
|
||||||
|
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
|
||||||
|
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
|
||||||
|
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
|
||||||
|
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
|
||||||
|
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
|
||||||
|
3.13 descritto sopra. Il fallimento è preesistente e indipendente da questo
|
||||||
|
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
|
||||||
|
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
|
||||||
|
che nei propri commenti cita pagine di documentazione esterne.
|
||||||
|
|
||||||
|
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
|
||||||
|
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||||
|
|
||||||
|
| File | Cosa impedisce |
|
||||||
|
|---|---|
|
||||||
|
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
|
||||||
|
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
|
||||||
|
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -359,6 +495,7 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
|
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||||
@@ -377,6 +514,7 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
|||||||
|
|
||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [`TieMeasureFlow_modifiche_2026-07-28.md`](TieMeasureFlow_modifiche_2026-07-28.md) | I quindici punti del 28/07: cosa deve cambiare, dove intervenire, decisioni in attesa del cliente (D-1…D-9) |
|
||||||
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||||
|
|
||||||
@@ -388,6 +526,8 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
|||||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||||
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||||
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||||
|
| [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava e la regola che lo tiene fermo |
|
||||||
|
| [`src/frontend/flask_app/static/vendor/VERSIONS.md`](src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte, come aggiornarle |
|
||||||
|
|
||||||
### Piani dettagliati
|
### Piani dettagliati
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -41,6 +42,23 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- tmflow-net
|
- tmflow-net
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
|
container_name: tmflow-vision
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
UPLOAD_DIR: uploads
|
UPLOAD_DIR: uploads
|
||||||
|
VISION_WORKER_URL: http://vision:8100
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads
|
- upload_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -50,6 +51,23 @@ services:
|
|||||||
- tmflow-net
|
- tmflow-net
|
||||||
- traefik-net
|
- traefik-net
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.vision
|
||||||
|
args:
|
||||||
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
|
container_name: tmflow-vision
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- tmflow-net
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Scaletta di collaudo — V3.0.0
|
||||||
|
|
||||||
|
Tredici dei quindici punti del 28/07 sono in esercizio e **nessuno li ha ancora
|
||||||
|
percorsi**. Questa è la scaletta per farlo: ordinata in modo che ogni prova prepari
|
||||||
|
la successiva, e scritta perché si possa spuntare riga per riga.
|
||||||
|
|
||||||
|
Per ogni riga: cosa fare, e **come si vede che è giusto**. Se qualcosa non torna,
|
||||||
|
annotare il testo esatto dell'eventuale errore in console — non «dava errore».
|
||||||
|
|
||||||
|
Totale stimato: **circa 90 minuti**, di cui 30 sul solo punto 3 (il timer, che si
|
||||||
|
misura aspettando).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0 · Preparazione (5 min)
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.1 | Aprire il sito e fare **ricarica forzata** (`Ctrl+Shift+R`) | CSS e JS sono in cache per 7 giorni: senza questo si collauda la versione di ieri |
|
||||||
|
| 0.2 | Aprire la **console del browser** (`F12`) e lasciarla aperta per tutto il collaudo | Molte prove si leggono lì, non a schermo |
|
||||||
|
| 0.3 | Verificare che esistano le ricette `COLLAUDO-A` e `COLLAUDO-B` | Sono in `/measure/select`. Se una sessione precedente le ha sporcate: `docker compose exec server uv run python /tmp/seed_collaudo.py --replace` (cancella anche le misure fatte su di esse) |
|
||||||
|
|
||||||
|
**Credenziali del capoturno:** `capoturno` / `Collaudo2026!` — da cambiare prima di
|
||||||
|
qualunque uso reale.
|
||||||
|
|
||||||
|
**Le ricette:**
|
||||||
|
|
||||||
|
- **COLLAUDO-A** — lotto obbligatorio, digitazione ammessa, intervallo **2 minuti**.
|
||||||
|
Cinque task: nota → misura (2 quote) → nota → misura (1 quota) → nota.
|
||||||
|
- **COLLAUDO-B** — lotto **e** seriale obbligatori, **solo calibro**, una quota.
|
||||||
|
|
||||||
|
**Le quote e i valori da usare** (tutte con nominale al centro):
|
||||||
|
|
||||||
|
| Quota | Task | Nominale | Conforme | Attenzione | Fuori tolleranza |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 · Diametro esterno | A, misura 1 | 10.00 | `10.00` | `10.30` | `12.00` |
|
||||||
|
| 2 · Spessore parete | A, misura 1 | 25.00 | `25.00` | `25.30` | `27.00` |
|
||||||
|
| 3 · Lunghezza totale | A, misura 2 | 100.00 | `100.00` | `100.70` | `105.00` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Layout — punto 14 (10 min) · **da monitor da scrivania**
|
||||||
|
|
||||||
|
Va fatto **prima** e **non dal tablet**: in verticale su un tablet quasi tutte le
|
||||||
|
larghezze coincidono e il difetto non si vede. Serve uno schermo largo.
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 1.1 | Percorrere Ricette → apri una ricetta → Task → Disegno → Anteprima, e tornare indietro | Il blocco di contenuto **non cambia larghezza** fra una schermata e l'altra |
|
||||||
|
| 1.2 | Su ognuna, guardare il bordo sinistro del contenuto e quello della barra in alto | Sono **allineati**: il contenuto sta dentro la stessa cornice della navbar |
|
||||||
|
| 1.3 | Passare da una pagina lunga (che scorre) a una corta (che non scorre) | Niente **salta di lato** di qualche pixel: lo spazio della barra di scorrimento è riservato sempre |
|
||||||
|
| 1.4 | Entrare in un task di misura e uscirne | Stesso controllo: nessuno spostamento orizzontale |
|
||||||
|
| 1.5 | Admin → Utenti, Stazioni, Impostazioni; poi il proprio Profilo | Utenti e Stazioni sono larghe, Impostazioni e Profilo strette — ma con **gli stessi margini esterni** |
|
||||||
|
| 1.6 | **Dal tablet**, entrare in un task di misura | Il piede con «Fine ciclo misura» e il tastierino è visibile **senza scorrere**, anche quando la barra dell'indirizzo compare e sparisce |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Niente arriva dalla rete — punto 12 (15 min)
|
||||||
|
|
||||||
|
Da fare **adesso**, perché la Content-Security-Policy è appena entrata in vigore: se
|
||||||
|
una libreria avesse bisogno di un permesso non concesso, il sintomo compare ora.
|
||||||
|
|
||||||
|
> **Attenzione al metodo.** Il sistema è oggi su una VPS pubblica: staccare la rete
|
||||||
|
> significherebbe perdere anche il server, quindi non prova niente. La prova
|
||||||
|
> equivalente e più diretta è guardare **dove vanno le richieste**. Il giro a rete
|
||||||
|
> davvero staccata si fa in reparto, sull'installazione on-premise.
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 2.1 | Console → scheda **Rete**, spuntare «Disabilita cache», ricaricare | Ordinando per dominio compare **un solo dominio**: `tieflow.tielogic.xyz`. Nessun `cdn`, `jsdelivr`, `cloudflare`, `googleapis`, `plot.ly` |
|
||||||
|
| 2.2 | Ripetere su: login, selezione ricetta, esecuzione task, editor annotazioni (`/maker`), dashboard statistiche | Sempre un dominio solo. La dashboard è quella che carica Plotly, l'editor quella che carica Fabric |
|
||||||
|
| 2.3 | Aprire un task che ha un **disegno PDF** allegato | Il disegno si vede. È la prova del *worker* di PDF.js: se cercasse la rete, tutto sembrerebbe a posto fino a questo momento |
|
||||||
|
| 2.4 | Con la console aperta su ogni pagina del giro | **Zero** messaggi che iniziano con `Refused to load…` o `…violates the following Content Security Policy directive`. Se ce n'è uno, copiarlo per intero: dice quale permesso manca |
|
||||||
|
| 2.5 | Generare un **report PDF** dalle statistiche | Il PDF esce. È generato sul server, non nel browser, ma vale la pena vederlo funzionare nel giro completo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Le regole della ricetta — punti 8 e 9 (10 min) · **COLLAUDO-B**
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 3.1 | `/measure/select`, **senza** compilare lotto e seriale | Sulla scheda di COLLAUDO-B il pulsante è spento e dice **«Compila prima: lotto, seriale»** |
|
||||||
|
| 3.2 | Compilare **solo** il lotto | Il pulsante resta spento e ora chiede solo il **seriale** |
|
||||||
|
| 3.3 | Provare ad andare a mano su `/measure/start/<id>` di COLLAUDO-B senza seriale | Rimanda alla selezione con il messaggio «Compila prima:». La regola non si aggira dall'indirizzo |
|
||||||
|
| 3.4 | Compilare entrambi e avviare | Entra nel task |
|
||||||
|
| 3.5 | Guardare il tastierino | I tasti numerici **non ci sono affatto** — non sono grigi, non ci sono. Restano «cancella» e «conferma» |
|
||||||
|
| 3.6 | Con un **calibro USB** collegato, prendere una lettura | Il valore entra e si salva |
|
||||||
|
| 3.7 | *(facoltativo, per chi vuole la prova dura)* Provare a salvare un valore digitato aggirando lo schermo | Il server risponde **422**: la regola vive lì, non nella pagina |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 · Il primo ciclo e l'avvio produzione — punti 10 e 11 (10 min) · **COLLAUDO-A**
|
||||||
|
|
||||||
|
> Da qui in avanti si lavora su COLLAUDO-A. **Il timer non parte subito**: prima si
|
||||||
|
> fa un ciclo completo, poi compare «Avvio Produzione». È voluto — il primo pezzo è
|
||||||
|
> l'attrezzaggio, non produzione.
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 4.1 | Selezione ricetta → COLLAUDO-A → lotto `LOT-TEST-01` → **AVVIA IN SEQUENZA** | Si apre **il primo task** (la nota «Preparazione del pezzo»), **non** l'elenco dei task |
|
||||||
|
| 4.2 | Leggere la nota | «a sinistra» è in **grassetto**, le righe vanno a capo e la riga vuota si vede (punto 11) |
|
||||||
|
| 4.3 | Guardare i due pulsanti in alto a destra | Sono **«Lista task»** e **«Completato»**. Su una nota «Completato» è attivo |
|
||||||
|
| 4.4 | Premere «Completato» | Passa al task 2, «Misura 1 — diametro e spessore» |
|
||||||
|
| 4.5 | Guardare il pulsante in basso a destra | **«Mancano 2 quote»**, grigio e non cliccabile |
|
||||||
|
| 4.6 | Misurare la quota 1 con `10.00` e confermare | Verde (conforme). Il pulsante in basso ora dice **«Manca 1 quota»** |
|
||||||
|
| 4.7 | **Prova chiave del punto 10**: premere «Lista task» adesso, a metà | Nel'elenco il task «Misura 1» porta **«Incompiuto 1/2»** con il bordo ambra. Gli altri non dicono niente |
|
||||||
|
| 4.8 | Premere **«Visualizza singolo TASK»** su quel task | Ci si torna dentro, con la quota già presa |
|
||||||
|
| 4.9 | Misurare la quota 2 con `25.00` | Il pulsante in basso diventa blu: **«Fine ciclo misura»** |
|
||||||
|
| 4.10 | Premerlo | Compare il verde **«Task successivo»** (non un secondo «Completato») |
|
||||||
|
| 4.11 | Guardare sotto il piede della pagina | Compare il pulsante verde **«Avvio Produzione»** |
|
||||||
|
| 4.12 | Premere «Avvio Produzione» | Parte il conto alla rovescia da **2 minuti**. Da qui in avanti compaiono «Fermo linea» e «Fine produzione» |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 · Il fuori tolleranza — punto 5 (15 min)
|
||||||
|
|
||||||
|
La parte più importante del collaudo: fino a ieri questo gate era una modale che si
|
||||||
|
poteva chiudere.
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 5.1 | Tornare su «Misura 1». Cliccare la **quota 1** in alto per rimisurarla, e inserire `12.00` | Si salva ed è **rossa**. La misura sbagliata resta a registro: è un dato, non un errore da nascondere |
|
||||||
|
| 5.2 | Provare a passare alla **quota 2** e salvare un valore | **Rifiutato.** Compare l'avviso che serve l'autorizzazione |
|
||||||
|
| 5.3 | Guardare «Completato» in alto | È **spento**, e passandoci sopra dice «Serve l'autorizzazione del capoturno» |
|
||||||
|
| 5.4 | **Ricaricare la pagina** (`F5`) | Il blocco **è ancora lì**. Ricaricare non è una via d'uscita |
|
||||||
|
| 5.5 | **La via d'uscita senza capoturno**: cliccare di nuovo la quota 1 e rimisurarla con `10.00` | Il blocco **si scioglie**. È voluto: il calibro scivola, il pezzo si riposiziona, e chiamare il capoturno per questo sarebbe assurdo |
|
||||||
|
| 5.6 | Rifarlo: quota 1 con `12.00`, e stavolta **autorizzare** con `capoturno` / `Collaudo2026!` | Il blocco si scioglie e la misura resta segnata come autorizzata |
|
||||||
|
| 5.7 | Provare ad autorizzare con una password sbagliata | Rifiutato, e il blocco **resta** |
|
||||||
|
| 5.8 | Provare ad autorizzare con un utente **senza** ruolo Supervisor (es. l'operatore stesso) | Rifiutato: non basta essere collegati, serve il ruolo |
|
||||||
|
| 5.9 | Rimettere `10.00` sulla quota 1 e `25.00` sulla 2, chiudere il ciclo | Si prosegue normalmente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 · Il ciclo di misura — punto 3 (30 min, quasi tutti di attesa)
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 6.1 | Con il conto alla rovescia in corso, **cambiare task** | Il tempo residuo **prosegue**, non riparte da 2 minuti |
|
||||||
|
| 6.2 | **Ricaricare la pagina** (`F5`) | Idem: il tempo è lo stesso. Vive sul server, non nella pagina |
|
||||||
|
| 6.3 | Fermarsi su una **nota** e lasciare scadere l'intervallo | A ~5 secondi dalla scadenza compare l'avviso e **suona il cicalino** |
|
||||||
|
| 6.4 | Non toccare niente | Alla scadenza si viene portati **al primo task di misura** della ricetta, non a quello dove si era |
|
||||||
|
| 6.5 | Fare un ciclo completo: misura 1 (2 quote) → nota → **misura 2** (quota 3, `100.00`) → «Fine ciclo misura» | Chiudendo il ciclo **sull'ultimo task di misura** l'intervallo **riparte da 2 minuti** |
|
||||||
|
| 6.6 | Ripetere ma chiudendo il ciclo sulla **prima** misura | L'intervallo **non** riparte: il ciclo si chiude solo sull'ultimo task di misura |
|
||||||
|
| 6.7 | **La prova più delicata**: stare **dentro** un task di misura, con una quota già presa e una da prendere, e lasciare scadere l'intervallo | **Non** si viene spostati. Il conteggio diventa **rosso** e prosegue **oltre lo zero**, mostrando il ritardo. Chi sta misurando va lasciato finire |
|
||||||
|
| 6.8 | Usare **«Rimisura»** dopo aver completato le quote | Registra una seconda lettura dello stesso pezzo **senza** far ripartire l'intervallo. Girare il pezzo e rimisurare non è un pezzo nuovo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 · Fermo linea e fine produzione — punto 6 (10 min)
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 7.1 | Dentro un task di misura, premere **«Fermo linea»** → credenziali capoturno | Il conto alla rovescia **si ferma** |
|
||||||
|
| 7.2 | Ricaricare la pagina | È ancora fermo: lo stato è sul server |
|
||||||
|
| 7.3 | Premere **«Ripresa»** → credenziali | Riparte |
|
||||||
|
| 7.4 | Premere **«Fine produzione»** → credenziali | La produzione si chiude |
|
||||||
|
| 7.5 | Sul server: `docker compose exec server ls uploads/statistics/` | C'è un file `production_<id>_<data>.csv` |
|
||||||
|
| 7.6 | Aprire il file | Contiene **tutte** le misure della produzione, compresi i `12.00`. Sulla riga autorizzata le colonne `authorised_by` e `authorised_at` sono **valorizzate**: il file mostra il guasto **e** la decisione |
|
||||||
|
| 7.7 | Aprire il file in Excel | Le accentate si leggono, il separatore è quello configurato |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 · La statistica resta separata — punto 15 (5 min)
|
||||||
|
|
||||||
|
| | Cosa | Verifica |
|
||||||
|
|---|---|---|
|
||||||
|
| 8.1 | Collegarsi con un utente che ha **solo** il ruolo MeasurementTec | In barra **non c'è** la voce Statistiche |
|
||||||
|
| 8.2 | Percorrere tutto il flusso operatore guardando ogni schermata | Nessun collegamento porta alla statistica, in nessun punto |
|
||||||
|
| 8.3 | Andare a mano su `/statistics/dashboard` | **403**: il ruolo non basta |
|
||||||
|
| 8.4 | Verificare che le misure ci siano comunque | Con un utente Metrologist, le misure appena fatte compaiono in dashboard. Si registra sempre, si consulta a parte |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cosa annotare
|
||||||
|
|
||||||
|
Per ogni riga che non torna:
|
||||||
|
|
||||||
|
1. **Numero della riga** di questa scaletta.
|
||||||
|
2. **Cosa è successo** invece di quello che c'è scritto.
|
||||||
|
3. Se c'è un errore in console, il **testo esatto**, per intero.
|
||||||
|
4. Se è un problema di aspetto, uno **screenshot** e su **quale schermo** (tablet o
|
||||||
|
monitor, e in che orientamento) — per il punto 14 è l'informazione che manca.
|
||||||
|
|
||||||
|
## Cosa questa scaletta non copre
|
||||||
|
|
||||||
|
- Il **punto 4** (numero di tentativi prima del capoturno) non è implementato: è
|
||||||
|
fermo sulle risposte del cliente (D-6, D-7, D-9).
|
||||||
|
- Il **cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
|
||||||
|
- Il giro a **rete davvero staccata** si fa in reparto, sull'installazione
|
||||||
|
on-premise: qui si prova l'equivalente (nessuna richiesta esce dal dominio).
|
||||||
|
- Il **carico** con venti tablet insieme non è mai stato misurato.
|
||||||
+10
-3
@@ -6,8 +6,11 @@ Indice della documentazione del progetto.
|
|||||||
|
|
||||||
| Documento | Scopo |
|
| Documento | Scopo |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
|
| [`../TieMeasureFlow_modifiche_2026-07-28.md`](../TieMeasureFlow_modifiche_2026-07-28.md) | **Il piano di lavoro corrente**: i quindici punti del sopralluogo del 28/07 e le decisioni in attesa del cliente (D-1…D-9). |
|
||||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
|
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V3.0.0), punto per punto, e cosa non è ancora stato provato sul campo. |
|
||||||
|
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta, in ordine: collaudo, punto 4, innesto GAIA, installazione a Tràfilo. Include la mappatura con le Fasi rev04 di aprile. |
|
||||||
|
| [`architecture/LAYOUT.md`](architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava fra le viste e la regola che lo tiene fermo. |
|
||||||
|
| [`../src/frontend/flask_app/static/vendor/VERSIONS.md`](../src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte SHA-256, come aggiornarle. |
|
||||||
|
|
||||||
## Riferimenti operativi
|
## Riferimenti operativi
|
||||||
|
|
||||||
@@ -17,8 +20,12 @@ Indice della documentazione del progetto.
|
|||||||
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
||||||
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
||||||
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
||||||
|
| [`COLLAUDO.md`](COLLAUDO.md) | **Scaletta di collaudo V3.0.0**: cosa provare, in che ordine, con quali valori, e come si vede che è giusto. |
|
||||||
|
|
||||||
## Piani dettagliati TDD (rev04)
|
## Piani dettagliati TDD (rev04) — storici
|
||||||
|
|
||||||
|
> Il piano rev04 di aprile è **superato** dal documento del 28/07. La mappatura fra
|
||||||
|
> le sue sette Fasi e i quindici punti correnti è in fondo a `architecture/ROADMAP.md`.
|
||||||
|
|
||||||
| Documento | Scopo |
|
| Documento | Scopo |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Cornice delle pagine
|
||||||
|
|
||||||
|
Nasce dal punto 14 del documento del 28/07: *«le dimensioni delle viste cambiano a
|
||||||
|
seconda del menu; passando da una schermata all'altra la finestra non mantiene
|
||||||
|
proporzioni stabili»*. La segnalazione era di un operatore e non era ancora stata
|
||||||
|
circoscritta sul codice. Questo è l'esito della circoscrizione e la regola che ne
|
||||||
|
è uscita.
|
||||||
|
|
||||||
|
## Cosa succedeva
|
||||||
|
|
||||||
|
Tre meccanismi distinti, tutti verificabili sul codice senza avere il tablet in mano.
|
||||||
|
|
||||||
|
**1. Ogni vista si dichiarava la propria larghezza.** Sette valori diversi su
|
||||||
|
diciassette template, e nessuno coincideva con quello della navbar, che sta a
|
||||||
|
`max-w-7xl` su tutte le pagine. Il contenuto risultava quindi disallineato dalla
|
||||||
|
barra sopra di sé, e il disallineamento cambiava da pagina a pagina.
|
||||||
|
|
||||||
|
| vista | prima | dopo |
|
||||||
|
|---|---|---|
|
||||||
|
| `measure/select_recipe.html` | `max-w-7xl` `py-8` | `tmf-page` |
|
||||||
|
| `measure/task_list.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||||
|
| `measure/task_complete.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||||
|
| `maker/recipe_list.html` | `max-w-6xl` `py-8` | `tmf-page` |
|
||||||
|
| `maker/task_editor.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||||
|
| `maker/task_drawing.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||||
|
| `maker/recipe_preview.html` | `max-w-5xl` `py-8` | `tmf-page` |
|
||||||
|
| `admin/stations.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||||
|
| `admin/users.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||||
|
| `statistics/dashboard.html` | `max-w-7xl` `py-6` | `tmf-page` |
|
||||||
|
| `admin/settings.html` | `max-w-3xl` `py-6` | `tmf-page tmf-page-narrow` |
|
||||||
|
| `auth/profile.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||||
|
| `maker/recipe_editor.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||||
|
| `maker/version_history.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||||
|
| `errors/station_not_configured.html` | `max-w-2xl` `py-8` | `tmf-page tmf-page-narrow` |
|
||||||
|
|
||||||
|
Il percorso dell'operatore — quello da cui è arrivata la segnalazione — faceva
|
||||||
|
`1280px → 1024px → tutto schermo → 1280px` in quattro passaggi.
|
||||||
|
|
||||||
|
**2. Il padding verticale non era lo stesso.** `py-8` e `py-6` mescolati fra viste
|
||||||
|
consecutive: la prima card si trovava a un'altezza diversa a ogni cambio di
|
||||||
|
schermata.
|
||||||
|
|
||||||
|
**3. La barra di scorrimento appariva e spariva.** È una barra classica da 8px
|
||||||
|
(`themes.css`, `::-webkit-scrollbar`), quindi occupa spazio nel layout. Una pagina
|
||||||
|
lunga la mostrava, una corta no, e la schermata di misura la toglie sempre
|
||||||
|
(`body { overflow: hidden }`): a ogni navigazione tutto il contenuto centrato —
|
||||||
|
navbar compresa — si spostava di 8px in orizzontale.
|
||||||
|
|
||||||
|
## La regola
|
||||||
|
|
||||||
|
Due classi in `static/css/themes.css`, nessuna larghezza nei template.
|
||||||
|
|
||||||
|
- **`.tmf-page`** — la cornice: `max-width: 80rem`, padding `1rem / 1.5rem / 2rem`
|
||||||
|
ai tre breakpoint, `padding-block: 1.5rem`. Sono esattamente i valori della
|
||||||
|
navbar (`max-w-7xl px-4 sm:px-6 lg:px-8`), così il contenuto ci si allinea.
|
||||||
|
- **`.tmf-page-narrow`** — si aggiunge alla prima e stringe a `56rem`. È per i
|
||||||
|
moduli: un campo di testo largo 1280px non si compila meglio.
|
||||||
|
- **`html { scrollbar-gutter: stable }`** — lo spazio della barra è riservato
|
||||||
|
sempre, anche dove la pagina non scorre.
|
||||||
|
- **`body.h-screen { height: 100dvh }`** — la schermata di misura è alta quanto la
|
||||||
|
finestra vera. `100vh` su Android e iOS è l'altezza che la pagina avrebbe con la
|
||||||
|
barra dell'indirizzo nascosta: su un tablet il piede della schermata — dove
|
||||||
|
stanno «Fine ciclo misura» e il tastierino — finiva sotto il bordo, e ricompariva
|
||||||
|
quando la barra si ritraeva.
|
||||||
|
|
||||||
|
Due larghezze in tutto il prodotto al posto di sette, e il criterio è il tipo di
|
||||||
|
pagina: liste, tabelle e tele di disegno stanno larghe, i moduli stanno stretti.
|
||||||
|
|
||||||
|
## Le due eccezioni
|
||||||
|
|
||||||
|
Hanno geometria propria per un motivo, e sono elencate in
|
||||||
|
`tests/test_layout_shell.py` perché non sembrino dimenticate.
|
||||||
|
|
||||||
|
- **`auth/login.html`** — schermata piena senza navbar: non c'è niente a cui
|
||||||
|
allinearsi.
|
||||||
|
- **`measure/task_execute.html`** — la schermata di misura è un pannello a tutta
|
||||||
|
altezza che non deve scorrere mentre un operatore sta misurando
|
||||||
|
(`h-screen overflow-hidden`, footer nascosto). È l'unica vista in cui il cambio
|
||||||
|
di forma è voluto, ed è anche l'unica in cui l'operatore si ferma a lavorare:
|
||||||
|
entrarci e uscirne resta un salto, ma ora è l'unico.
|
||||||
|
|
||||||
|
## Cosa resta da verificare sul campo
|
||||||
|
|
||||||
|
La segnalazione non è mai stata riprodotta su un dispositivo: quanto sopra viene
|
||||||
|
dalla lettura del codice, non da una prova. Quello che è dimostrato è che il
|
||||||
|
layout *poteva* muoversi per quattro motivi distinti e che ora non può più per
|
||||||
|
nessuno dei quattro.
|
||||||
|
|
||||||
|
Se l'operatore vedesse ancora spostamenti, il sospetto successivo è la **tastiera
|
||||||
|
virtuale**: quando compare, il browser ridimensiona la finestra, e con `100dvh` la
|
||||||
|
schermata di misura si ridimensiona con lei invece di scorrere sotto. È il
|
||||||
|
comportamento giusto per il tastierino a schermo, ma va guardato con un calibro
|
||||||
|
USB collegato, dove la tastiera di sistema non dovrebbe comparire affatto.
|
||||||
|
|
||||||
|
Da chiedere all'operatore che ha aperto la segnalazione: **su quale schermo** l'ha
|
||||||
|
vista. Su un tablet in verticale la maggior parte delle larghezze qui sopra
|
||||||
|
collassa allo stesso valore e il difetto non si vede; su un monitor da scrivania
|
||||||
|
si vede tutto.
|
||||||
+126
-75
@@ -1,95 +1,146 @@
|
|||||||
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente)
|
# Roadmap TieMeasureFlow — V3.0.0 → installazione a Tràfilo
|
||||||
|
|
||||||
> Aggiornare ad ogni Fase chiusa.
|
> Aggiornata al 2026-07-28. Aggiornare a ogni punto chiuso.
|
||||||
|
|
||||||
## Riferimenti
|
## Dove siamo
|
||||||
|
|
||||||
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
La roadmap rev04 di aprile (Fasi 1-7, milestone M1/M2) è **superata dai fatti**: il
|
||||||
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
|
sopralluogo del 28/07 ha prodotto un elenco di quindici punti concreti che è oggi il
|
||||||
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
piano di lavoro. Buona parte delle vecchie Fasi 2 e 4 è dentro quei punti ed è già
|
||||||
|
fatta; il resto è confluito o decaduto. La mappatura è in fondo, per non perdere il
|
||||||
|
filo con i documenti di aprile.
|
||||||
|
|
||||||
## Strategia: due milestone
|
- Piano corrente: [`../../TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md)
|
||||||
|
- Stato di dettaglio: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
||||||
|
- Piano rev04 (storico): [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
||||||
|
|
||||||
| Milestone | Scope | Obiettivo |
|
**Scadenza che comanda tutto: l'installazione on-premise a Tràfilo è prevista per
|
||||||
|
settembre 2026.**
|
||||||
|
|
||||||
|
## I quindici punti
|
||||||
|
|
||||||
|
| # | Punto | Stato |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback |
|
| 1 | Memoria della produzione in corso | ✅ fatto |
|
||||||
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
|
| 2 | Tipo di task esplicito | ✅ fatto |
|
||||||
|
| 3 | Loop di misura e ripetizione | ✅ fatto |
|
||||||
|
| 4 | Limite di tentativi prima del capoturno | ⛔ **fermo** su D-6, D-7, D-9 |
|
||||||
|
| 5 | Avanzamento solo se in tolleranza | ✅ fatto |
|
||||||
|
| 6 | Fermo linea e Fine produzione con effetto | ✅ fatto (innesto GAIA fermo su D-1, D-2) |
|
||||||
|
| 7 | Gestione delle stazioni | ✅ fatto |
|
||||||
|
| 8 | Tracciabilità obbligatoria | ✅ fatto |
|
||||||
|
| 9 | Blocco dell'inserimento manuale | ✅ fatto |
|
||||||
|
| 10 | Interfaccia operatore: sequenza e conferme | ✅ fatto |
|
||||||
|
| 11 | Formattazione delle descrizioni | ✅ fatto |
|
||||||
|
| 12 | Funzionamento senza internet | ✅ fatto |
|
||||||
|
| 13 | Generazione task dalla scheda tecnica con l'AI | — fuori offerta (quotazione su D-8) |
|
||||||
|
| 14 | Stabilità del layout | ✅ fatto |
|
||||||
|
| 15 | Statistica separata dalla registrazione | ✅ fatto |
|
||||||
|
|
||||||
## Stato Fasi (M1)
|
## Cosa resta, in ordine
|
||||||
|
|
||||||
| Fase | Scope | Stato | Branch / Commit |
|
### 1. Collaudo sul campo — *da fare adesso, non serve nessuna risposta*
|
||||||
|
|
||||||
|
È il lavoro più urgente rimasto, ed è l'unico che non dipende da nessuno. Tredici
|
||||||
|
punti sono in esercizio e **nessuno li ha percorsi su un tablet**.
|
||||||
|
|
||||||
|
Le ricette `COLLAUDO-A` e `COLLAUDO-B` sono già sul sistema, con un utente
|
||||||
|
`capoturno` (`scripts/seed_collaudo.py`). Da verificare, in quest'ordine:
|
||||||
|
|
||||||
|
| Cosa | Come si vede che è giusto |
|
||||||
|
|---|---|
|
||||||
|
| Ciclo di misura (3) | Il conto alla rovescia sopravvive al cambio task, prosegue in rosso oltre lo zero, riporta alla misura |
|
||||||
|
| Fuori tolleranza (5) | Con `12.00` sulla prima quota non si passa alla seconda finché il capoturno non autorizza, o finché non si rimisura dentro |
|
||||||
|
| Tracciabilità (8) | Su `COLLAUDO-B` l'avvio non parte finché lotto e seriale non ci sono |
|
||||||
|
| Inserimento manuale (9) | Su `COLLAUDO-B` il tastierino numerico non c'è affatto |
|
||||||
|
| Sequenza (10) | La ricetta si apre sul primo task; un task lasciato a metà appare «Incompiuto 1/2» nella lista |
|
||||||
|
| Senza rete (12) | Staccare la rete e percorrere login → ricetta → task → annotazione → statistiche → report, console aperta |
|
||||||
|
| Layout (14) | **Da un monitor da scrivania**, non solo dal tablet: è lì che il difetto si vedeva |
|
||||||
|
|
||||||
|
Il giro del punto 12 va fatto **ora**, perché la Content-Security-Policy è appena
|
||||||
|
entrata in vigore: se una libreria avesse bisogno di un permesso non concesso, il
|
||||||
|
sintomo compare adesso.
|
||||||
|
|
||||||
|
### 2. Punto 4 — appena arrivano le risposte
|
||||||
|
|
||||||
|
Numero di tentativi prima del capoturno. Serve sapere **D-7** (quanti, e se uguali
|
||||||
|
per tutte le ricette), **D-6** (password, PIN o badge) e **D-9** (se cambiare i
|
||||||
|
parametri crea una nuova versione). Il resto dell'impianto è pronto: il blocco per
|
||||||
|
fuori tolleranza e l'autorizzazione del capoturno esistono già, manca il contatore.
|
||||||
|
|
||||||
|
Stima, a decisioni chiuse: **2-3 giorni**.
|
||||||
|
|
||||||
|
### 3. Innesto verso GAIA — fermo su D-1 e D-2
|
||||||
|
|
||||||
|
Avvio produzione, fermo linea e fine produzione hanno già lo stato e gli eventi; la
|
||||||
|
fine produzione emette già il file di statistica. Manca solo il canale verso il
|
||||||
|
gestionale, e non si può nemmeno disegnare finché non si sa **come** si parla con
|
||||||
|
GAIA (D-1) e **da dove** (D-2).
|
||||||
|
|
||||||
|
Stima, a protocollo noto: **1-2 settimane**, molto dipendente dalla risposta.
|
||||||
|
|
||||||
|
### 4. Installazione a Tràfilo — settembre
|
||||||
|
|
||||||
|
| Cosa | Blocco |
|
||||||
|
|---|---|
|
||||||
|
| Macchina server, spazio disco | D-4 |
|
||||||
|
| Immagini Docker portate in reparto, non repository da compilare sul posto | — |
|
||||||
|
| Colonnina luminosa, se serve | D-5 |
|
||||||
|
| Validazione rete e macchine con l'IT | D-4 |
|
||||||
|
|
||||||
|
Nota: **la costruzione delle immagini richiede rete** (npm, apt, uv); è l'esecuzione
|
||||||
|
a non richiederla. In reparto va portata l'immagine già costruita.
|
||||||
|
|
||||||
|
## Decisioni in attesa del cliente
|
||||||
|
|
||||||
|
Da girare a Tràfilo tramite Menoncin. Le prime due e la D-4 hanno l'orizzonte di
|
||||||
|
settembre; D-6 e D-7 bloccano lavoro che sappiamo già fare.
|
||||||
|
|
||||||
|
| ID | Decisione | Blocca | Chi risponde |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) |
|
| **D-1** | Protocollo del gestionale GAIA: servizi web, database condiviso, file? | innesto GAIA (punto 6) | IT Tràfilo + fornitore GAIA |
|
||||||
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
|
| **D-2** | Rete e credenziali per raggiungere GAIA | come sopra | IT Tràfilo |
|
||||||
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
|
| ~~D-3~~ | ~~Una app per stazione o una sola?~~ **Decisa il 28/07**: dati sul server, app di stazione su ogni PC | — | chiusa; validazione in D-4 |
|
||||||
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
|
| **D-4** | Server: quale macchina, quanto spazio disco (database **e** disegni) | installazione | IT Tràfilo |
|
||||||
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
|
| **D-5** | Cicalino: basta il suono del browser o serve una colonnina luminosa? | punto 3 (completamento) | Tràfilo |
|
||||||
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
|
| **D-6** | Autorizzazione capoturno: password come oggi, o PIN / badge? Venti volte al giorno la password è attrito | punti 4, 5, 6 | Tràfilo |
|
||||||
|
| **D-7** | Quanti tentativi prima del capoturno, e uguali per tutte le ricette? | punto 4 | Tràfilo |
|
||||||
|
| **D-8** | Schede tecniche: quante, formato standard, conversione una-tantum o funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
|
||||||
|
| **D-9** | Cambiare i parametri di una ricetta (timer, tentativi) crea una nuova versione? | punti 3, 4 | noi, con conferma cliente |
|
||||||
|
|
||||||
## Stato Fasi (M2)
|
Una decisione già presa che va **confermata**: la migrazione 009 ha impostato
|
||||||
|
`allow_manual_input = 1` su tutte le ricette esistenti, per conservare il
|
||||||
|
comportamento in essere invece di irrigidire di colpo ricette già in uso. Va deciso
|
||||||
|
ricetta per ricetta quali devono passare a solo calibro.
|
||||||
|
|
||||||
| Fase | Scope | Stato |
|
## Tech debt
|
||||||
|---|---|---|
|
|
||||||
| 5 (M2) | GAIA reale (protocollo TBD, polling, comandi produzione) | ⏳ Bloccata da decisioni cliente (D-0.1, D-0.2) |
|
|
||||||
| 6 | Deploy B industriale (registry privato + Watchtower + STATION_ID per-tablet + CI release) | ⏳ Pianificata |
|
|
||||||
| 7 | Hardening, security review, E2E sito pilota, docs aggiornati, i18n delta | ⏳ Pianificata |
|
|
||||||
|
|
||||||
## Decisioni aperte (bloccanti per M2 / future fasi)
|
|
||||||
|
|
||||||
Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente prima della Fase 5/6.
|
|
||||||
|
|
||||||
| ID | Decisione | Stato | Bloccante per |
|
|
||||||
|---|---|---|---|
|
|
||||||
| D-0.1 | Protocollo integrazione GAIA (REST / DB shared / OPC-UA / file) | **Aperta** | Fase 5 reale (M2) |
|
|
||||||
| D-0.2 | Credenziali e rete GAIA (VPN / firewall / whitelist IP) | **Aperta** | Fase 5 reale (M2) |
|
|
||||||
| D-0.3 | Target hardware "tablet" (Windows / Linux industriale / Android) | **Aperta** | Fase 6 (deploy B) |
|
|
||||||
| D-0.4 | Cicalino/luce avviso (audio HTML5 / hardware USB / entrambi) | **Rimandata a M2** | Fase 4 finale |
|
|
||||||
| D-0.5 | Parametri runtime modificabili vs versione immutabile | **Aperta** (raccomandato B: separare volatili) | Fase 3 |
|
|
||||||
| D-0.6 | Auth capoturno durante override (modale / PIN / RFID) | **Aperta** | Fase 2 |
|
|
||||||
| D-0.7 | Timeout auto-logout | **Risolta** | — |
|
|
||||||
| D-0.8 | Naming ruolo capoturno | **Proposta:** `Supervisor` | Fase 2 |
|
|
||||||
| D-0.9 | Tag versione immagine docker | **Proposta:** SemVer + `latest` | Fase 6 |
|
|
||||||
| D-0.10 | Registry esposto su Internet o solo VPN | **Proposta:** solo VPN cliente | Fase 6 |
|
|
||||||
|
|
||||||
## Tech debt da chiudere
|
|
||||||
|
|
||||||
| Item | Priorità | Note |
|
| Item | Priorità | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
|
| `components/barcode_scanner.html` dipende da `html5-qrcode`, mai caricata | Media | Codice morto: il componente non è incluso da nessuna parte e il lettore che l'operatore usa è un campo di testo. Da rimuovere, o da completare portando la libreria in `static/vendor/` |
|
||||||
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
|
| Pagina `task_complete`: riga vuota segnalata in alcuni scenari | Media | Segnalazione di aprile mai riprodotta. Da verificare durante il collaudo, ora che il flusso è cambiato |
|
||||||
| Pagina `task_complete` riepilogo: utente segnala riga vuota in alcuni scenari | Media | Da debuggare (rendering corretto via curl ma utente vede vuoto in browser, possibile interazione con sessione lot/serial). |
|
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato: impatta i deploy esistenti |
|
||||||
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). |
|
| Header `X-API-Key` → `X-Api-Key` | Bassa | Breaking per i deploy esistenti |
|
||||||
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
|
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 |
|
||||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
|
| Test di carico a 20 tablet reali | Bassa | La capacità è dimensionata ma mai misurata sotto carico vero |
|
||||||
| `Dockerfile.frontend`: `pybabel compile` via `uv run` non testato in build reale | Alta | Verificare al primo `docker compose build`. |
|
|
||||||
| Smoke test in container Docker (non solo locale uvicorn+gunicorn) | Alta | Validare che i Dockerfile riscritti con `uv` buildino e girino correttamente prima di chiudere V2.0.0. |
|
|
||||||
|
|
||||||
## Open per scelta utente prima della prossima sessione
|
I quattro test rotti tracciati nello snapshot V2.0.0 non risultano più: la suite è a
|
||||||
|
360 pass, 0 fail.
|
||||||
|
|
||||||
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo).
|
## Mappatura con la roadmap rev04 di aprile
|
||||||
2. **Revisione decisioni aperte col cliente** — D-0.1 / D-0.2 / D-0.3 / D-0.6 prima di pianificare Fase 5 e 6.
|
|
||||||
3. **Smoke test Docker** della nuova struttura V2.0.0 (`docker compose -f docker-compose.dev.yml up --build`) per validare i Dockerfile riscritti.
|
|
||||||
4. **Test di carico** (k6/locust) a 20 VU su `/measure/save-measurement` per validare la scalatura worker (capacità annunciata: 20-30 tablet contemporanei).
|
|
||||||
|
|
||||||
## Stima tempi residui M1 (post-Fase 1)
|
Per chi torna sui documenti di aprile e non ritrova le Fasi.
|
||||||
|
|
||||||
| Task | Stima full-time |
|
| Fase rev04 | Che fine ha fatto |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Fase 2 — Supervisor + override | 1 settimana |
|
| 1 — Stazioni per-tablet | Chiusa in V2.0.0, ampliata dal punto 7 |
|
||||||
| Fase 3 — Block editor | 1.5 settimane |
|
| 2 — Ruolo Capoturno + override | Assorbita dai punti 5 e 6: il ruolo `Supervisor` esiste e l'autorizzazione resta scritta sulla misura. L'override a token breve dipende da D-6 |
|
||||||
| Fase 4 — Workflow operatore | 2 settimane |
|
| 3 — Editor ricetta a blocchi | Sostituita dal punto 2 (tipo di task dichiarato), che risolve il problema vero senza riscrivere l'editor |
|
||||||
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
|
| 4 — Workflow operatore | Assorbita dai punti 3, 4, 10: timer e sequenza sono fatti, i tentativi sono il punto 4 |
|
||||||
| Deploy M1 demo | 0.5 settimane |
|
| 5 — Import GAIA | Diventata l'innesto del punto 6, ferma su D-1 e D-2 |
|
||||||
| **Totale M1 residuo** | **~6 settimane** |
|
| 6 — Deploy industriale (registry + Watchtower) | Ridimensionata: con una rete isolata l'aggiornamento automatico non ha senso. Restano immagini versionate portate a mano |
|
||||||
|
| 7 — Hardening | Parzialmente assorbita: CSP, versioni congelate e impronte sono entrate col punto 12 |
|
||||||
|
|
||||||
## Stima tempi M2 (dopo feedback)
|
Le vecchie decisioni `D-0.x` sono confluite nelle `D-x` qui sopra: D-0.1→D-1,
|
||||||
|
D-0.2→D-2, D-0.4→D-5, D-0.5→D-9, D-0.6→D-6. D-0.8 (nome del ruolo capoturno) è
|
||||||
| Task | Stima |
|
chiusa: si chiama `Supervisor`. D-0.7 (auto-logout) era già risolta.
|
||||||
|---|---|
|
|
||||||
| Aggiustamenti post-feedback | variabile (1-2 sett.) |
|
|
||||||
| Fase 5 reale GAIA | 1-2 settimane |
|
|
||||||
| Fase 6 deploy B | 1 settimana |
|
|
||||||
| Fase 7 hardening | 1-2 settimane |
|
|
||||||
| **Totale M2** | **~4-7 settimane** |
|
|
||||||
|
|
||||||
**Totale fino a produzione:** ~10-13 settimane full-time da oggi (2026-04-25), assumendo decisioni aperte risolte in tempo utile.
|
|
||||||
|
|||||||
+157
-110
@@ -1,145 +1,192 @@
|
|||||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
# Stato Progetto TieMeasureFlow — V3.0.0
|
||||||
|
|
||||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
> Snapshot al 2026-07-28. Aggiornare ad ogni milestone.
|
||||||
|
> Lo snapshot V2.0.0 è in [`../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md`](../archive/STATO_PROGETTO_V2.0.0_2026-04-27.md).
|
||||||
|
|
||||||
## Versione corrente
|
## Versione corrente
|
||||||
|
|
||||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
**V3.0.0** (in sviluppo, branch `V3.0.0`). Versione precedente: `V2.0.0`.
|
||||||
|
|
||||||
Versione precedente di produzione: `V1.0.7`.
|
In esercizio su `tieflow.tielogic.xyz` (Docker Compose + Traefik + Let's Encrypt),
|
||||||
|
schema alla migrazione `010_meas_authorisation`.
|
||||||
|
|
||||||
## Sintesi esecutiva
|
## Sintesi esecutiva
|
||||||
|
|
||||||
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
V2.0.0 aveva chiuso stazioni per-tablet, ristrutturazione monorepo con `uv` e la
|
||||||
|
tenuta multi-tablet. **V3.0.0 nasce dal sopralluogo del 28/07 e lavora sui quindici
|
||||||
|
punti** raccolti in [`TieMeasureFlow_modifiche_2026-07-28.md`](../../TieMeasureFlow_modifiche_2026-07-28.md).
|
||||||
|
|
||||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
Il filo che li tiene insieme: fino a V2.0.0 il sistema *registrava* misure ma non
|
||||||
|
*governava* la produzione. Le regole vivevano nell'interfaccia — una modale che si
|
||||||
|
poteva chiudere, un timer che moriva al cambio pagina, un tipo di task dedotto dalla
|
||||||
|
presenza di quote. V3.0.0 le sposta sul server, dove non si aggirano.
|
||||||
|
|
||||||
### Funzionalità ereditate da V1.0.7
|
Tredici punti su quindici sono fatti e in esercizio. Uno è fermo sulle risposte del
|
||||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
cliente, uno è fuori offerta.
|
||||||
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
|
||||||
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
|
||||||
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
|
||||||
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
|
||||||
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
|
||||||
- Report PDF (WeasyPrint + Kaleido SVG).
|
|
||||||
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
|
||||||
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
|
||||||
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
|
||||||
|
|
||||||
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
## I quindici punti
|
||||||
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
|
||||||
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
|
||||||
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
|
||||||
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
|
||||||
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
|
||||||
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
|
||||||
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
|
||||||
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
|
||||||
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
|
||||||
- 47 nuovi test (32 server + 15 client) tutti pass.
|
|
||||||
|
|
||||||
### Aggiunte V2.0.0 (performance + multi-utente)
|
| # | Punto | Stato | Dove vive |
|
||||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
|---|---|---|---|
|
||||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
| 1 | Memoria della produzione in corso | ✅ | `production_runs`, `production_service` |
|
||||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
| 2 | Tipo di task esplicito | ✅ | `RecipeTask.task_type`, migrazione 007 |
|
||||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
| 3 | Loop di misura e ripetizione | ✅ | `production-clock.js`, `production_service`, migrazione 008 |
|
||||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
| 4 | Limite di tentativi prima del capoturno | ⛔ **Fermo su D-6, D-7, D-9** | — |
|
||||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
| 5 | Avanzamento solo se in tolleranza | ✅ | `measurement_service.pending_authorisation`, migrazione 010 |
|
||||||
- 12 test aggiuntivi (7 server + 5 client).
|
| 6 | Fermo linea e Fine produzione con effetto | ✅ | `production_service`, `production_export_service` |
|
||||||
|
| 7 | Gestione delle stazioni | ✅ | `station_service`, `/admin/stations` |
|
||||||
|
| 8 | Tracciabilità obbligatoria | ✅ | `Recipe.requires_lot/serial`, migrazione 009 |
|
||||||
|
| 9 | Blocco dell'inserimento manuale | ✅ | `Recipe.allow_manual_input`, `numpad.js` |
|
||||||
|
| 10 | Interfaccia operatore: sequenza e conferme | ✅ | `/measure/start`, `task-progress`, `task_list`, `task_execute` |
|
||||||
|
| 11 | Formattazione delle descrizioni | ✅ | filtro `rich_text`, `rich-text.js` |
|
||||||
|
| 12 | Funzionamento senza internet | ✅ | `static/vendor/`, CSP in `app.py` |
|
||||||
|
| 13 | Generazione task dalla scheda tecnica con l'AI | — **Fuori offerta** | — |
|
||||||
|
| 14 | Stabilità del layout | ✅ | `.tmf-page` in `themes.css`, [`LAYOUT.md`](LAYOUT.md) |
|
||||||
|
| 15 | Statistica separata dalla registrazione | ✅ | `@role_required("Metrologist")`, test dedicato |
|
||||||
|
|
||||||
### Aggiunte V2.0.0 (struttura monorepo)
|
## Cosa è entrato in V3.0.0
|
||||||
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
|
||||||
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
|
||||||
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
|
||||||
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
|
||||||
- `docker-compose.{dev,}.yml` con build context `.`.
|
|
||||||
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
|
|
||||||
- `.dockerignore` aggiornato.
|
|
||||||
|
|
||||||
### Hardening post-restructure (smoke test 2026-04-26)
|
### Produzione come stato, non come pulsante (punti 1, 6)
|
||||||
|
|
||||||
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
- Tabelle `production_runs` e `production_events`; una produzione si apre su una
|
||||||
|
stazione, accumula eventi (`start`, `cycle_completed`, `task_measured`,
|
||||||
|
`remeasure`, `line_stop`, `resume`, `close`) e si chiude.
|
||||||
|
- Fermo linea, ripresa e fine produzione richiedono le credenziali del capoturno e
|
||||||
|
**cambiano lo stato**: prima erano modali che si chiudevano.
|
||||||
|
- La fine produzione emette il file di statistica dell'intera produzione
|
||||||
|
(`production_export_service`), CSV con i delimitatori configurati, marcando le
|
||||||
|
misure come esportate. Il punto d'innesto verso GAIA è lì accanto, in attesa di
|
||||||
|
D-1 e D-2.
|
||||||
|
|
||||||
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
|
### Il ciclo di misura (punto 3)
|
||||||
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
|
|
||||||
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
|
|
||||||
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `"..."` o riformulazione testuale.
|
|
||||||
|
|
||||||
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
|
- L'intervallo della ricetta è calcolato dal server (`seconds_to_next_measurement`,
|
||||||
|
`overdue`, `server_time`): sopravvive al cambio task, che è un ricaricamento di
|
||||||
|
pagina, e non si azzera cambiando schermata.
|
||||||
|
- Il conteggio **prosegue oltre lo zero**: il ritardo si vede, in rosso.
|
||||||
|
- A scadenza l'operatore viene riportato al primo task di misura della ricetta, con
|
||||||
|
cinque secondi di preavviso; chi sta già misurando viene lasciato finire.
|
||||||
|
- Cicalino via WebAudio, nessun file audio da caricare.
|
||||||
|
- `remeasure` distingue una seconda lettura dello stesso pezzo da un ciclo nuovo:
|
||||||
|
girare il pezzo e rimisurare non deve far ripartire l'intervallo.
|
||||||
|
|
||||||
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
|
### Le regole della ricetta (punti 8, 9)
|
||||||
|
|
||||||
## Layout repository (V2.0.0)
|
- `requires_lot`, `requires_serial`, `allow_manual_input` per ricetta, verificati in
|
||||||
|
`measurement_service`: **ogni porta d'ingresso passa di lì**, barcode compreso.
|
||||||
|
- Con `allow_manual_input` a falso il tastierino non viene disegnato — non nascosto:
|
||||||
|
il markup non esiste — e il server rifiuta comunque un valore digitato a mano.
|
||||||
|
- Il rilevamento del calibro USB è stato reso più tollerante (Enter veloce), perché
|
||||||
|
con la regola stretta una lettura corta come `9.5` veniva scambiata per digitazione.
|
||||||
|
|
||||||
```
|
### Il fuori tolleranza (punto 5)
|
||||||
TieMeasureFlow/
|
|
||||||
├── pyproject.toml + uv.lock + .python-version
|
|
||||||
├── Dockerfile (backend) + Dockerfile.frontend
|
|
||||||
├── docker-compose.dev.yml + docker-compose.yml
|
|
||||||
├── nginx/
|
|
||||||
├── uploads/ # volume Docker
|
|
||||||
├── docs/ # raggruppata e indicizzata
|
|
||||||
│ ├── README.md (indice)
|
|
||||||
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
|
||||||
│ ├── architecture/ # questo file + ROADMAP.md
|
|
||||||
│ ├── archive/ # piani storici
|
|
||||||
│ ├── specs/ # spec esterne (.docx)
|
|
||||||
│ └── superpowers/plans/ # piani TDD dettagliati
|
|
||||||
└── src/
|
|
||||||
├── backend/
|
|
||||||
│ ├── main.py / config.py / database.py
|
|
||||||
│ ├── api/{routers,middleware}/
|
|
||||||
│ ├── models/{orm,api}/
|
|
||||||
│ ├── services/
|
|
||||||
│ ├── migrations/
|
|
||||||
│ ├── templates/
|
|
||||||
│ └── tests/
|
|
||||||
└── frontend/
|
|
||||||
└── flask_app/
|
|
||||||
├── app.py / config.py / compile_translations.py
|
|
||||||
├── blueprints/ (auth, maker, measure, statistics, admin)
|
|
||||||
├── services/ (api_client.py)
|
|
||||||
├── templates/ + static/ + translations/
|
|
||||||
└── tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smoke test status
|
- Una quota fuori tolleranza e non autorizzata **blocca** il salvataggio della quota
|
||||||
|
successiva e la chiusura del ciclo (409).
|
||||||
|
- Conta l'**ultima** lettura di ogni quota: rimisurare la stessa quota resta
|
||||||
|
possibile — il calibro scivola, il pezzo si riposiziona — e una lettura dentro i
|
||||||
|
limiti scioglie il blocco senza chiamare nessuno.
|
||||||
|
- L'autorizzazione del capoturno resta scritta sulla misura (`supervisor_id`,
|
||||||
|
`authorised_at`) e finisce in due colonne del CSV di produzione: il file mostra il
|
||||||
|
guasto **e** la decisione.
|
||||||
|
- Quanti tentativi siano ammessi è il punto 4, non questo.
|
||||||
|
|
||||||
Validazione end-to-end in locale (2026-04-26):
|
### L'interfaccia dell'operatore (punti 10, 11, 14, 15)
|
||||||
|
|
||||||
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
- La ricetta si apre sul primo task (`/measure/start/<id>`), non su un elenco. La
|
||||||
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
lista scende a secondo livello e mostra quali task sono rimasti incompiuti
|
||||||
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
(`GET /api/measurements/task-progress`, contato per quota e non per tentativo).
|
||||||
- ✅ Login `admin/admin123` via web, sessione persistente
|
- «Fine ciclo misura» è visibile da subito e spento finché mancano quote, con
|
||||||
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
scritto quante ne mancano.
|
||||||
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
- Descrizioni con `**grassetto**` e a capo: marcatura, non HTML, così la
|
||||||
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
sanificazione è per costruzione.
|
||||||
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
- Una cornice sola per tutte le viste, allineata alla navbar — vedi [`LAYOUT.md`](LAYOUT.md).
|
||||||
|
- Nessun percorso dell'operatore porta alla statistica, ed è un test.
|
||||||
|
|
||||||
|
### Fuori dalla rete (punto 12)
|
||||||
|
|
||||||
|
- Alpine, Plotly, PDF.js (+ worker), Fabric e i font sono nell'installazione, con
|
||||||
|
versione nel nome e impronta SHA-256 verificata da un test.
|
||||||
|
- Content-Security-Policy a sola origine locale servita **dal client Flask**: prima
|
||||||
|
esisteva solo sul backend, cioè sulle risposte API e non sulle pagine.
|
||||||
|
- Tailwind fissato a `3.4.19` nel `Dockerfile.frontend`.
|
||||||
|
|
||||||
|
## Migrazioni
|
||||||
|
|
||||||
|
| # | Contenuto |
|
||||||
|
|---|---|
|
||||||
|
| 001–004 | image_path, stazioni, intervallo di misura + auto-logout, `input_duration_ms` |
|
||||||
|
| 005–006 | `production_runs`, misure legate alla produzione |
|
||||||
|
| 007 | `task_type` dichiarato |
|
||||||
|
| 008 | tipi di evento del ciclo + `task_id` sull'evento |
|
||||||
|
| 009 | `requires_lot`, `requires_serial`, `allow_manual_input` |
|
||||||
|
| 010 | `supervisor_id`, `authorised_at` sulla misura |
|
||||||
|
|
||||||
|
Le 008 e 010 usano il *batch mode* per compatibilità SQLite (i test) e sono state
|
||||||
|
verificate anche in resa MySQL (`alembic upgrade X:Y --sql`) prima del deploy.
|
||||||
|
|
||||||
|
La 009 imposta `allow_manual_input = 1` sulle ricette esistenti: conserva il
|
||||||
|
comportamento in essere invece di irrigidire di colpo ricette già in uso. È una
|
||||||
|
scelta, e va confermata dal cliente ricetta per ricetta.
|
||||||
|
|
||||||
## Test status
|
## Test status
|
||||||
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
|
|
||||||
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
|
|
||||||
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
|
|
||||||
|
|
||||||
## Stack confermato
|
| | Test | Fail |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend (`src/backend/tests/`) | 212 | 0 |
|
||||||
|
| Frontend (`src/frontend/flask_app/tests/`) | 148 | 0 |
|
||||||
|
| **Totale** | **360** | **0** |
|
||||||
|
|
||||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
I quattro fallimenti pre-esistenti tracciati nello snapshot V2.0.0 non ci sono più.
|
||||||
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
|
||||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
Tre file di test non renderizzano niente e leggono i sorgenti, perché guardano
|
||||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||||
|
|
||||||
|
| File | Cosa impedisce |
|
||||||
|
|---|---|
|
||||||
|
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
|
||||||
|
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
|
||||||
|
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
|
||||||
|
|
||||||
|
## Cosa non è stato provato
|
||||||
|
|
||||||
|
Va detto perché non si confonda «i test passano» con «funziona in reparto».
|
||||||
|
|
||||||
|
- **Il collaudo sul campo non è mai stato percorso.** Le ricette `COLLAUDO-A` e
|
||||||
|
`COLLAUDO-B` sono state seminate sul sistema in esercizio (`scripts/seed_collaudo.py`)
|
||||||
|
insieme a un utente `capoturno`, ma nessuno ha ancora guidato il flusso su un
|
||||||
|
tablet vero.
|
||||||
|
- **Il punto 14 non è stato riprodotto su un dispositivo**: è dimostrato che il
|
||||||
|
layout *poteva* muoversi per quattro motivi e che ora non può più, non che
|
||||||
|
l'operatore vedesse esattamente quelli.
|
||||||
|
- **Il punto 12 non è stato provato a rete staccata.** È dimostrato che nessuna
|
||||||
|
risorsa esterna viene richiesta. Il giro con la rete staccata va fatto, ed è ora il
|
||||||
|
momento giusto perché la CSP è appena entrata in vigore.
|
||||||
|
- **Il cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 +
|
||||||
|
WeasyPrint + Plotly/Kaleido. 12 router.
|
||||||
|
- **Frontend:** Flask + Jinja2 + Alpine.js 3.15.12 + TailwindCSS 3.4.19 +
|
||||||
|
Fabric.js 5.3.1 + PDF.js 3.11.174 + Plotly.js 2.32.0 + Flask-Babel —
|
||||||
|
**tutte copie locali**, nessun CDN.
|
||||||
|
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt.
|
||||||
|
- **Tooling:** uv, pytest + pytest-asyncio + httpx + aiosqlite.
|
||||||
|
|
||||||
## Decisioni architetturali rilevanti
|
## Decisioni architetturali rilevanti
|
||||||
|
|
||||||
| Decisione | Stato | Note |
|
| Decisione | Stato | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
| Le regole di misura stanno sul server, non sullo schermo | **Confermata (V3.0.0)** | Tracciabilità, inserimento manuale, fuori tolleranza: la schermata può nasconderle, il server le rifiuta. |
|
||||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
| Una app di stazione per PC, dati sul server | **Decisa il 28/07** (D-3) | Validazione con l'IT di Tràfilo aperta come D-4. |
|
||||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
| Descrizioni in marcatura, non in HTML | **Confermata** | Niente HTML accettato in ingresso: la sanificazione è per costruzione, non per filtro. |
|
||||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, calibri USB, editor Fabric.js, i18n Babel collaudato. |
|
||||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, nessun microservizio. |
|
||||||
|
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo alto, rotture client. Eventuale API v2. |
|
||||||
|
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare è breaking per i deploy esistenti. |
|
||||||
|
|
||||||
## Branch git
|
## Branch git
|
||||||
|
|
||||||
- **Default:** `V2.0.0` (lavoro corrente)
|
- **Corrente:** `V3.0.0`
|
||||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
- **Precedenti:** `V2.0.0`, `V1.0.0` … `V1.0.7` (release storiche)
|
||||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||||
|
|
||||||
|
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
||||||
|
|
||||||
|
## Versione corrente
|
||||||
|
|
||||||
|
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
||||||
|
|
||||||
|
Versione precedente di produzione: `V1.0.7`.
|
||||||
|
|
||||||
|
## Sintesi esecutiva
|
||||||
|
|
||||||
|
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
||||||
|
|
||||||
|
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
||||||
|
|
||||||
|
### Funzionalità ereditate da V1.0.7
|
||||||
|
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
||||||
|
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
||||||
|
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
||||||
|
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
||||||
|
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
||||||
|
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
||||||
|
- Report PDF (WeasyPrint + Kaleido SVG).
|
||||||
|
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
||||||
|
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
||||||
|
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
||||||
|
|
||||||
|
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
||||||
|
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
||||||
|
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
||||||
|
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
||||||
|
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
||||||
|
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
||||||
|
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
||||||
|
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
||||||
|
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
||||||
|
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
||||||
|
- 47 nuovi test (32 server + 15 client) tutti pass.
|
||||||
|
|
||||||
|
### Aggiunte V2.0.0 (performance + multi-utente)
|
||||||
|
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
||||||
|
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
||||||
|
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
||||||
|
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
||||||
|
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
||||||
|
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
||||||
|
- 12 test aggiuntivi (7 server + 5 client).
|
||||||
|
|
||||||
|
### Aggiunte V2.0.0 (struttura monorepo)
|
||||||
|
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
||||||
|
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
||||||
|
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
||||||
|
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
||||||
|
- `docker-compose.{dev,}.yml` con build context `.`.
|
||||||
|
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
|
||||||
|
- `.dockerignore` aggiornato.
|
||||||
|
|
||||||
|
### Hardening post-restructure (smoke test 2026-04-26)
|
||||||
|
|
||||||
|
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
||||||
|
|
||||||
|
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
|
||||||
|
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
|
||||||
|
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
|
||||||
|
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `"..."` o riformulazione testuale.
|
||||||
|
|
||||||
|
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
|
||||||
|
|
||||||
|
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
|
||||||
|
|
||||||
|
## Layout repository (V2.0.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
TieMeasureFlow/
|
||||||
|
├── pyproject.toml + uv.lock + .python-version
|
||||||
|
├── Dockerfile (backend) + Dockerfile.frontend
|
||||||
|
├── docker-compose.dev.yml + docker-compose.yml
|
||||||
|
├── nginx/
|
||||||
|
├── uploads/ # volume Docker
|
||||||
|
├── docs/ # raggruppata e indicizzata
|
||||||
|
│ ├── README.md (indice)
|
||||||
|
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
||||||
|
│ ├── architecture/ # questo file + ROADMAP.md
|
||||||
|
│ ├── archive/ # piani storici
|
||||||
|
│ ├── specs/ # spec esterne (.docx)
|
||||||
|
│ └── superpowers/plans/ # piani TDD dettagliati
|
||||||
|
└── src/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py / config.py / database.py
|
||||||
|
│ ├── api/{routers,middleware}/
|
||||||
|
│ ├── models/{orm,api}/
|
||||||
|
│ ├── services/
|
||||||
|
│ ├── migrations/
|
||||||
|
│ ├── templates/
|
||||||
|
│ └── tests/
|
||||||
|
└── frontend/
|
||||||
|
└── flask_app/
|
||||||
|
├── app.py / config.py / compile_translations.py
|
||||||
|
├── blueprints/ (auth, maker, measure, statistics, admin)
|
||||||
|
├── services/ (api_client.py)
|
||||||
|
├── templates/ + static/ + translations/
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke test status
|
||||||
|
|
||||||
|
Validazione end-to-end in locale (2026-04-26):
|
||||||
|
|
||||||
|
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
||||||
|
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
||||||
|
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
||||||
|
- ✅ Login `admin/admin123` via web, sessione persistente
|
||||||
|
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
||||||
|
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
||||||
|
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
||||||
|
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
||||||
|
|
||||||
|
## Test status
|
||||||
|
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
|
||||||
|
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
|
||||||
|
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
|
||||||
|
|
||||||
|
## Stack confermato
|
||||||
|
|
||||||
|
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
||||||
|
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
||||||
|
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
||||||
|
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
||||||
|
|
||||||
|
## Decisioni architetturali rilevanti
|
||||||
|
|
||||||
|
| Decisione | Stato | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
||||||
|
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
||||||
|
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
||||||
|
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
||||||
|
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
||||||
|
|
||||||
|
## Branch git
|
||||||
|
|
||||||
|
- **Default:** `V2.0.0` (lavoro corrente)
|
||||||
|
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
||||||
|
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
|||||||
|
# Integrazione di VisionSuite in TieMeasureFlow — design
|
||||||
|
|
||||||
|
> Progetto: TieMeasureFlow V3.0.0 → V3.1.0
|
||||||
|
> Data: 2026-08-16
|
||||||
|
> Stato: design approvato, piano di implementazione da scrivere
|
||||||
|
|
||||||
|
## Perché
|
||||||
|
|
||||||
|
TieMeasureFlow oggi registra misure prese a mano, con il calibro USB o con il
|
||||||
|
tastierino. Il passo successivo che il cliente chiede è misurare con una camera:
|
||||||
|
acquisire l'immagine di un pezzo e ricavarne le quote, oppure confrontarne il
|
||||||
|
profilo con il disegno DXF di riferimento.
|
||||||
|
|
||||||
|
La visione artificiale non va scritta qui. Tielogic ha già
|
||||||
|
[VisionSuite](ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git), un
|
||||||
|
monorepo di moduli riusabili il cui README indica esplicitamente TieMeasureFlow
|
||||||
|
fra i consumatori previsti. Questo documento descrive come agganciarlo.
|
||||||
|
|
||||||
|
Il terreno è in parte già preparato. La migrazione `007_task_type` ha introdotto
|
||||||
|
il tipo di task dichiarato e l'enum contiene fin da allora due valori che nessuno
|
||||||
|
produce né consuma ancora:
|
||||||
|
|
||||||
|
```python
|
||||||
|
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
|
||||||
|
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo lavoro li riempie.
|
||||||
|
|
||||||
|
## Che cosa entra e che cosa resta fuori
|
||||||
|
|
||||||
|
**Entra:** l'acquisizione da camera, l'esecuzione di un programma di visione su
|
||||||
|
quell'immagine — pattern matching più misure, oppure pattern matching più
|
||||||
|
confronto DXF — e l'ingresso dei risultati nel modello di misura che già esiste.
|
||||||
|
|
||||||
|
**Resta fuori, per ora:** i semafori USB. Sono previsti dall'architettura — è
|
||||||
|
l'agente di stazione a possederli, insieme alle camere — ma non fanno parte di
|
||||||
|
questa consegna. Il confine è disegnato perché ci entrino senza ristrutturazioni.
|
||||||
|
|
||||||
|
## Le otto decisioni
|
||||||
|
|
||||||
|
Sono le scelte prese in fase di brainstorming, con il motivo per cui sono state
|
||||||
|
prese. Chi legge fra sei mesi ha bisogno del motivo più della scelta.
|
||||||
|
|
||||||
|
### 1. La visione gira sia sul server sia sulla stazione
|
||||||
|
|
||||||
|
Il motore è un componente unico distribuito in due posti: accanto al server e
|
||||||
|
dentro l'agente di stazione. Questo dà la resilienza — se cade il collegamento,
|
||||||
|
la stazione continua a misurare — e permette al server di elaborare in fase di
|
||||||
|
produzione, di comporre ricette per conto del Maker e di rifare analisi su
|
||||||
|
immagini archiviate.
|
||||||
|
|
||||||
|
È coerente con la decisione **D-3**, chiusa il 28/07: dati sul server, app di
|
||||||
|
stazione su ogni PC.
|
||||||
|
|
||||||
|
### 2. Server e stazione montano la stessa identica versione di VisionSuite
|
||||||
|
|
||||||
|
Non è una raccomandazione di build: è un vincolo metrologico. Se il server monta
|
||||||
|
un commit e una stazione ne monta un altro, lo stesso pezzo con lo stesso grafo
|
||||||
|
può dare due numeri diversi, e nessuno se ne accorge finché non arriva una
|
||||||
|
contestazione.
|
||||||
|
|
||||||
|
Da qui tre obblighi che il design deve garantire:
|
||||||
|
|
||||||
|
- VisionSuite entra come **sottomodulo git su un commit preciso**, lo stesso per
|
||||||
|
entrambi — la forma che il README di VisionSuite prescrive
|
||||||
|
- ogni misura porta scritto **con quale versione del motore** è stata prodotta
|
||||||
|
- una stazione che monta una versione diversa da quella del server **si rifiuta
|
||||||
|
di misurare** e lo dichiara, invece di produrre numeri plausibili e sbagliati
|
||||||
|
|
||||||
|
### 3. L'agente di stazione è sottile
|
||||||
|
|
||||||
|
L'agente parla ai device, consegna immagini e — più avanti — piloterà i semafori.
|
||||||
|
Non contiene decisioni di visione.
|
||||||
|
|
||||||
|
La conseguenza pratica è l'impacchettamento: un agente che non contiene
|
||||||
|
VisionSuite non contiene nemmeno PyTorch. Distribuirlo come eseguibile su Windows
|
||||||
|
o come container su Linux diventa realistico, e la scelta su driver e formato può
|
||||||
|
restare tardiva — che è bene, perché dipende dalla **D-4**, ancora aperta.
|
||||||
|
|
||||||
|
L'agente incorpora il motore solo quando la stazione è configurata per elaborare
|
||||||
|
in locale, e in quel caso resta comunque un guscio: riceve un grafo, lo esegue,
|
||||||
|
restituisce le uscite.
|
||||||
|
|
||||||
|
### 4. Il server non importa mai VisionSuite
|
||||||
|
|
||||||
|
La visione sul lato server vive in un **servizio separato** nello stesso compose,
|
||||||
|
il *vision worker*. Il server FastAPI continua a fare quello che fa —
|
||||||
|
autenticazione, ricette, misure, statistica — e resta leggero.
|
||||||
|
|
||||||
|
Quattro ragioni concrete: l'immagine dell'API non passa da poche centinaia di
|
||||||
|
megabyte a cinque gigabyte; un aggiornamento di VisionSuite non richiede di
|
||||||
|
riavviare l'API in produzione; un'elaborazione che va in crash non porta giù le
|
||||||
|
richieste degli altri tablet; il giorno in cui la visione chiede una GPU o una
|
||||||
|
macchina propria si sposta senza toccare il resto.
|
||||||
|
|
||||||
|
Il confine va difeso: se un domani il server importasse VisionSuite per una
|
||||||
|
scorciatoia, il vantaggio si perde in silenzio.
|
||||||
|
|
||||||
|
### 5. La configurazione dei device sta sul PC
|
||||||
|
|
||||||
|
Le camere — e domani i semafori — appartengono alla macchina, non alla ricetta.
|
||||||
|
Il server ne tiene uno **specchio** in sola lettura, popolato dall'agente quando
|
||||||
|
si connette, perché il Maker deve poter scegliere un device da un elenco stando
|
||||||
|
alla scrivania.
|
||||||
|
|
||||||
|
Un device che l'agente non dichiara più è **offline**, non cancellato: una
|
||||||
|
ricetta che lo referenzia deve continuare a esistere e a spiegare perché non può
|
||||||
|
girare.
|
||||||
|
|
||||||
|
### 6. Dove si esegue lo decide la configurazione della stazione
|
||||||
|
|
||||||
|
Ogni PC dichiara se elabora in locale o delega al server, nella stessa
|
||||||
|
configurazione dove già stanno i device. Il default è il server.
|
||||||
|
|
||||||
|
Le macchine capaci si prendono il carico e non mandano immagini in rete; quelle
|
||||||
|
deboli delegano. Se il server non risponde e la stazione ha il motore, la
|
||||||
|
stazione ripiega in locale e lo segna nel risultato. Un impianto misto si governa
|
||||||
|
senza toccare le ricette.
|
||||||
|
|
||||||
|
La ricetta non dice mai dove gira: legare una scelta di impianto a un documento
|
||||||
|
di produzione significa dover rimettere mano alle ricette per spostare il carico.
|
||||||
|
|
||||||
|
### 7. Il verdetto lo dà TieMeasureFlow, non il grafo
|
||||||
|
|
||||||
|
Gli strumenti di quota di `vs-task` hanno anche loro `nominal`, `plus`, `minus` e
|
||||||
|
un `outcome`. Quell'esito viene ignorato e quei limiti restano vuoti: il grafo
|
||||||
|
produce numeri, `measurement_service` decide se sono buoni.
|
||||||
|
|
||||||
|
Due giudici sullo stesso valore è il modo più rapido per ottenere un CSV che si
|
||||||
|
contraddice e una contestazione che non si sa come chiudere.
|
||||||
|
|
||||||
|
La conseguenza è che tutto il resto continua a funzionare senza modifiche:
|
||||||
|
pass/warning/fail, gate del fuori tolleranza con autorizzazione del capoturno,
|
||||||
|
Cp/Cpk, carta di controllo, colonne del CSV di produzione. Una quota da camera è
|
||||||
|
una misura come le altre.
|
||||||
|
|
||||||
|
### 8. La sorgente immagine è un'astrazione a tre facce
|
||||||
|
|
||||||
|
Il motore riceve un'immagine e non sa da dove venga. Le tre sorgenti sono: dal
|
||||||
|
vivo da un device tramite l'agente, un'immagine di riferimento salvata sul
|
||||||
|
server, un file caricato dal Maker.
|
||||||
|
|
||||||
|
Non è un'astrazione nostra: è quella che VisionSuite si è già data — *«espongono
|
||||||
|
una sorgente, non una telecamera: è questo che permette di collaudare un impianto
|
||||||
|
a cinque telecamere senza avere le cinque telecamere sul tavolo, e di scrivere
|
||||||
|
prove che danno sempre lo stesso risultato»*.
|
||||||
|
|
||||||
|
Sblocca tre cose che altrimenti non ci sarebbero:
|
||||||
|
|
||||||
|
- il Maker compone alla scrivania, senza tenere occupata una stazione né avere il
|
||||||
|
pezzo in mano
|
||||||
|
- una ricetta si prova prima di andare in produzione, invece di scoprirne i
|
||||||
|
difetti misurando
|
||||||
|
- diventa possibile una **prova di non regressione**: salvando accanto
|
||||||
|
all'immagine il risultato atteso, si riesegue l'intero parco ricette dopo un
|
||||||
|
aggiornamento di VisionSuite e si vede quali quote si sono mosse
|
||||||
|
|
||||||
|
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
||||||
|
sottomodulo, con un motore che sta su due host.
|
||||||
|
|
||||||
|
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
|
||||||
|
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
|
||||||
|
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
|
||||||
|
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
|
||||||
|
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
|
||||||
|
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
|
||||||
|
> completa.
|
||||||
|
|
||||||
|
## Architettura
|
||||||
|
|
||||||
|
### I componenti
|
||||||
|
|
||||||
|
| Componente | Nuovo | Dove vive | Responsabilità |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `vendor/visionsuite` | sì | sottomodulo git | i pacchetti `vs-core`, `vs-camera`, `vs-pm2d`, `vs-measure`, `vs-dxf`, su un commit fisso |
|
||||||
|
| Vision runner | sì | libreria condivisa | immagine + grafo + calibrazione → quote e artefatti. Unica implementazione, due host |
|
||||||
|
| Vision worker | sì | container nel compose | espone il runner dietro una API interna. Il container pesante |
|
||||||
|
| Agente di stazione | sì | PC di reparto | possiede i device, acquisisce, apre la connessione verso il server, incorpora il runner se configurato |
|
||||||
|
| Backend FastAPI | modificato | container esistente | tipi di task, grafo sul task, registro device, inoltro, ingresso risultati. **Nessun import di VisionSuite** |
|
||||||
|
| Frontend Flask | modificato | container esistente | Maker: device, fotogramma, aggancio quote. Measure: schermata del task con camera |
|
||||||
|
|
||||||
|
L'aggancio del sottomodulo segue la forma prescritta dal README di VisionSuite:
|
||||||
|
dipendenza dai soli pacchetti che servono, dichiarati in path editable.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.uv.sources]
|
||||||
|
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||||
|
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
I due host non montano lo stesso insieme, ed è il punto dell'intera struttura di
|
||||||
|
VisionSuite:
|
||||||
|
|
||||||
|
| Pacchetto | Worker | Agente | Perché |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `vs-core` | sì | sì | strutture di base e calibrazione, servono a entrambi |
|
||||||
|
| `vs-pm2d` | sì | solo se elabora in locale | pattern matching |
|
||||||
|
| `vs-measure` | sì | solo se elabora in locale | primitive e quote |
|
||||||
|
| `vs-dxf` | sì | solo se elabora in locale | confronto col disegno. È quello che porta PyTorch |
|
||||||
|
| `vs-camera` | **no** | sì | il worker non tocca hardware: riceve immagini già acquisite |
|
||||||
|
|
||||||
|
Un agente su una stazione che delega al server monta quindi due soli pacchetti,
|
||||||
|
`vs-core` e `vs-camera`, e resta leggero. È la differenza fra un eseguibile
|
||||||
|
distribuibile e un'installazione da cinque gigabyte su ogni PC di reparto.
|
||||||
|
|
||||||
|
I nomi di importazione non sono uniformi — `vs-core` si importa come
|
||||||
|
`visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare` — ed è una
|
||||||
|
stortura nota e dichiarata a monte, non un errore da correggere qui.
|
||||||
|
|
||||||
|
### Il flusso di una misura, esecuzione sul server
|
||||||
|
|
||||||
|
```
|
||||||
|
operatore apre il task
|
||||||
|
→ client chiede al server di eseguire
|
||||||
|
→ server chiede all'agente un fotogramma sul device dichiarato
|
||||||
|
→ agente acquisisce e carica l'immagine
|
||||||
|
→ server passa immagine e grafo al worker
|
||||||
|
→ worker restituisce le uscite
|
||||||
|
→ server applica pass/fail, gate del fuori tolleranza, salva in measurements
|
||||||
|
→ client mostra esito e immagine con le zone dove ha guardato
|
||||||
|
```
|
||||||
|
|
||||||
|
### Il flusso con esecuzione in locale
|
||||||
|
|
||||||
|
Il giro si accorcia: il server manda il grafo all'agente, l'agente acquisisce ed
|
||||||
|
esegue, e rimanda uscite più immagine. **Da lì in poi il server fa le stesse
|
||||||
|
identiche cose.** Il verdetto, il gate e il salvataggio non cambiano mai posto:
|
||||||
|
cambia chi calcola i numeri, non chi decide se sono buoni.
|
||||||
|
|
||||||
|
È questo che rende le due strade equivalenti a valle, e che permette di cambiare
|
||||||
|
la configurazione di una stazione senza toccare né il frontend né le ricette.
|
||||||
|
|
||||||
|
## Modello dati
|
||||||
|
|
||||||
|
### Il grafo di visione
|
||||||
|
|
||||||
|
`recipe_tasks` prende **`vision_json`** (JSON, nullable): il grafo di `vs-task`
|
||||||
|
serializzato, opaco al server.
|
||||||
|
|
||||||
|
Il versionamento arriva gratis. Il copy-on-write delle ricette copia già i task
|
||||||
|
in profondità, quindi una modifica al grafo su una ricetta con misure produce una
|
||||||
|
versione nuova come qualunque altra modifica; in `recipe_service` è un campo in
|
||||||
|
più nella copia, non una logica nuova.
|
||||||
|
|
||||||
|
Non si riusa `annotations_json`, che sta lì accanto: le annotazioni sono un
|
||||||
|
disegno per l'operatore, il grafo è un programma da eseguire. Hanno cicli di vita
|
||||||
|
e regole di validazione diversi, e mescolarli significa non poter validare né
|
||||||
|
l'uno né l'altro.
|
||||||
|
|
||||||
|
### Il legame fra quota del grafo e quota della ricetta
|
||||||
|
|
||||||
|
`recipe_subtasks` prende **`vision_output`** (String, nullable): la coppia
|
||||||
|
«identificativo dello strumento, nome della sua uscita».
|
||||||
|
|
||||||
|
È la stessa forma di legame che `vs-task` usa internamente, e per la stessa
|
||||||
|
ragione dichiarata nel suo README: *«Il legame è un ID, non un puntatore»* — un
|
||||||
|
riferimento in memoria non si scrive su disco.
|
||||||
|
|
||||||
|
Tutto il resto della subtask — nominale, UTL, UWL, LWL, LTL, unità — resta com'è
|
||||||
|
e continua a significare quello che significa oggi.
|
||||||
|
|
||||||
|
### Le misure
|
||||||
|
|
||||||
|
`input_method` passa da `("usb_caliper", "manual")` a
|
||||||
|
`("usb_caliper", "manual", "camera")`.
|
||||||
|
|
||||||
|
Il contorno della visione **non** va su `measurements`. Quella tabella la leggono
|
||||||
|
la statistica e l'export a ogni giro, e appesantirla la rovina. Ma soprattutto
|
||||||
|
sarebbe sbagliato di modello: una sola acquisizione produce N quote, e immagine,
|
||||||
|
overlay, device e versione del motore sono gli stessi per tutte.
|
||||||
|
|
||||||
|
Nasce quindi **`vision_results`**, una riga per **esecuzione**:
|
||||||
|
|
||||||
|
| Campo | Perché c'è |
|
||||||
|
|---|---|
|
||||||
|
| `image_path`, `overlay_path` | l'immagine, e quella con sopra le zone dove ha guardato |
|
||||||
|
| `engine_version` | il commit di VisionSuite che ha prodotto i numeri |
|
||||||
|
| `executed_on` | `server` o `station` |
|
||||||
|
| `station_id`, `device_id` | da quale macchina e quale camera |
|
||||||
|
| `calibration_snapshot` | la taratura in vigore in quel momento, **copiata** |
|
||||||
|
| `graph_snapshot` | il grafo effettivamente eseguito, **copiato** |
|
||||||
|
| `duration_ms`, `executed_at` | quanto è costata, quando |
|
||||||
|
|
||||||
|
Su `measurements` una sola colonna nuova: `vision_result_id`, FK nullable.
|
||||||
|
|
||||||
|
Calibrazione e grafo si copiano invece di essere referenziati per la stessa
|
||||||
|
ragione: una taratura rifatta sei mesi dopo non deve riscrivere il significato di
|
||||||
|
una misura già presa.
|
||||||
|
|
||||||
|
### Il registro dei device
|
||||||
|
|
||||||
|
**`station_devices`**: `station_id`, `code` (l'identificativo locale), `kind`
|
||||||
|
(`camera` oggi, `light` domani), `label`, `capabilities` (JSON), `calibration`
|
||||||
|
(JSON) con `calibration_taken_at`, `last_seen_at`.
|
||||||
|
|
||||||
|
Si popola dall'agente. Il server lo riceve e non lo modifica.
|
||||||
|
|
||||||
|
### Le immagini di riferimento
|
||||||
|
|
||||||
|
**`vision_reference_images`**: `task_id`, `path`, e la provenienza per intero —
|
||||||
|
`station_id`, `device_id`, snapshot della calibrazione, `engine_version`,
|
||||||
|
`acquired_at`, una nota — più `expected_json` per l'atteso della prova di non
|
||||||
|
regressione.
|
||||||
|
|
||||||
|
La provenienza non è rimandabile. Senza, si riesegue il grafo mesi dopo, escono
|
||||||
|
numeri diversi, e non c'è modo di distinguere un motore aggiornato da un'ottica
|
||||||
|
spostata.
|
||||||
|
|
||||||
|
### Migrazione 011
|
||||||
|
|
||||||
|
Cinque cose: `vision_json` su `recipe_tasks`, `vision_output` su
|
||||||
|
`recipe_subtasks`, `camera` in `input_method_enum`, `vision_result_id` su
|
||||||
|
`measurements`, e le tre tabelle nuove.
|
||||||
|
|
||||||
|
Più una sesta, **da fare adesso o mai**: `task_type_enum` contiene `xf_compare`,
|
||||||
|
refuso per `dxf_compare`. Oggi nessuna riga usa quel valore e correggerlo costa
|
||||||
|
una riga. Dal primo task salvato in poi diventa una migrazione di dati, e il
|
||||||
|
refuso finisce nelle API pubbliche.
|
||||||
|
|
||||||
|
Come le migrazioni 008 e 010, va scritta in *batch mode* per la compatibilità
|
||||||
|
SQLite dei test e verificata in resa MySQL con `alembic upgrade X:Y --sql` prima
|
||||||
|
del deploy: è la procedura che il progetto si è già dato.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
L'agente apre lui la connessione. **Il server non bussa mai a un PC**, e questo
|
||||||
|
evita di dover aprire porte sulle macchine di reparto — che è materia della D-4.
|
||||||
|
|
||||||
|
| Verso | Endpoint | Cosa fa |
|
||||||
|
|---|---|---|
|
||||||
|
| agente → server | `WS /api/stations/agent` | si autentica con la chiave di stazione, dichiara i device, resta in ascolto |
|
||||||
|
| server → agente | comando `acquire` | «scatta sul device X»; l'agente carica con `POST /api/vision/frames` |
|
||||||
|
| server → agente | comando `execute` | esecuzione in locale: manda il grafo, riceve le uscite |
|
||||||
|
| client → server | `POST /api/vision/execute` | esegui il task per la produzione in corso; **il server sceglie dove** |
|
||||||
|
| client → server | `POST /api/vision/preview` | authoring: esegui su una sorgente qualsiasi, non salvare nulla |
|
||||||
|
| client → server | `GET /api/stations/{code}/devices` | l'elenco per il Maker |
|
||||||
|
| client → server | `POST /api/vision/reference-images` | salva un fotogramma come riferimento |
|
||||||
|
| server → worker | `POST /run` | interno, mai esposto: immagine e grafo → uscite e overlay |
|
||||||
|
|
||||||
|
Un solo endpoint decide dove si esegue, ed è `execute`. Il client non sa e non
|
||||||
|
deve sapere se ha misurato il server o la stazione: chiede una misura e riceve un
|
||||||
|
esito.
|
||||||
|
|
||||||
|
## Errori e casi limite
|
||||||
|
|
||||||
|
| Caso | Comportamento |
|
||||||
|
|---|---|
|
||||||
|
| Versione del motore diversa fra stazione e server | la stazione si rifiuta di misurare e lo dichiara. Mai produrre numeri con un motore non concorde |
|
||||||
|
| Agente non raggiungibile | il task di visione non è eseguibile e lo dice. Da decidere con il cliente se la stazione debba consentire il ripiego sul calibro |
|
||||||
|
| Server non raggiungibile, stazione con motore | la stazione esegue in locale e lo segna in `executed_on` |
|
||||||
|
| Device dichiarato dalla ricetta ma offline | la ricetta resta valida, il task spiega perché non può girare |
|
||||||
|
| Il grafo non produce un'uscita attesa da una subtask | la misura non si salva, l'errore nomina l'uscita mancante |
|
||||||
|
| Elaborazione in crash o oltre il tempo | il worker isola il guasto, l'API resta in piedi, il task riporta l'errore |
|
||||||
|
| Device senza calibrazione | le quote in millimetri non si producono. Mai convertire con un fattore implicito |
|
||||||
|
|
||||||
|
## Prove
|
||||||
|
|
||||||
|
Ai tre file di test che il progetto ha già e che leggono i sorgenti invece di
|
||||||
|
renderizzare — `test_offline.py`, `test_layout_shell.py`,
|
||||||
|
`test_template_js_syntax.py` — se ne aggiunge la stessa specie:
|
||||||
|
|
||||||
|
- **una prova che il server non importi VisionSuite.** È il confine su cui poggia
|
||||||
|
la decisione 4, e senza qualcuno che lo controlli si perde in silenzio
|
||||||
|
- **una prova che il commit del sottomodulo dichiarato coincida** con quello che
|
||||||
|
server e agente montano
|
||||||
|
|
||||||
|
Per il resto, il runner si prova su immagini di riferimento salvate — che è
|
||||||
|
precisamente ciò per cui l'astrazione della sorgente esiste. Nessuna prova
|
||||||
|
richiede una camera collegata.
|
||||||
|
|
||||||
|
## Quello che questo design non risolve
|
||||||
|
|
||||||
|
- **Il dimensionamento del server.** Il pattern matching è pesante e, con
|
||||||
|
l'esecuzione lato server come default, la CPU è contesa fra tutte le stazioni.
|
||||||
|
Va aggiunto alla **D-4**, che finora chiedeva solo quale macchina e quanto
|
||||||
|
disco: ora chiede anche quanti core e quanta RAM.
|
||||||
|
- **La conservazione delle immagini.** Due politiche distinte, entrambe da
|
||||||
|
portare in D-4: le immagini di riferimento sono poche e vivono quanto la
|
||||||
|
ricetta; quelle di produzione sono una per pezzo e crescono senza limite, e
|
||||||
|
vogliono una scadenza o una regola del tipo «si conservano solo quelle dei
|
||||||
|
pezzi fuori tolleranza».
|
||||||
|
- **Che cosa fa la stazione se la rete cade** e non è configurata per elaborare
|
||||||
|
in locale: si blocca, o consente il calibro e segna il task come non
|
||||||
|
eseguibile? È una domanda per il cliente.
|
||||||
|
- **L'impacchettamento dell'agente.** Le macchine possono essere Linux o Windows
|
||||||
|
e i driver possono stare sulla macchina o nel container. La decisione è
|
||||||
|
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
||||||
|
|
||||||
|
## Correzioni imposte dall'esecuzione
|
||||||
|
|
||||||
|
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
|
||||||
|
che qui erano scritte male o taciute, corrette dai fatti.
|
||||||
|
|
||||||
|
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
|
||||||
|
|
||||||
|
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
|
||||||
|
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
|
||||||
|
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
|
||||||
|
lettura cattiva irrisolta.
|
||||||
|
|
||||||
|
Una camera produce tutte le N quote **nello stesso istante**. Dentro
|
||||||
|
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
|
||||||
|
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
|
||||||
|
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
|
||||||
|
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
|
||||||
|
|
||||||
|
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
|
||||||
|
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
|
||||||
|
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
|
||||||
|
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
|
||||||
|
|
||||||
|
### Il repository è deliberatamente bi-ambiente
|
||||||
|
|
||||||
|
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
|
||||||
|
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
|
||||||
|
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
|
||||||
|
tengono separate le due risoluzioni.
|
||||||
|
|
||||||
|
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
|
||||||
|
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
|
||||||
|
|
||||||
|
### La versione del motore si timbra al build
|
||||||
|
|
||||||
|
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
|
||||||
|
nel contesto di build non esiste: interrogare git dentro il container non
|
||||||
|
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
|
||||||
|
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
|
||||||
|
l'altro. Il build fallisce se la variabile manca.
|
||||||
|
|
||||||
|
### La divisione degli extra compra esplicitezza, non peso
|
||||||
|
|
||||||
|
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
|
||||||
|
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
|
||||||
|
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
|
||||||
|
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
|
||||||
|
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
|
||||||
|
— e va aggiunta alle verifiche su VisionSuite qui sotto.
|
||||||
|
|
||||||
|
### L'immagine di produzione non viene ancora conservata
|
||||||
|
|
||||||
|
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
|
||||||
|
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
|
||||||
|
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
|
||||||
|
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
|
||||||
|
|
||||||
|
### Prima di applicare le migrazioni in produzione
|
||||||
|
|
||||||
|
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
|
||||||
|
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
|
||||||
|
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
|
||||||
|
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
|
||||||
|
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
|
||||||
|
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
|
||||||
|
reali, perché `measurements` è la tabella grossa.
|
||||||
|
|
||||||
|
## Da verificare prima di cominciare
|
||||||
|
|
||||||
|
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
||||||
|
*«Rassegna delle firme pubbliche prima che si congelino»*: le API si stanno
|
||||||
|
stabilizzando proprio ora. Prima di fissare il commit del sottomodulo:
|
||||||
|
|
||||||
|
- **il suo README è indietro rispetto al codice.** Dichiara `vs-measure` «da
|
||||||
|
scrivere» quando invece esiste, con circa 4.865 righe e tredici file di test, e
|
||||||
|
dichiara `vs-dxf` non avviabile quando l'import di `common.calibration` non c'è
|
||||||
|
più. Va riletto lo stato reale, non quello documentato.
|
||||||
|
- **il test di calibrazione dato per rosso** — la focale recuperata a 373 contro
|
||||||
|
456 attesi, con tolleranza al 5% — va verificato sullo stato attuale. Il README
|
||||||
|
di VisionSuite avverte di guardarlo *prima* di costruirci sopra misure in
|
||||||
|
millimetri, che è esattamente ciò che questo lavoro fa. Le calibrazioni sono
|
||||||
|
state toccate il 13/08 e lo stato potrebbe essere cambiato.
|
||||||
|
- **il peso dell'ambiente.** `vs-dxf` dipende da PyTorch e l'installazione
|
||||||
|
completa arriva a circa 5 GB. È la ragione per cui il worker è un container a
|
||||||
|
parte, e va confermato che il solo sottoinsieme necessario pesi meno.
|
||||||
+33
-1
@@ -53,6 +53,32 @@ dev = [
|
|||||||
"coverage>=7.0.0",
|
"coverage>=7.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
|
||||||
|
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
|
||||||
|
# marker keeps the base 3.11 resolution untouched. numpy is declared
|
||||||
|
# explicitly - runner.py imports it directly, and it must not depend on
|
||||||
|
# being pulled in as a side effect of vs-pm2d's own dependencies.
|
||||||
|
vision = [
|
||||||
|
"visionsuite; python_version>='3.13'",
|
||||||
|
"vs-task; python_version>='3.13'",
|
||||||
|
"vs-measure; python_version>='3.13'",
|
||||||
|
"vs-pm2d; python_version>='3.13'",
|
||||||
|
"numpy; python_version>='3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
|
||||||
|
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
|
||||||
|
# whole structure exists so a consumer installs only what it needs, and a
|
||||||
|
# later station agent embeds the same runner without being a web service.
|
||||||
|
# It must not drag fastapi/uvicorn onto shop-floor PCs.
|
||||||
|
vision-worker = [
|
||||||
|
"tiemeasureflow[vision]",
|
||||||
|
"fastapi>=0.110.0; python_version>='3.13'",
|
||||||
|
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
|
||||||
|
"pillow>=10.0.0; python_version>='3.13'",
|
||||||
|
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
# Backend
|
# Backend
|
||||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||||
@@ -70,6 +96,12 @@ packages = ["src/backend", "src/frontend"]
|
|||||||
# Pin the resolver to the deps we declared; reproducible builds.
|
# Pin the resolver to the deps we declared; reproducible builds.
|
||||||
package = false
|
package = false
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||||
|
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||||
|
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||||
|
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
|
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests", "src/vision/tests", "src/vision_worker/tests"]
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""Seed the recipes used to accept points 2, 3, 5, 8, 9 and 11 on a real screen.
|
||||||
|
|
||||||
|
The tests prove the rules hold; this puts them in front of an operator. Two
|
||||||
|
recipes, because they need opposite settings:
|
||||||
|
|
||||||
|
COLLAUDO-A typing allowed, lot compulsory, two measurement tasks with
|
||||||
|
documental tasks before, between and after them, interval of two
|
||||||
|
minutes. This is the one to drive: the measurement loop, the
|
||||||
|
bidirectional countdown, the out-of-tolerance gate, the cycle that
|
||||||
|
closes only on the last measurement task.
|
||||||
|
|
||||||
|
COLLAUDO-B caliper only, lot and serial compulsory. This one is to look at:
|
||||||
|
the keypad is not drawn, and Avvia does not start until both
|
||||||
|
traceability fields are filled in.
|
||||||
|
|
||||||
|
Run it inside the server container, where src.backend and the database settings
|
||||||
|
are already in place:
|
||||||
|
|
||||||
|
docker compose cp scripts/seed_collaudo.py server:/tmp/seed_collaudo.py
|
||||||
|
docker compose exec server uv run python /tmp/seed_collaudo.py --station ST-DEFAULT
|
||||||
|
|
||||||
|
Re-running finds the recipes already there and stops. --replace deletes them
|
||||||
|
first, along with anything measured against them - which is what you want between
|
||||||
|
one acceptance session and the next, and never what you want by accident.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
|
||||||
|
from src.backend.database import async_session_factory # noqa: E402
|
||||||
|
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||||
|
from src.backend.models.orm.production import ( # noqa: E402
|
||||||
|
ProductionEvent, ProductionRun,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||||
|
from src.backend.models.orm.station import ( # noqa: E402
|
||||||
|
Station, StationRecipeAssignment,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||||
|
from src.backend.models.orm.user import User # noqa: E402
|
||||||
|
from src.backend.services import auth_service # noqa: E402
|
||||||
|
|
||||||
|
CODES = ("COLLAUDO-A", "COLLAUDO-B")
|
||||||
|
|
||||||
|
# Limits chosen so every outcome is one deliberate keystroke away:
|
||||||
|
# 10.00 -> conforme 10.30 -> attenzione (dentro tolleranza)
|
||||||
|
# 12.00 -> fuori tolleranza, serve il capoturno
|
||||||
|
QUOTES_A1 = [
|
||||||
|
dict(marker_number=1, description="Diametro esterno",
|
||||||
|
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
|
||||||
|
dict(marker_number=2, description="Spessore parete",
|
||||||
|
nominal=25.0, ltl=24.5, lwl=24.8, uwl=25.2, utl=25.5, unit="mm"),
|
||||||
|
]
|
||||||
|
QUOTES_A2 = [
|
||||||
|
dict(marker_number=3, description="Lunghezza totale",
|
||||||
|
nominal=100.0, ltl=99.0, lwl=99.5, uwl=100.5, utl=101.0, unit="mm"),
|
||||||
|
]
|
||||||
|
QUOTES_B = [
|
||||||
|
dict(marker_number=1, description="Diametro con calibro",
|
||||||
|
nominal=10.0, ltl=9.5, lwl=9.8, uwl=10.2, utl=10.5, unit="mm"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# A description with a blank line and a bold phrase: point 11 is visible or it is
|
||||||
|
# not, and this is where you look.
|
||||||
|
NOTA_INIZIALE = (
|
||||||
|
"Prendere il pezzo dalla cassetta **a sinistra** della postazione.\n"
|
||||||
|
"Pulire la superficie prima di misurare.\n\n"
|
||||||
|
"**Attenzione**: non misurare pezzi ancora caldi."
|
||||||
|
)
|
||||||
|
NOTA_INTERMEDIA = (
|
||||||
|
"Girare il pezzo di **90 gradi** prima della seconda misura.\n"
|
||||||
|
"Se il pezzo non appoggia in piano, ripetere il posizionamento."
|
||||||
|
)
|
||||||
|
NOTA_FINALE = (
|
||||||
|
"Riporre il pezzo nella cassetta **a destra**.\n"
|
||||||
|
"Segnalare al capoturno qualsiasi quota fuori tolleranza."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_recipes(session, codes):
|
||||||
|
result = await session.execute(select(Recipe).where(Recipe.code.in_(codes)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_recipes(session, recipes):
|
||||||
|
"""Remove the collaudo recipes and everything measured against them."""
|
||||||
|
for recipe in recipes:
|
||||||
|
versions = (await session.execute(
|
||||||
|
select(RecipeVersion.id).where(RecipeVersion.recipe_id == recipe.id)
|
||||||
|
)).scalars().all()
|
||||||
|
runs = (await session.execute(
|
||||||
|
select(ProductionRun.id).where(ProductionRun.recipe_id == recipe.id)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
if versions:
|
||||||
|
await session.execute(
|
||||||
|
delete(Measurement).where(Measurement.version_id.in_(versions))
|
||||||
|
)
|
||||||
|
if runs:
|
||||||
|
await session.execute(
|
||||||
|
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(StationRecipeAssignment).where(
|
||||||
|
StationRecipeAssignment.recipe_id == recipe.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Versions, tasks and subtasks go with the recipe through the ORM cascade.
|
||||||
|
await session.delete(recipe)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_task(session, version_id, order_index, title, task_type,
|
||||||
|
directive=None, description=None, quotes=()):
|
||||||
|
task = RecipeTask(
|
||||||
|
version_id=version_id,
|
||||||
|
order_index=order_index,
|
||||||
|
title=title,
|
||||||
|
task_type=task_type,
|
||||||
|
directive=directive,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
await session.flush()
|
||||||
|
for quote in quotes:
|
||||||
|
session.add(RecipeSubtask(task_id=task.id, **quote))
|
||||||
|
await session.flush()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_recipe(session, user_id, *, code, name, description,
|
||||||
|
interval, requires_lot, requires_serial,
|
||||||
|
allow_manual_input):
|
||||||
|
recipe = Recipe(
|
||||||
|
code=code,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
created_by=user_id,
|
||||||
|
active=True,
|
||||||
|
measurement_interval_minutes=interval,
|
||||||
|
requires_lot=requires_lot,
|
||||||
|
requires_serial=requires_serial,
|
||||||
|
allow_manual_input=allow_manual_input,
|
||||||
|
)
|
||||||
|
session.add(recipe)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
version = RecipeVersion(
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_number=1,
|
||||||
|
is_current=True,
|
||||||
|
created_by=user_id,
|
||||||
|
change_notes="Ricetta di collaudo",
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
await session.flush()
|
||||||
|
return recipe, version
|
||||||
|
|
||||||
|
|
||||||
|
async def _assign(session, station, recipe, user_id):
|
||||||
|
session.add(StationRecipeAssignment(
|
||||||
|
station_id=station.id, recipe_id=recipe.id, assigned_by=user_id,
|
||||||
|
))
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_supervisor(session, username, password):
|
||||||
|
"""A capoturno with the role, not an admin standing in for one.
|
||||||
|
|
||||||
|
An admin is accepted by the authorisation check, but the acceptance session
|
||||||
|
should exercise the role the shop floor will actually use.
|
||||||
|
"""
|
||||||
|
existing = (await session.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing, False
|
||||||
|
|
||||||
|
user = await auth_service.create_user(
|
||||||
|
session,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
display_name="Capo Turno (collaudo)",
|
||||||
|
roles=["Supervisor", "MeasurementTec"],
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return user, True
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(station_code: str, replace: bool, supervisor_password: str) -> int:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
owner = (await session.execute(
|
||||||
|
select(User).where(User.is_admin == True) # noqa: E712
|
||||||
|
)).scalars().first()
|
||||||
|
if owner is None:
|
||||||
|
print("Nessun utente amministratore: impossibile intestare le ricette.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
station = (await session.execute(
|
||||||
|
select(Station).where(Station.code == station_code)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if station is None:
|
||||||
|
print(f"Stazione '{station_code}' inesistente.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
existing = await _find_recipes(session, CODES)
|
||||||
|
if existing and not replace:
|
||||||
|
found = ", ".join(r.code for r in existing)
|
||||||
|
print(f"Ricette di collaudo gia' presenti ({found}).")
|
||||||
|
print("Usa --replace per rifarle da zero (cancella anche le misure).")
|
||||||
|
return 1
|
||||||
|
if existing:
|
||||||
|
await _delete_recipes(session, existing)
|
||||||
|
print(f"Rimosse: {', '.join(r.code for r in existing)}")
|
||||||
|
|
||||||
|
# ---- COLLAUDO-A: the one to drive ----
|
||||||
|
recipe_a, version_a = await _create_recipe(
|
||||||
|
session, owner.id,
|
||||||
|
code="COLLAUDO-A",
|
||||||
|
name="Collaudo — ciclo di misura",
|
||||||
|
description=(
|
||||||
|
"Due task di misura con task documentali intorno. "
|
||||||
|
"Lotto obbligatorio, inserimento manuale consentito, "
|
||||||
|
"intervallo di 2 minuti."
|
||||||
|
),
|
||||||
|
interval=2,
|
||||||
|
requires_lot=True,
|
||||||
|
requires_serial=False,
|
||||||
|
allow_manual_input=True,
|
||||||
|
)
|
||||||
|
await _add_task(
|
||||||
|
session, version_a.id, 0, "Preparazione del pezzo", "note",
|
||||||
|
directive="Leggere prima di iniziare",
|
||||||
|
description=NOTA_INIZIALE,
|
||||||
|
)
|
||||||
|
await _add_task(
|
||||||
|
session, version_a.id, 1, "Misura 1 — diametro e spessore", "measure",
|
||||||
|
directive="Misurare le due quote in sequenza",
|
||||||
|
description="Quota **1** e quota **2**.\nUsare il calibro o il tastierino.",
|
||||||
|
quotes=QUOTES_A1,
|
||||||
|
)
|
||||||
|
await _add_task(
|
||||||
|
session, version_a.id, 2, "Riposizionamento", "note",
|
||||||
|
directive="Fra la prima e la seconda misura",
|
||||||
|
description=NOTA_INTERMEDIA,
|
||||||
|
)
|
||||||
|
await _add_task(
|
||||||
|
session, version_a.id, 3, "Misura 2 — lunghezza", "measure",
|
||||||
|
directive="Ultimo task di misura: chiude il ciclo",
|
||||||
|
description="Alla conferma di questa quota **riparte l'intervallo**.",
|
||||||
|
quotes=QUOTES_A2,
|
||||||
|
)
|
||||||
|
await _add_task(
|
||||||
|
session, version_a.id, 4, "Chiusura", "note",
|
||||||
|
directive="Dopo le misure",
|
||||||
|
description=NOTA_FINALE,
|
||||||
|
)
|
||||||
|
await _assign(session, station, recipe_a, owner.id)
|
||||||
|
|
||||||
|
# ---- COLLAUDO-B: the one to look at ----
|
||||||
|
recipe_b, version_b = await _create_recipe(
|
||||||
|
session, owner.id,
|
||||||
|
code="COLLAUDO-B",
|
||||||
|
name="Collaudo — solo calibro",
|
||||||
|
description=(
|
||||||
|
"Lotto e seriale obbligatori, inserimento manuale vietato: "
|
||||||
|
"il tastierino non compare e Avvia non parte finche' mancano i dati."
|
||||||
|
),
|
||||||
|
interval=5,
|
||||||
|
requires_lot=True,
|
||||||
|
requires_serial=True,
|
||||||
|
allow_manual_input=False,
|
||||||
|
)
|
||||||
|
await _add_task(
|
||||||
|
session, version_b.id, 0, "Misura con calibro", "measure",
|
||||||
|
directive="Il valore deve arrivare dal calibro",
|
||||||
|
description="Nessun tastierino: la ricetta **non ammette valori digitati**.",
|
||||||
|
quotes=QUOTES_B,
|
||||||
|
)
|
||||||
|
await _assign(session, station, recipe_b, owner.id)
|
||||||
|
|
||||||
|
supervisor, created = await _ensure_supervisor(
|
||||||
|
session, "capoturno", supervisor_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
print(f"Ricette create e assegnate alla stazione {station_code}:")
|
||||||
|
print(" COLLAUDO-A ciclo di misura, lotto obbligatorio, intervallo 2 min")
|
||||||
|
print(" COLLAUDO-B solo calibro, lotto e seriale obbligatori")
|
||||||
|
if created:
|
||||||
|
print(f"Capoturno creato: {supervisor.username} / {supervisor_password}")
|
||||||
|
print(" cambiare la password prima di qualunque uso reale.")
|
||||||
|
else:
|
||||||
|
print(f"Capoturno gia' presente: {supervisor.username} (password invariata)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--station", default="ST-DEFAULT",
|
||||||
|
help="codice della stazione a cui assegnare le ricette")
|
||||||
|
parser.add_argument("--replace", action="store_true",
|
||||||
|
help="cancella le ricette di collaudo esistenti e le misure fatte su di esse")
|
||||||
|
parser.add_argument("--supervisor-password", default="Collaudo2026!",
|
||||||
|
help="password del capoturno, se va creato")
|
||||||
|
args = parser.parse_args()
|
||||||
|
return asyncio.run(
|
||||||
|
seed(args.station, args.replace, args.supervisor_password)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -19,11 +19,14 @@ from src.backend.models.orm.recipe import RecipeVersion
|
|||||||
from src.backend.models.orm.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
|
MeasurementAuthorisation,
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
MeasurementCreate,
|
||||||
MeasurementListResponse,
|
MeasurementListResponse,
|
||||||
MeasurementResponse,
|
MeasurementResponse,
|
||||||
|
TaskProgressListResponse,
|
||||||
)
|
)
|
||||||
|
from src.backend.services import auth_service, measurement_service
|
||||||
from src.backend.services.measurement_service import save_measurement
|
from src.backend.services.measurement_service import save_measurement
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||||
@@ -47,6 +50,7 @@ async def create_measurement(
|
|||||||
serial_number=data.serial_number,
|
serial_number=data.serial_number,
|
||||||
input_method=data.input_method,
|
input_method=data.input_method,
|
||||||
input_duration_ms=data.input_duration_ms,
|
input_duration_ms=data.input_duration_ms,
|
||||||
|
production_run_id=data.production_run_id,
|
||||||
)
|
)
|
||||||
return MeasurementResponse.model_validate(measurement)
|
return MeasurementResponse.model_validate(measurement)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -56,6 +60,78 @@ async def create_measurement(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending-authorisation", response_model=MeasurementResponse | None)
|
||||||
|
async def get_pending_authorisation(
|
||||||
|
version_id: int = Query(..., gt=0),
|
||||||
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""The out-of-tolerance measurement holding this operator up, or null.
|
||||||
|
|
||||||
|
Asked on load, so that reloading the page is not a way past the gate: the
|
||||||
|
screen finds the same block waiting for it that the server enforces.
|
||||||
|
"""
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, version_id, user.id, production_run_id,
|
||||||
|
)
|
||||||
|
return MeasurementResponse.model_validate(blocking) if blocking else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/task-progress", response_model=TaskProgressListResponse)
|
||||||
|
async def get_task_progress(
|
||||||
|
version_id: int = Query(..., gt=0),
|
||||||
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""How many quotes of each measurement task are already taken.
|
||||||
|
|
||||||
|
The task list is where an operator picks up work they left behind, and until
|
||||||
|
now it showed a task begun and a task never touched identically.
|
||||||
|
"""
|
||||||
|
tasks = await measurement_service.task_progress(
|
||||||
|
db, version_id, user.id, production_run_id,
|
||||||
|
)
|
||||||
|
return TaskProgressListResponse(tasks=tasks)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
|
||||||
|
async def authorise_measurement(
|
||||||
|
measurement_id: int,
|
||||||
|
action: MeasurementAuthorisation,
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Let an out-of-tolerance value stand, on the capoturno's name.
|
||||||
|
|
||||||
|
Credentials go to this endpoint rather than to a separate check because the
|
||||||
|
approval has to end up attached to the measurement. A check whose answer is
|
||||||
|
thrown away is what this point had before: a modal that closed.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Measurement).where(Measurement.id == measurement_id)
|
||||||
|
)
|
||||||
|
measurement = result.scalar_one_or_none()
|
||||||
|
if measurement is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Measurement not found",
|
||||||
|
)
|
||||||
|
if measurement.pass_fail != "fail":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="This measurement is within tolerance: there is nothing to authorise",
|
||||||
|
)
|
||||||
|
|
||||||
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
)
|
||||||
|
measurement = await measurement_service.authorise_measurement(
|
||||||
|
db, measurement, supervisor,
|
||||||
|
)
|
||||||
|
return MeasurementResponse.model_validate(measurement)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch", response_model=list[MeasurementResponse])
|
@router.post("/batch", response_model=list[MeasurementResponse])
|
||||||
async def create_measurement_batch(
|
async def create_measurement_batch(
|
||||||
data: MeasurementBatchCreate,
|
data: MeasurementBatchCreate,
|
||||||
@@ -76,6 +152,7 @@ async def create_measurement_batch(
|
|||||||
serial_number=measurement_data.serial_number,
|
serial_number=measurement_data.serial_number,
|
||||||
input_method=measurement_data.input_method,
|
input_method=measurement_data.input_method,
|
||||||
input_duration_ms=measurement_data.input_duration_ms,
|
input_duration_ms=measurement_data.input_duration_ms,
|
||||||
|
production_run_id=measurement_data.production_run_id,
|
||||||
)
|
)
|
||||||
measurements.append(measurement)
|
measurements.append(measurement)
|
||||||
return [MeasurementResponse.model_validate(m) for m in measurements]
|
return [MeasurementResponse.model_validate(m) for m in measurements]
|
||||||
|
|||||||
@@ -15,21 +15,28 @@ from src.backend.models.api.production import (
|
|||||||
ProductionRunCreate,
|
ProductionRunCreate,
|
||||||
ProductionRunResponse,
|
ProductionRunResponse,
|
||||||
ProductionRunWithEventsResponse,
|
ProductionRunWithEventsResponse,
|
||||||
|
RemeasurePayload,
|
||||||
SupervisorAction,
|
SupervisorAction,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import production_service
|
from src.backend.services import auth_service, production_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
|
_DERIVED = {
|
||||||
|
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
||||||
|
}
|
||||||
|
|
||||||
def _as_response(run: ProductionRun) -> ProductionRunResponse:
|
|
||||||
|
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
|
||||||
|
"""The stored run plus everything the client cannot work out on its own."""
|
||||||
return ProductionRunResponse(
|
return ProductionRunResponse(
|
||||||
**ProductionRunResponse.model_validate(run).model_dump(
|
**ProductionRunResponse.model_validate(run).model_dump(exclude=_DERIVED),
|
||||||
exclude={"seconds_to_next_measurement", "overdue", "server_time"}
|
|
||||||
),
|
|
||||||
**production_service.describe(run),
|
**production_service.describe(run),
|
||||||
|
measurement_task_ids=await production_service.measurement_task_ids(
|
||||||
|
db, run.version_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ async def open_production_run(
|
|||||||
):
|
):
|
||||||
"""Start a production at a station."""
|
"""Start a production at a station."""
|
||||||
run = await production_service.open_run(db, data, user)
|
run = await production_service.open_run(db, data, user)
|
||||||
return _as_response(run)
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current", response_model=ProductionRunResponse | None)
|
@router.get("/current", response_model=ProductionRunResponse | None)
|
||||||
@@ -56,7 +63,7 @@ async def get_current_production_run(
|
|||||||
"""
|
"""
|
||||||
station = await production_service.get_station_by_code(db, station_code)
|
station = await production_service.get_station_by_code(db, station_code)
|
||||||
run = await production_service.get_open_run_for_station(db, station.id)
|
run = await production_service.get_open_run_for_station(db, station.id)
|
||||||
return _as_response(run) if run is not None else None
|
return await _as_response(db, run) if run is not None else None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
@@ -69,7 +76,7 @@ async def get_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
events = await production_service.list_run_events(db, run_id)
|
events = await production_service.list_run_events(db, run_id)
|
||||||
return ProductionRunWithEventsResponse(
|
return ProductionRunWithEventsResponse(
|
||||||
**_as_response(run).model_dump(),
|
**(await _as_response(db, run)).model_dump(),
|
||||||
events=[ProductionEventResponse.model_validate(e) for e in events],
|
events=[ProductionEventResponse.model_validate(e) for e in events],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,12 +88,31 @@ async def complete_measurement_cycle(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Record a finished measurement cycle and restart the interval."""
|
"""Record a finished measurement task; the last one restarts the interval."""
|
||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
run = await production_service.complete_cycle(
|
run = await production_service.complete_cycle(
|
||||||
db, run, user, note=payload.note if payload else None,
|
db, run, user,
|
||||||
|
task_id=payload.task_id if payload else None,
|
||||||
|
note=payload.note if payload else None,
|
||||||
)
|
)
|
||||||
return _as_response(run)
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/remeasure", response_model=ProductionRunResponse)
|
||||||
|
async def remeasure_in_cycle(
|
||||||
|
run_id: int,
|
||||||
|
payload: RemeasurePayload | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Turn the piece over and measure again, without closing the cycle."""
|
||||||
|
run = await production_service.get_run(db, run_id)
|
||||||
|
run = await production_service.remeasure(
|
||||||
|
db, run, user,
|
||||||
|
task_id=payload.task_id if payload else None,
|
||||||
|
note=payload.note if payload else None,
|
||||||
|
)
|
||||||
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
|
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
|
||||||
@@ -98,11 +124,11 @@ async def pause_production_run(
|
|||||||
):
|
):
|
||||||
"""Fermo linea - requires a supervisor."""
|
"""Fermo linea - requires a supervisor."""
|
||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await production_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
)
|
)
|
||||||
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||||
return _as_response(run)
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
|
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
|
||||||
@@ -114,11 +140,11 @@ async def resume_production_run(
|
|||||||
):
|
):
|
||||||
"""Restart a stopped line - requires a supervisor."""
|
"""Restart a stopped line - requires a supervisor."""
|
||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await production_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
)
|
)
|
||||||
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||||
return _as_response(run)
|
return await _as_response(db, run)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
|
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
|
||||||
@@ -130,8 +156,8 @@ async def close_production_run(
|
|||||||
):
|
):
|
||||||
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await production_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
)
|
)
|
||||||
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||||
return _as_response(run)
|
return await _as_response(db, run)
|
||||||
|
|||||||
@@ -172,12 +172,14 @@ async def create_task(
|
|||||||
new_task = RecipeTask(
|
new_task = RecipeTask(
|
||||||
version_id=new_version.id,
|
version_id=new_version.id,
|
||||||
order_index=max_order + 1,
|
order_index=max_order + 1,
|
||||||
|
task_type=data.task_type,
|
||||||
title=data.title,
|
title=data.title,
|
||||||
directive=data.directive,
|
directive=data.directive,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
|
vision_json=data.vision_json,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -196,6 +198,7 @@ async def create_task(
|
|||||||
ltl=sub_data.ltl,
|
ltl=sub_data.ltl,
|
||||||
unit=sub_data.unit,
|
unit=sub_data.unit,
|
||||||
image_path=sub_data.image_path,
|
image_path=sub_data.image_path,
|
||||||
|
vision_output=sub_data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(sub)
|
db.add(sub)
|
||||||
|
|
||||||
@@ -339,6 +342,7 @@ async def create_subtask(
|
|||||||
ltl=data.ltl,
|
ltl=data.ltl,
|
||||||
unit=data.unit,
|
unit=data.unit,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
|
vision_output=data.vision_output,
|
||||||
)
|
)
|
||||||
db.add(subtask)
|
db.add(subtask)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""The client asks for a measurement and gets an outcome.
|
||||||
|
|
||||||
|
It does not know, and must not know, whether the server or the station computed
|
||||||
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
|
the frontend or the recipes.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||||
|
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
|
# A vision graph measures pixels, not a PDF: stricter than files.py's
|
||||||
|
# ALLOWED_TYPES, which also accepts documents for technical drawings.
|
||||||
|
_IMAGE_EXTENSIONS = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||||
|
"""Shared by the endpoints of this router."""
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == task_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_image(image: UploadFile) -> bytes:
|
||||||
|
"""Type checked from the header before reading, size checked once the body
|
||||||
|
is in hand - the same two-step convention `files.py::upload_file` already
|
||||||
|
uses, reused rather than reinvented here.
|
||||||
|
"""
|
||||||
|
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||||
|
)
|
||||||
|
content = await image.read()
|
||||||
|
if not validate_file_size(len(content)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"File size {len(content)} bytes exceeds maximum "
|
||||||
|
f"{settings.max_upload_size_mb}MB"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||||
|
async def execute(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
lot_number: str | None = Form(None),
|
||||||
|
serial_number: str | None = Form(None),
|
||||||
|
production_run_id: int | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_measurement_tec),
|
||||||
|
) -> VisionExecuteResponse:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
|
||||||
|
result, saved = await vision_service.execute_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
await _read_image(image),
|
||||||
|
current_user.id,
|
||||||
|
version_id=task.version_id,
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return VisionExecuteResponse(
|
||||||
|
vision_result_id=result.id,
|
||||||
|
engine_version=result.engine_version,
|
||||||
|
measurements=[
|
||||||
|
VisionMeasurementResult(
|
||||||
|
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||||
|
)
|
||||||
|
for m in saved
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Composing a recipe and keeping a reference image are Maker operations, not
|
||||||
|
# measuring: both endpoints below gate on `require_maker`, not the
|
||||||
|
# `require_measurement_tec` that `execute` above uses.
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reference-images")
|
||||||
|
async def add_reference_image(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
note: str | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
content = await _read_image(image)
|
||||||
|
reference = await vision_service.save_reference_image(
|
||||||
|
db, task.id, content, note=note,
|
||||||
|
extension=_IMAGE_EXTENSIONS[image.content_type],
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def preview(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str | None = None
|
openrouter_api_key: str | None = None
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||||
|
|
||||||
|
# Vision worker (internal network only, reachable as `vision` in Compose)
|
||||||
|
vision_worker_url: str = "http://vision:8100"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async MySQL connection string."""
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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.setup import router as setup_router
|
||||||
from src.backend.api.routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
from src.backend.api.routers.production import router as production_router
|
from src.backend.api.routers.production import router as production_router
|
||||||
|
from src.backend.api.routers.vision import router as vision_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -75,6 +76,7 @@ app.include_router(reports_router)
|
|||||||
app.include_router(setup_router)
|
app.include_router(setup_router)
|
||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
app.include_router(production_router)
|
app.include_router(production_router)
|
||||||
|
app.include_router(vision_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""link measurements to their production run
|
||||||
|
|
||||||
|
Fine produzione has to hand the measurements of the whole production to the
|
||||||
|
statistics file. Without this column "the measurements of this production" is not a
|
||||||
|
query: measurements only knew their recipe version, lot and serial, none of which
|
||||||
|
delimits one run from the next on the same recipe and lot.
|
||||||
|
|
||||||
|
Nullable on purpose: measurements taken before this - and any taken outside a
|
||||||
|
production - simply have no run.
|
||||||
|
|
||||||
|
Revision ID: 006_measurement_run
|
||||||
|
Revises: 005_production_runs
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '006_measurement_run'
|
||||||
|
down_revision: Union[str, None] = '005_production_runs'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Batch mode, because the column carries a foreign key: adding a constraint is
|
||||||
|
# an ALTER that SQLite cannot do, and alembic raises rather than silently
|
||||||
|
# dropping it. On MySQL this is a plain ALTER; on SQLite it rebuilds the table.
|
||||||
|
# Keeping the key is worth the ceremony - ON DELETE SET NULL means a measurement,
|
||||||
|
# the record that matters in an audit, survives its run being deleted.
|
||||||
|
with op.batch_alter_table('measurements') as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
'production_run_id', sa.Integer,
|
||||||
|
sa.ForeignKey(
|
||||||
|
'production_runs.id',
|
||||||
|
ondelete='SET NULL',
|
||||||
|
# Named because batch mode requires it, and because an anonymous
|
||||||
|
# constraint cannot be referred to later.
|
||||||
|
name='fk_measurements_production_run',
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_measurements_production_run_id', 'measurements', ['production_run_id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Where the statistics file for a closed run was written. No constraint, so a
|
||||||
|
# plain ALTER is enough.
|
||||||
|
op.add_column(
|
||||||
|
'production_runs',
|
||||||
|
sa.Column('statistics_path', sa.String(500), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('production_runs', 'statistics_path')
|
||||||
|
op.drop_index('ix_measurements_production_run_id', table_name='measurements')
|
||||||
|
with op.batch_alter_table('measurements') as batch_op:
|
||||||
|
batch_op.drop_column('production_run_id')
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""add an explicit type to recipe tasks
|
||||||
|
|
||||||
|
Until now the kind of a task was deduced: quotes present meant a measurement,
|
||||||
|
otherwise a note. A measurement task whose quotes had not been entered yet was
|
||||||
|
therefore treated as a note - the system behaved differently depending on how
|
||||||
|
complete the recipe happened to be.
|
||||||
|
|
||||||
|
Existing rows are classified by the rule that was in force, so nothing changes
|
||||||
|
behaviour on upgrade: tasks with quotes become 'measure'. Tasks with no quotes but
|
||||||
|
a drawing attached become 'drawing' rather than 'note' - that is what they already
|
||||||
|
displayed as, and calling them notes would be the one place this migration did
|
||||||
|
change behaviour.
|
||||||
|
|
||||||
|
Revision ID: 007_task_type
|
||||||
|
Revises: 006_measurement_run
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '007_task_type'
|
||||||
|
down_revision: Union[str, None] = '006_measurement_run'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
TASK_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'recipe_tasks',
|
||||||
|
sa.Column(
|
||||||
|
'task_type',
|
||||||
|
sa.Enum(*TASK_TYPES, name='task_type_enum'),
|
||||||
|
nullable=False,
|
||||||
|
server_default='note',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index('ix_recipe_tasks_task_type', 'recipe_tasks', ['task_type'])
|
||||||
|
|
||||||
|
# Backfill by the rule the frontend used, so no recipe changes behaviour.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recipe_tasks
|
||||||
|
SET task_type = 'measure'
|
||||||
|
WHERE id IN (SELECT DISTINCT task_id FROM recipe_subtasks)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recipe_tasks
|
||||||
|
SET task_type = 'drawing'
|
||||||
|
WHERE task_type = 'note'
|
||||||
|
AND file_path IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_recipe_tasks_task_type', table_name='recipe_tasks')
|
||||||
|
op.drop_column('recipe_tasks', 'task_type')
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""widen the production event trace for the measurement loop
|
||||||
|
|
||||||
|
The loop needs to distinguish two things the trace could not say before: a
|
||||||
|
measurement task finished while the cycle is still running (task_measured), and the
|
||||||
|
piece turned over and measured again without closing the cycle (remeasure). Only
|
||||||
|
the last measurement task of a recipe still produces cycle_completed, because only
|
||||||
|
that one restarts the interval.
|
||||||
|
|
||||||
|
Both values are added in one go. Widening a MySQL enum rewrites the table, so the
|
||||||
|
values that are already planned go in now rather than one migration at a time -
|
||||||
|
the same reasoning as the task types in 007.
|
||||||
|
|
||||||
|
The events that happen inside a cycle also need to say which task they are about,
|
||||||
|
so the column comes with them: a task_measured that does not name its task records
|
||||||
|
nothing usable.
|
||||||
|
|
||||||
|
Revision ID: 008_loop_events
|
||||||
|
Revises: 007_task_type
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '008_loop_events'
|
||||||
|
down_revision: Union[str, None] = '007_task_type'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('start', 'cycle_completed', 'line_stop', 'resume', 'close')
|
||||||
|
NEW_TYPES = (
|
||||||
|
'start', 'cycle_completed', 'task_measured', 'remeasure',
|
||||||
|
'line_stop', 'resume', 'close',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _alter(values: Sequence[str]) -> None:
|
||||||
|
"""Restate the column with a new set of allowed values.
|
||||||
|
|
||||||
|
On MySQL this is the ALTER that rewrites the enum. On SQLite, where an enum is
|
||||||
|
stored as a plain VARCHAR, batch mode rebuilds the table instead of emitting an
|
||||||
|
ALTER the engine does not support - the tests run there.
|
||||||
|
"""
|
||||||
|
with op.batch_alter_table('production_events') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'event_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='production_event_type_enum'),
|
||||||
|
type_=sa.Enum(*values, name='production_event_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_alter(NEW_TYPES)
|
||||||
|
with op.batch_alter_table('production_events') as batch:
|
||||||
|
batch.add_column(sa.Column('task_id', sa.Integer(), nullable=True))
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_production_events_task_id', 'recipe_tasks', ['task_id'], ['id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('production_events') as batch:
|
||||||
|
batch.drop_constraint('fk_production_events_task_id', type_='foreignkey')
|
||||||
|
batch.drop_column('task_id')
|
||||||
|
# The new kinds of event have no equivalent in the old vocabulary, and a row
|
||||||
|
# left with a value the column no longer accepts would fail the rebuild.
|
||||||
|
op.execute(
|
||||||
|
"DELETE FROM production_events "
|
||||||
|
"WHERE event_type IN ('task_measured', 'remeasure')"
|
||||||
|
)
|
||||||
|
_alter(OLD_TYPES)
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""record who authorised an out-of-tolerance measurement
|
||||||
|
|
||||||
|
The supervisor gate existed on screen and nowhere else: the modal opened, the
|
||||||
|
credentials were checked, the modal closed, and nothing was written down. Nothing
|
||||||
|
depended on the answer either, so a value outside tolerance could be left behind
|
||||||
|
by dismissing the modal.
|
||||||
|
|
||||||
|
These two columns are what the rule now stands on. A failed measurement with no
|
||||||
|
supervisor on it is a measurement waiting for one, and while one is waiting the
|
||||||
|
operator cannot move to the next quote.
|
||||||
|
|
||||||
|
Existing rows are left null. Backfilling an authorisation that never happened
|
||||||
|
would be inventing an audit record; the fails already in the database belong to
|
||||||
|
production runs that are over, and nothing is waiting on them.
|
||||||
|
|
||||||
|
Revision ID: 010_meas_authorisation
|
||||||
|
Revises: 009_recipe_rules
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '010_meas_authorisation'
|
||||||
|
down_revision: Union[str, None] = '009_recipe_rules'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.add_column(sa.Column('supervisor_id', sa.Integer(), nullable=True))
|
||||||
|
batch.add_column(sa.Column('authorised_at', sa.DateTime(), nullable=True))
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_measurements_supervisor_id', 'users', ['supervisor_id'], ['id'],
|
||||||
|
)
|
||||||
|
batch.create_index(
|
||||||
|
'ix_measurements_supervisor_id', ['supervisor_id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.drop_index('ix_measurements_supervisor_id')
|
||||||
|
batch.drop_constraint('fk_measurements_supervisor_id', type_='foreignkey')
|
||||||
|
batch.drop_column('authorised_at')
|
||||||
|
batch.drop_column('supervisor_id')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""the vision graph lives on the task, and the quote names its output
|
||||||
|
|
||||||
|
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
|
||||||
|
renaming costs one statement here; from the first saved task onwards it would
|
||||||
|
be a data migration and the typo would already be in the public API.
|
||||||
|
|
||||||
|
Revision ID: 011_vision_graph
|
||||||
|
Revises: 010_meas_authorisation
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '011_vision_graph'
|
||||||
|
down_revision: Union[str, None] = '010_meas_authorisation'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
||||||
|
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_output', sa.String(length=120), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.drop_column('vision_output')
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
batch.drop_column('vision_json')
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""one row per vision execution, and camera as an input method
|
||||||
|
|
||||||
|
The vision surroundings do not go on `measurements`: statistics and the export
|
||||||
|
read that table on every pass. And it would be wrong modelling anyway - one
|
||||||
|
acquisition produces N quotes, and image, overlay, device and engine version are
|
||||||
|
the same for all of them.
|
||||||
|
|
||||||
|
Revision ID: 012_vision_results
|
||||||
|
Revises: 011_vision_graph
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '012_vision_results'
|
||||||
|
down_revision: Union[str, None] = '011_vision_graph'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_METHODS = ('usb_caliper', 'manual')
|
||||||
|
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('image_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('overlay_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('executed_on',
|
||||||
|
sa.Enum('server', 'station', name='vision_executed_on_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('executed_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_result_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_measurements_vision_result',
|
||||||
|
'vision_results', ['vision_result_id'], ['id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.drop_constraint('fk_measurements_vision_result',
|
||||||
|
type_='foreignkey')
|
||||||
|
batch.drop_column('vision_result_id')
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_table('vision_results')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""reference images, with their provenance
|
||||||
|
|
||||||
|
A reference image without provenance is a trap: the graph is re-run months later,
|
||||||
|
different numbers come out, and there is no way to tell an updated engine from a
|
||||||
|
moved lens.
|
||||||
|
|
||||||
|
`expected_json` holds what the graph is supposed to produce on this image. It is
|
||||||
|
what makes a regression run possible after a VisionSuite upgrade - the safety net
|
||||||
|
that makes bumping the submodule sustainable with an engine on two hosts.
|
||||||
|
|
||||||
|
Revision ID: 013_reference_images
|
||||||
|
Revises: 012_vision_results
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '013_reference_images'
|
||||||
|
down_revision: Union[str, None] = '012_vision_results'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_reference_images',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('path', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('note', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('expected_json', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('acquired_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('vision_reference_images')
|
||||||
@@ -12,8 +12,15 @@ class MeasurementCreate(BaseModel):
|
|||||||
value: float
|
value: float
|
||||||
lot_number: Optional[str] = Field(None, max_length=100)
|
lot_number: Optional[str] = Field(None, max_length=100)
|
||||||
serial_number: Optional[str] = Field(None, max_length=100)
|
serial_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
# "camera" is deliberately absent from this pattern, even though the ORM
|
||||||
|
# enum and the database allow it. Do not add it here: a camera
|
||||||
|
# measurement must be produced by vision_service.execute_task, never by a
|
||||||
|
# client posting JSON straight to this endpoint - there is no image, no
|
||||||
|
# graph and no engine_version behind a value that arrived this way.
|
||||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||||
input_duration_ms: Optional[int] = Field(None, ge=0)
|
input_duration_ms: Optional[int] = Field(None, ge=0)
|
||||||
|
# The production this belongs to, when one is open at the station.
|
||||||
|
production_run_id: Optional[int] = Field(None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
class MeasurementBatchCreate(BaseModel):
|
class MeasurementBatchCreate(BaseModel):
|
||||||
@@ -36,6 +43,11 @@ class MeasurementResponse(BaseModel):
|
|||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
input_method: str
|
input_method: str
|
||||||
input_duration_ms: Optional[int] = None
|
input_duration_ms: Optional[int] = None
|
||||||
|
production_run_id: Optional[int] = None
|
||||||
|
# Who let this value stand, when it was out of tolerance. Null on a value in
|
||||||
|
# tolerance - and on one that is not and is still waiting for an answer.
|
||||||
|
supervisor_id: Optional[int] = None
|
||||||
|
authorised_at: Optional[datetime] = None
|
||||||
measured_at: datetime
|
measured_at: datetime
|
||||||
synced_to_csv: bool
|
synced_to_csv: bool
|
||||||
|
|
||||||
@@ -49,6 +61,29 @@ class MeasurementListResponse(BaseModel):
|
|||||||
pages: int
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class MeasurementAuthorisation(BaseModel):
|
||||||
|
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||||
|
|
||||||
|
supervisor_username: str = Field(..., min_length=1)
|
||||||
|
supervisor_password: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskProgressResponse(BaseModel):
|
||||||
|
"""How far one measurement task has got: quotes taken out of quotes expected."""
|
||||||
|
|
||||||
|
task_id: int
|
||||||
|
quotes: int
|
||||||
|
measured: int
|
||||||
|
# empty (declared as measurement, no quotes yet) | none | partial | complete
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskProgressListResponse(BaseModel):
|
||||||
|
"""Progress of every measurement task of a version."""
|
||||||
|
|
||||||
|
tasks: list[TaskProgressResponse]
|
||||||
|
|
||||||
|
|
||||||
class MeasurementQuery(BaseModel):
|
class MeasurementQuery(BaseModel):
|
||||||
"""Schema for measurement query filters."""
|
"""Schema for measurement query filters."""
|
||||||
recipe_id: Optional[int] = None
|
recipe_id: Optional[int] = None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class ProductionEventResponse(BaseModel):
|
|||||||
event_type: str
|
event_type: str
|
||||||
user_id: int
|
user_id: int
|
||||||
supervisor_id: Optional[int]
|
supervisor_id: Optional[int]
|
||||||
|
task_id: Optional[int] = None
|
||||||
note: Optional[str]
|
note: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ class ProductionRunResponse(BaseModel):
|
|||||||
paused_at: Optional[datetime]
|
paused_at: Optional[datetime]
|
||||||
closed_at: Optional[datetime]
|
closed_at: Optional[datetime]
|
||||||
closed_by: Optional[int]
|
closed_by: Optional[int]
|
||||||
|
statistics_path: Optional[str] = None
|
||||||
|
|
||||||
# Derived server-side so every client agrees on the countdown regardless of
|
# 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
|
# clock skew. Negative once the interval has elapsed: how long the run has been
|
||||||
@@ -49,6 +51,11 @@ class ProductionRunResponse(BaseModel):
|
|||||||
overdue: bool = False
|
overdue: bool = False
|
||||||
server_time: Optional[datetime] = None
|
server_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
# The measurement tasks of this run's version, in execution order. The client
|
||||||
|
# needs them to bring the operator back to the measurement when the interval
|
||||||
|
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||||
|
measurement_task_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||||
events: list[ProductionEventResponse] = Field(default_factory=list)
|
events: list[ProductionEventResponse] = Field(default_factory=list)
|
||||||
@@ -63,9 +70,20 @@ class SupervisorAction(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CycleCompletePayload(BaseModel):
|
class CycleCompletePayload(BaseModel):
|
||||||
|
# Which measurement task was just finished. The interval restarts only on the
|
||||||
|
# last one of the recipe; omitting it closes the cycle outright.
|
||||||
|
task_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemeasurePayload(BaseModel):
|
||||||
|
"""The piece was turned over: measure it again inside the same cycle."""
|
||||||
|
|
||||||
|
task_id: Optional[int] = Field(default=None, gt=0)
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
ProductionEventType = Literal[
|
ProductionEventType = Literal[
|
||||||
"start", "cycle_completed", "line_stop", "resume", "close",
|
"start", "cycle_completed", "task_measured", "remeasure",
|
||||||
|
"line_stop", "resume", "close",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class RecipeCreate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
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
|
# Optional task-level fields for the initial technical drawing
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
@@ -27,6 +32,9 @@ class RecipeUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
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
|
change_notes: Optional[str] = None
|
||||||
# Task-level fields: saved to the first task of the new version
|
# Task-level fields: saved to the first task of the new version
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
@@ -58,6 +66,11 @@ class RecipeResponse(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
measurement_interval_minutes: Optional[int] = 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_by: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
active: bool
|
active: bool
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class RecipeSummary(BaseModel):
|
|||||||
active: bool
|
active: bool
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
description: 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):
|
class StationWithRecipesResponse(StationResponse):
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||||
from typing import Any, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from src.backend.models.orm.task import TASK_TYPES
|
||||||
|
|
||||||
|
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||||
|
TaskType = Literal[TASK_TYPES]
|
||||||
|
|
||||||
|
|
||||||
class SubtaskCreate(BaseModel):
|
class SubtaskCreate(BaseModel):
|
||||||
"""Schema for creating a subtask."""
|
"""Schema for creating a subtask."""
|
||||||
@@ -16,6 +21,7 @@ class SubtaskCreate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str = Field("mm", max_length=20)
|
unit: str = Field("mm", max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskUpdate(BaseModel):
|
class SubtaskUpdate(BaseModel):
|
||||||
@@ -30,6 +36,7 @@ class SubtaskUpdate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: Optional[str] = Field(None, max_length=20)
|
unit: Optional[str] = Field(None, max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskResponse(BaseModel):
|
class SubtaskResponse(BaseModel):
|
||||||
@@ -48,27 +55,32 @@ class SubtaskResponse(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str
|
unit: str
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
"""Schema for creating a task."""
|
"""Schema for creating a task."""
|
||||||
title: str = Field(..., min_length=1, max_length=255)
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
task_type: TaskType = "note"
|
||||||
directive: Optional[str] = None
|
directive: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
subtasks: list[SubtaskCreate] = []
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
"""Schema for updating a task."""
|
"""Schema for updating a task."""
|
||||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
task_type: Optional[TaskType] = None
|
||||||
directive: Optional[str] = None
|
directive: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
@@ -79,12 +91,14 @@ class TaskResponse(BaseModel):
|
|||||||
version_id: int
|
version_id: int
|
||||||
recipe_id: Optional[int] = None
|
recipe_id: Optional[int] = None
|
||||||
order_index: int
|
order_index: int
|
||||||
|
task_type: str
|
||||||
title: str
|
title: str
|
||||||
directive: Optional[str] = None
|
directive: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
subtasks: list[SubtaskResponse] = []
|
subtasks: list[SubtaskResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Pydantic schemas for the vision execution response."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class VisionMeasurementResult(BaseModel):
|
||||||
|
"""One quote's outcome, as much of it as the client needs to show."""
|
||||||
|
subtask_id: int
|
||||||
|
value: float
|
||||||
|
pass_fail: str
|
||||||
|
|
||||||
|
|
||||||
|
class VisionExecuteResponse(BaseModel):
|
||||||
|
"""What `POST /api/vision/execute` returns: the run, and every quote it filled."""
|
||||||
|
vision_result_id: int
|
||||||
|
engine_version: str
|
||||||
|
measurements: list[VisionMeasurementResult]
|
||||||
@@ -7,6 +7,7 @@ from src.backend.models.orm.access_log import AccessLog
|
|||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -22,4 +23,6 @@ __all__ = [
|
|||||||
"StationRecipeAssignment",
|
"StationRecipeAssignment",
|
||||||
"ProductionRun",
|
"ProductionRun",
|
||||||
"ProductionEvent",
|
"ProductionEvent",
|
||||||
|
"VisionResult",
|
||||||
|
"VisionReferenceImage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -38,16 +38,43 @@ class Measurement(Base):
|
|||||||
|
|
||||||
# Input method
|
# Input method
|
||||||
input_method: Mapped[str] = mapped_column(
|
input_method: Mapped[str] = mapped_column(
|
||||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="manual",
|
default="manual",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The vision execution that produced this value, when there was one. Null for
|
||||||
|
# everything taken by caliper or typed by hand.
|
||||||
|
vision_result_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("vision_results.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Time spent by the operator entering this value (client-reported)
|
# Time spent by the operator entering this value (client-reported)
|
||||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The production this was taken during, when there was one. Nullable: rows
|
||||||
|
# predating production runs, and measurements taken outside a production, have
|
||||||
|
# none. This is what makes "the measurements of this production" a query, which
|
||||||
|
# fine produzione needs to emit the statistics file.
|
||||||
|
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Who let an out-of-tolerance value stand, and when. Null on a value that is
|
||||||
|
# within tolerance, and null on one that is not and has not been authorised -
|
||||||
|
# which is exactly the state that stops the operator moving on to the next
|
||||||
|
# quote. Before this the supervisor's approval was a modal that closed: nothing
|
||||||
|
# recorded it, and nothing depended on it.
|
||||||
|
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
authorised_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Timestamp
|
# Timestamp
|
||||||
measured_at: Mapped[datetime] = mapped_column(
|
measured_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now(), index=True
|
DateTime, nullable=False, server_default=func.now(), index=True
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
RUN_STATUSES = ("running", "paused", "closed")
|
RUN_STATUSES = ("running", "paused", "closed")
|
||||||
EVENT_TYPES = ("start", "cycle_completed", "line_stop", "resume", "close")
|
# task_measured marks a measurement task finished inside a cycle that is not over
|
||||||
|
# yet; only the last one produces cycle_completed and restarts the interval.
|
||||||
|
# remeasure is the piece being turned and measured again without closing the cycle.
|
||||||
|
EVENT_TYPES = (
|
||||||
|
"start", "cycle_completed", "task_measured", "remeasure",
|
||||||
|
"line_stop", "resume", "close",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProductionRun(Base):
|
class ProductionRun(Base):
|
||||||
@@ -84,6 +90,8 @@ class ProductionRun(Base):
|
|||||||
closed_by: Mapped[Optional[int]] = mapped_column(
|
closed_by: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, ForeignKey("users.id"), nullable=True
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
)
|
)
|
||||||
|
# Statistics file emitted when the run was closed, relative to the upload dir.
|
||||||
|
statistics_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
|
# 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
|
# index on it lets the database - not a check-then-insert race - guarantee that a
|
||||||
@@ -137,6 +145,12 @@ class ProductionEvent(Base):
|
|||||||
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, ForeignKey("users.id"), nullable=True
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
)
|
)
|
||||||
|
# Which task the event is about, for the ones that happen inside a cycle. A
|
||||||
|
# task_measured or a remeasure that does not say which task is not a record of
|
||||||
|
# anything; null for the events that concern the run as a whole.
|
||||||
|
task_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id"), nullable=True
|
||||||
|
)
|
||||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now(), index=True
|
DateTime, nullable=False, server_default=func.now(), index=True
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ class Recipe(Base):
|
|||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=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
|
# Relationships
|
||||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||||
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ if TYPE_CHECKING:
|
|||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
|
||||||
|
|
||||||
|
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
||||||
|
# contents. The last two are not built yet; they are in the enum from the start
|
||||||
|
# because widening a MySQL enum later is an ALTER on a live table.
|
||||||
|
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
||||||
|
|
||||||
|
# Types the operator screen treats as "there are quotes to take here".
|
||||||
|
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||||
|
|
||||||
|
|
||||||
class RecipeTask(Base):
|
class RecipeTask(Base):
|
||||||
__tablename__ = "recipe_tasks"
|
__tablename__ = "recipe_tasks"
|
||||||
|
|
||||||
@@ -20,6 +29,16 @@ class RecipeTask(Base):
|
|||||||
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
||||||
)
|
)
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# Declared, not deduced. The old rule - "has subtasks, therefore a measurement" -
|
||||||
|
# meant a measurement task whose quotes had not been entered yet behaved as a
|
||||||
|
# note, so the system acted differently depending on how finished the recipe was.
|
||||||
|
task_type: Mapped[str] = mapped_column(
|
||||||
|
Enum(*TASK_TYPES, name="task_type_enum"),
|
||||||
|
nullable=False,
|
||||||
|
default="note",
|
||||||
|
server_default="note",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
@@ -28,6 +47,10 @@ class RecipeTask(Base):
|
|||||||
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||||
)
|
)
|
||||||
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# The vision graph, serialised by vs-task. Opaque here on purpose: the
|
||||||
|
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||||
|
# out of this container.
|
||||||
|
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||||
@@ -70,6 +93,12 @@ class RecipeSubtask(Base):
|
|||||||
|
|
||||||
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
|
||||||
|
# A link is an id, not a pointer - the same rule vs-task follows, and for
|
||||||
|
# the same reason: a pointer does not survive being written to disk.
|
||||||
|
vision_output: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(120), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""One row per vision execution - not per quote."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VisionResult(Base):
|
||||||
|
__tablename__ = "vision_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
executed_on: Mapped[str] = mapped_column(
|
||||||
|
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
|
||||||
|
)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
# Copied, not referenced: a calibration redone six months from now must not
|
||||||
|
# rewrite what a measurement already taken means. Same for the graph.
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
executed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionResult task={self.task_id} engine={self.engine_version}>"
|
||||||
|
|
||||||
|
|
||||||
|
class VisionReferenceImage(Base):
|
||||||
|
"""An image kept so a graph can be composed, tried and re-tried without a
|
||||||
|
camera - which is exactly what VisionSuite's source abstraction is for."""
|
||||||
|
|
||||||
|
__tablename__ = "vision_reference_images"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
acquired_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionReferenceImage task={self.task_id} path={self.path}>"
|
||||||
@@ -3,6 +3,7 @@ import secrets
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -39,6 +40,31 @@ async def authenticate_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||||
|
"""Check the credentials of the supervisor authorising something.
|
||||||
|
|
||||||
|
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
||||||
|
out-of-tolerance value allowed to stand. Lives here rather than beside any one
|
||||||
|
of them because it is the same question every time - who is this, and are they
|
||||||
|
allowed to say yes.
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|
||||||
|
|
||||||
async def login_user(db: AsyncSession, user: User) -> str:
|
async def login_user(db: AsyncSession, user: User) -> str:
|
||||||
"""Generate API key and update last_login for user."""
|
"""Generate API key and update last_login for user."""
|
||||||
api_key = generate_api_key()
|
api_key = generate_api_key()
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
"""Measurement service - pass/fail calculation, data storage."""
|
"""Measurement service - pass/fail calculation, data storage."""
|
||||||
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import select
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from src.backend.models.orm.task import RecipeSubtask
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.task import (
|
||||||
|
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
|
||||||
def calculate_pass_fail(
|
def calculate_pass_fail(
|
||||||
@@ -39,6 +45,174 @@ def calculate_pass_fail(
|
|||||||
return "pass", deviation
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scope(query, version_id: int, measured_by: int, production_run_id: int | None):
|
||||||
|
"""Narrow a measurement query to the run in progress, or to this operator's work.
|
||||||
|
|
||||||
|
Inside a production the run is the boundary. Outside one - a recipe executed on
|
||||||
|
its own - the boundary is the version and the person doing it, so two operators
|
||||||
|
on the same recipe do not block each other.
|
||||||
|
"""
|
||||||
|
if production_run_id is not None:
|
||||||
|
return query.where(Measurement.production_run_id == production_run_id)
|
||||||
|
return query.where(
|
||||||
|
Measurement.version_id == version_id,
|
||||||
|
Measurement.measured_by == measured_by,
|
||||||
|
Measurement.production_run_id.is_(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def pending_authorisation(
|
||||||
|
db: AsyncSession,
|
||||||
|
version_id: int,
|
||||||
|
measured_by: int,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
) -> Measurement | None:
|
||||||
|
"""The out-of-tolerance measurement that is holding everything up, if any.
|
||||||
|
|
||||||
|
Only the *latest* reading of each quote counts. Measuring the same quote again
|
||||||
|
is allowed - the caliper slips, the piece is reseated - and a second reading
|
||||||
|
within tolerance releases the block. What is not allowed is moving on to the
|
||||||
|
next quote while the one in hand is out of tolerance and nobody has said so.
|
||||||
|
|
||||||
|
How many attempts that permits is point 4's business, not this one's. Every
|
||||||
|
reading stays in the record either way.
|
||||||
|
"""
|
||||||
|
latest_per_subtask = _scope(
|
||||||
|
select(func.max(Measurement.id)),
|
||||||
|
version_id, measured_by, production_run_id,
|
||||||
|
).group_by(Measurement.subtask_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Measurement)
|
||||||
|
.where(
|
||||||
|
Measurement.id.in_(latest_per_subtask),
|
||||||
|
Measurement.pass_fail == "fail",
|
||||||
|
Measurement.supervisor_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Measurement.id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def task_progress(
|
||||||
|
db: AsyncSession,
|
||||||
|
version_id: int,
|
||||||
|
measured_by: int,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""How far each measurement task of a version has got.
|
||||||
|
|
||||||
|
A task left half done is the thing the operator cannot see today: they walk
|
||||||
|
away from it, come back to the list, and it looks exactly like one never
|
||||||
|
started. Counting quotes against measurements is what tells them apart.
|
||||||
|
|
||||||
|
Documental tasks are not in here. A note cannot be half read, and marking one
|
||||||
|
incomplete for ever would be noise on the only screen that has to stay legible.
|
||||||
|
Which tasks count is read from the declared type, not from whether quotes happen
|
||||||
|
to have been entered yet - the same rule point 2 settled everywhere else.
|
||||||
|
"""
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(RecipeTask.id, RecipeSubtask.id)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id, isouter=True)
|
||||||
|
.where(
|
||||||
|
RecipeTask.version_id == version_id,
|
||||||
|
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
by_task: dict[int, set[int]] = {}
|
||||||
|
for task_id, subtask_id in rows:
|
||||||
|
quotes = by_task.setdefault(task_id, set())
|
||||||
|
if subtask_id is not None:
|
||||||
|
quotes.add(subtask_id)
|
||||||
|
|
||||||
|
measured = set((await db.execute(
|
||||||
|
_scope(
|
||||||
|
select(Measurement.subtask_id).distinct(),
|
||||||
|
version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
)).scalars().all())
|
||||||
|
|
||||||
|
progress = []
|
||||||
|
for task_id, subtask_ids in by_task.items():
|
||||||
|
total = len(subtask_ids)
|
||||||
|
done = len(subtask_ids & measured)
|
||||||
|
if total == 0:
|
||||||
|
# Declared as a measurement but with no quotes on it yet: nothing to
|
||||||
|
# take, so nothing to report as missing either.
|
||||||
|
state = "empty"
|
||||||
|
elif done == total:
|
||||||
|
state = "complete"
|
||||||
|
elif done:
|
||||||
|
state = "partial"
|
||||||
|
else:
|
||||||
|
state = "none"
|
||||||
|
progress.append({
|
||||||
|
"task_id": task_id,
|
||||||
|
"quotes": total,
|
||||||
|
"measured": done,
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
progress.sort(key=lambda row: row["task_id"])
|
||||||
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
async def authorise_measurement(
|
||||||
|
db: AsyncSession, measurement: Measurement, supervisor: User,
|
||||||
|
) -> Measurement:
|
||||||
|
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||||
|
measurement.supervisor_id = supervisor.id
|
||||||
|
measurement.authorised_at = datetime.now()
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(measurement)
|
||||||
|
return measurement
|
||||||
|
|
||||||
|
|
||||||
async def save_measurement(
|
async def save_measurement(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
@@ -49,8 +223,19 @@ async def save_measurement(
|
|||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
input_method: str = "manual",
|
input_method: str = "manual",
|
||||||
input_duration_ms: int | None = None,
|
input_duration_ms: int | None = None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
vision_result_id: int | None = None,
|
||||||
|
enforce_tolerance_gate: bool = True,
|
||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Save a single measurement with auto-calculated pass/fail."""
|
"""Save a single measurement with auto-calculated pass/fail.
|
||||||
|
|
||||||
|
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
|
||||||
|
below - everything else (verdict, recipe rules, deviation) still applies.
|
||||||
|
This is for a caller that has already evaluated the gate itself for a whole
|
||||||
|
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||||
|
the gate models "moving on" between measurements taken one after another,
|
||||||
|
which does not exist inside a single camera acquisition.
|
||||||
|
"""
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||||
@@ -59,6 +244,25 @@ async def save_measurement(
|
|||||||
if subtask is None:
|
if subtask is None:
|
||||||
raise ValueError(f"Subtask {subtask_id} not found")
|
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)
|
||||||
|
|
||||||
|
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
||||||
|
# that same quote again is the way out that does not need the capoturno; the
|
||||||
|
# next quote is not.
|
||||||
|
if enforce_tolerance_gate:
|
||||||
|
blocking = await pending_authorisation(
|
||||||
|
db, version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
if blocking is not None and blocking.subtask_id != subtask_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||||
|
|
||||||
measurement = Measurement(
|
measurement = Measurement(
|
||||||
@@ -72,6 +276,8 @@ async def save_measurement(
|
|||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
input_method=input_method,
|
input_method=input_method,
|
||||||
input_duration_ms=input_duration_ms,
|
input_duration_ms=input_duration_ms,
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=vision_result_id,
|
||||||
)
|
)
|
||||||
db.add(measurement)
|
db.add(measurement)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Statistics file emitted when a production is closed.
|
||||||
|
|
||||||
|
Fine produzione has to hand the measurements of the whole production over. That part
|
||||||
|
does not depend on the ERP: the file is produced now, and the hand-off to GAIA plugs
|
||||||
|
in beside it once the protocol is agreed (questions D-1 and D-2). Keeping it in its
|
||||||
|
own module makes that seam obvious rather than buried in the close path.
|
||||||
|
|
||||||
|
The CSV uses the delimiters configured in system_settings, the same ones the manual
|
||||||
|
export honours, so a shop floor set to the Italian convention (';' and ',') gets
|
||||||
|
files that open correctly in their Excel.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
|
||||||
|
# Written under the upload directory: it is the volume that is already mounted and
|
||||||
|
# backed up, so the files survive a container being replaced.
|
||||||
|
EXPORT_SUBDIR = "statistics"
|
||||||
|
|
||||||
|
HEADER = [
|
||||||
|
"production_run_id",
|
||||||
|
"station_code",
|
||||||
|
"recipe_code",
|
||||||
|
"version_id",
|
||||||
|
"lot_number",
|
||||||
|
"serial_number",
|
||||||
|
"measurement_id",
|
||||||
|
"subtask_id",
|
||||||
|
"marker_number",
|
||||||
|
"subtask_description",
|
||||||
|
"nominal",
|
||||||
|
"value",
|
||||||
|
"deviation",
|
||||||
|
"pass_fail",
|
||||||
|
# The limits travel with the row: without them a pass/fail cannot be re-derived
|
||||||
|
# from the file years later, which is the whole point of an audit record.
|
||||||
|
"ltl",
|
||||||
|
"lwl",
|
||||||
|
"uwl",
|
||||||
|
"utl",
|
||||||
|
"unit",
|
||||||
|
"input_method",
|
||||||
|
"input_duration_ms",
|
||||||
|
"measured_by",
|
||||||
|
"measured_at",
|
||||||
|
# A value outside tolerance that was allowed to stand carries the name of who
|
||||||
|
# allowed it. Without these two columns the file would show the failure and
|
||||||
|
# not the decision, which is the half an auditor asks about.
|
||||||
|
"authorised_by",
|
||||||
|
"authorised_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _csv_format(db: AsyncSession) -> tuple[str, str]:
|
||||||
|
"""Delimiter and decimal separator as configured for this installation."""
|
||||||
|
delimiter_row = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
|
||||||
|
)
|
||||||
|
delimiter = delimiter_row.scalar_one_or_none()
|
||||||
|
decimal_row = await db.execute(
|
||||||
|
select(SystemSetting).where(
|
||||||
|
SystemSetting.setting_key == "csv_decimal_separator"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
decimal = decimal_row.scalar_one_or_none()
|
||||||
|
return (
|
||||||
|
delimiter.setting_value if delimiter else ",",
|
||||||
|
decimal.setting_value if decimal else ".",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||||
|
"""Render every measurement of a run as CSV text."""
|
||||||
|
delimiter, decimal_separator = await _csv_format(db)
|
||||||
|
|
||||||
|
station = (await db.execute(
|
||||||
|
select(Station).where(Station.id == run.station_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
recipe = (await db.execute(
|
||||||
|
select(Recipe).where(Recipe.id == run.recipe_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Measurement, RecipeSubtask)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id, isouter=True)
|
||||||
|
.where(Measurement.production_run_id == run.id)
|
||||||
|
.order_by(Measurement.measured_at, Measurement.id)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
def number(value) -> str:
|
||||||
|
return "" if value is None else str(value).replace(".", decimal_separator)
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, delimiter=delimiter, lineterminator="\n")
|
||||||
|
writer.writerow(HEADER)
|
||||||
|
|
||||||
|
for measurement, subtask in rows:
|
||||||
|
writer.writerow([
|
||||||
|
run.id,
|
||||||
|
station.code if station else "",
|
||||||
|
recipe.code if recipe else "",
|
||||||
|
measurement.version_id,
|
||||||
|
measurement.lot_number or "",
|
||||||
|
measurement.serial_number or "",
|
||||||
|
measurement.id,
|
||||||
|
measurement.subtask_id,
|
||||||
|
subtask.marker_number if subtask else "",
|
||||||
|
subtask.description if subtask else "",
|
||||||
|
number(subtask.nominal) if subtask else "",
|
||||||
|
number(measurement.value),
|
||||||
|
number(measurement.deviation),
|
||||||
|
measurement.pass_fail,
|
||||||
|
number(subtask.ltl) if subtask else "",
|
||||||
|
number(subtask.lwl) if subtask else "",
|
||||||
|
number(subtask.uwl) if subtask else "",
|
||||||
|
number(subtask.utl) if subtask else "",
|
||||||
|
(subtask.unit or "") if subtask else "",
|
||||||
|
measurement.input_method,
|
||||||
|
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||||
|
measurement.measured_by,
|
||||||
|
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||||
|
measurement.supervisor_id or "",
|
||||||
|
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||||
|
])
|
||||||
|
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | None:
|
||||||
|
"""Write the run's statistics file and mark its measurements as exported.
|
||||||
|
|
||||||
|
Returns the path relative to the upload directory, or None when the run has no
|
||||||
|
measurements - an empty file would only be noise in the folder.
|
||||||
|
"""
|
||||||
|
content = await build_statistics_csv(db, run)
|
||||||
|
# Header only: nothing was measured during this production.
|
||||||
|
if len(content.strip().splitlines()) <= 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"production_{run.id}_{stamp}.csv"
|
||||||
|
(directory / filename).write_text(content, encoding="utf-8-sig")
|
||||||
|
|
||||||
|
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
|
||||||
|
# and these files are opened in Excel on the shop floor.
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(Measurement)
|
||||||
|
.where(Measurement.production_run_id == run.id)
|
||||||
|
.values(synced_to_csv=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{EXPORT_SUBDIR}/{filename}"
|
||||||
@@ -24,8 +24,11 @@ from src.backend.models.api.production import ProductionRunCreate
|
|||||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.station import Station
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import auth_service
|
from src.backend.services import (
|
||||||
|
auth_service, measurement_service, production_export_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -103,6 +106,28 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
|||||||
return station
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||||
|
"""Ids of the measurement tasks of a version, in the order they are executed.
|
||||||
|
|
||||||
|
This is the loop the operator lives in once production starts: the client uses
|
||||||
|
the first of these to bring the measurement back on screen when the interval
|
||||||
|
expires, and the last to know which task closes a cycle.
|
||||||
|
|
||||||
|
The type is read from the declared task_type (point 2), not from whether quotes
|
||||||
|
happen to be present: a measurement task with no quotes yet is still where the
|
||||||
|
operator has to be taken.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeTask.id)
|
||||||
|
.where(
|
||||||
|
RecipeTask.version_id == version_id,
|
||||||
|
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||||
|
)
|
||||||
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
|
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProductionEvent)
|
select(ProductionEvent)
|
||||||
@@ -114,27 +139,9 @@ async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Supervisor authorisation
|
# Supervisor authorisation
|
||||||
# ---------------------------------------------------------------------------
|
# The capoturno check lives in auth_service, next to the rest of the credential
|
||||||
|
# handling: a line stop, a closure and an out-of-tolerance value all ask the same
|
||||||
|
# question, and only one of the three is about production.
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -149,6 +156,7 @@ def _add_event(
|
|||||||
user: User,
|
user: User,
|
||||||
supervisor: Optional[User] = None,
|
supervisor: Optional[User] = None,
|
||||||
note: Optional[str] = None,
|
note: Optional[str] = None,
|
||||||
|
task_id: Optional[int] = None,
|
||||||
) -> ProductionEvent:
|
) -> ProductionEvent:
|
||||||
"""Append to the run's trace.
|
"""Append to the run's trace.
|
||||||
|
|
||||||
@@ -161,6 +169,7 @@ def _add_event(
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
supervisor_id=supervisor.id if supervisor else None,
|
supervisor_id=supervisor.id if supervisor else None,
|
||||||
note=note,
|
note=note,
|
||||||
|
task_id=task_id,
|
||||||
created_at=_now(),
|
created_at=_now(),
|
||||||
)
|
)
|
||||||
db.add(event)
|
db.add(event)
|
||||||
@@ -218,6 +227,20 @@ async def open_run(
|
|||||||
detail="Recipe has no current version",
|
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()
|
now = _now()
|
||||||
# The interval is copied, not referenced: editing the recipe mid-production must
|
# The interval is copied, not referenced: editing the recipe mid-production must
|
||||||
# not move the deadline of a run already under way.
|
# not move the deadline of a run already under way.
|
||||||
@@ -254,10 +277,7 @@ def _require_open(run: ProductionRun) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def complete_cycle(
|
def _require_measurable(run: ProductionRun) -> None:
|
||||||
db: AsyncSession, run: ProductionRun, user: User, note: Optional[str] = None,
|
|
||||||
) -> ProductionRun:
|
|
||||||
"""Record a finished measurement cycle and restart the interval."""
|
|
||||||
_require_open(run)
|
_require_open(run)
|
||||||
if run.status == "paused":
|
if run.status == "paused":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -265,6 +285,51 @@ async def complete_cycle(
|
|||||||
detail="Production run is paused: resume it before measuring",
|
detail="Production run is paused: resume it before measuring",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_cycle(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
task_id: Optional[int] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Record a finished measurement task, and restart the interval if it was the last.
|
||||||
|
|
||||||
|
A cycle spans every measurement task of the recipe, so only the last one closes
|
||||||
|
it. Restarting the interval at the first would make a recipe with three
|
||||||
|
measurement tasks measure three times as often as it was set up to.
|
||||||
|
|
||||||
|
An unrecognised task_id - or none at all - closes the cycle: a caller that does
|
||||||
|
not know about the sequence gets the behaviour that existed before it.
|
||||||
|
"""
|
||||||
|
_require_measurable(run)
|
||||||
|
|
||||||
|
# A cycle that contains a quote out of tolerance is not a finished cycle. This
|
||||||
|
# is the same gate as on the next measurement: closing the cycle would be
|
||||||
|
# another way of walking past it.
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, run.version_id, user.id, run.id,
|
||||||
|
)
|
||||||
|
if blocking is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"the cycle cannot be closed until it is authorised"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
measure_ids = await measurement_task_ids(db, run.version_id)
|
||||||
|
closes_cycle = (
|
||||||
|
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not closes_cycle:
|
||||||
|
_add_event(db, run, "task_measured", user, note=note, task_id=task_id)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
run.cycle_count += 1
|
run.cycle_count += 1
|
||||||
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
|
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
|
||||||
@@ -273,7 +338,29 @@ async def complete_cycle(
|
|||||||
run.next_measurement_at = now + timedelta(
|
run.next_measurement_at = now + timedelta(
|
||||||
minutes=run.measurement_interval_minutes
|
minutes=run.measurement_interval_minutes
|
||||||
)
|
)
|
||||||
_add_event(db, run, "cycle_completed", user, note=note)
|
_add_event(db, run, "cycle_completed", user, note=note, task_id=task_id)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def remeasure(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
task_id: Optional[int] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Measure the same task again without closing the cycle.
|
||||||
|
|
||||||
|
This is the piece being turned over. The deadline and the cycle count are left
|
||||||
|
exactly as they are - a second reading of the same part is not a new cycle, and
|
||||||
|
it must not buy the operator another interval's worth of time. The values
|
||||||
|
themselves are ordinary measurements: both readings stay in the statistics,
|
||||||
|
which is the point of taking them.
|
||||||
|
"""
|
||||||
|
_require_measurable(run)
|
||||||
|
_add_event(db, run, "remeasure", user, note=note, task_id=task_id)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(run)
|
await db.refresh(run)
|
||||||
return run
|
return run
|
||||||
@@ -333,7 +420,13 @@ async def close_run(
|
|||||||
supervisor: User,
|
supervisor: User,
|
||||||
note: Optional[str] = None,
|
note: Optional[str] = None,
|
||||||
) -> ProductionRun:
|
) -> ProductionRun:
|
||||||
"""Fine produzione: close the run for good and stop the timer."""
|
"""Fine produzione: close the run for good, stop the timer, emit the statistics.
|
||||||
|
|
||||||
|
Everything here happens without the ERP. Handing the production over to GAIA
|
||||||
|
goes at the marked seam below, once D-1 and D-2 are answered; until then the
|
||||||
|
file is produced and the run is properly closed, which is what the shop floor
|
||||||
|
needs either way.
|
||||||
|
"""
|
||||||
_require_open(run)
|
_require_open(run)
|
||||||
run.status = "closed"
|
run.status = "closed"
|
||||||
run.closed_at = _now()
|
run.closed_at = _now()
|
||||||
@@ -344,6 +437,15 @@ async def close_run(
|
|||||||
# column is what keeps exactly one open at a time.
|
# column is what keeps exactly one open at a time.
|
||||||
run.active_station_id = None
|
run.active_station_id = None
|
||||||
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
|
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Statistics file for the whole production, and the measurements marked as sent.
|
||||||
|
run.statistics_path = await production_export_service.export_run_statistics(db, run)
|
||||||
|
|
||||||
|
# --- ERP hand-off (GAIA) plugs in here once the protocol is defined (D-1, D-2).
|
||||||
|
# Deliberately absent rather than stubbed: an empty call that looks wired is
|
||||||
|
# worse than none at all.
|
||||||
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(run)
|
await db.refresh(run)
|
||||||
return run
|
return run
|
||||||
|
|||||||
@@ -19,6 +19,30 @@ from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
|||||||
# Helpers
|
# 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:
|
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||||
"""Return a recipe or raise 404."""
|
"""Return a recipe or raise 404."""
|
||||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
@@ -55,12 +79,20 @@ async def _copy_tasks_to_version(
|
|||||||
new_task = RecipeTask(
|
new_task = RecipeTask(
|
||||||
version_id=target_version.id,
|
version_id=target_version.id,
|
||||||
order_index=task.order_index,
|
order_index=task.order_index,
|
||||||
|
# Carried over explicitly: a copy-on-write version that dropped the type
|
||||||
|
# would silently reclassify every task the moment a recipe is edited.
|
||||||
|
task_type=task.task_type,
|
||||||
title=task.title,
|
title=task.title,
|
||||||
directive=task.directive,
|
directive=task.directive,
|
||||||
description=task.description,
|
description=task.description,
|
||||||
file_path=task.file_path,
|
file_path=task.file_path,
|
||||||
file_type=task.file_type,
|
file_type=task.file_type,
|
||||||
annotations_json=task.annotations_json,
|
annotations_json=task.annotations_json,
|
||||||
|
# Carried over explicitly, same reasoning as task_type above: a
|
||||||
|
# copy-on-write version that dropped the camera graph would leave
|
||||||
|
# a camera_measure task unable to execute from the moment a
|
||||||
|
# recipe with measurements is next edited.
|
||||||
|
vision_json=task.vision_json,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush() # get new_task.id
|
await db.flush() # get new_task.id
|
||||||
@@ -78,6 +110,7 @@ async def _copy_tasks_to_version(
|
|||||||
ltl=sub.ltl,
|
ltl=sub.ltl,
|
||||||
unit=sub.unit,
|
unit=sub.unit,
|
||||||
image_path=sub.image_path,
|
image_path=sub.image_path,
|
||||||
|
vision_output=sub.vision_output,
|
||||||
)
|
)
|
||||||
db.add(new_sub)
|
db.add(new_sub)
|
||||||
|
|
||||||
@@ -135,6 +168,9 @@ async def create_recipe(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
measurement_interval_minutes=data.measurement_interval_minutes,
|
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,
|
created_by=user.id,
|
||||||
)
|
)
|
||||||
db.add(recipe)
|
db.add(recipe)
|
||||||
@@ -254,6 +290,7 @@ async def create_new_version(
|
|||||||
default_task = RecipeTask(
|
default_task = RecipeTask(
|
||||||
version_id=new_version.id,
|
version_id=new_version.id,
|
||||||
order_index=0,
|
order_index=0,
|
||||||
|
task_type="drawing",
|
||||||
title="Technical Drawing",
|
title="Technical Drawing",
|
||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type or (
|
file_type=data.file_type or (
|
||||||
@@ -267,15 +304,7 @@ async def create_new_version(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Apply header updates
|
# Apply header updates
|
||||||
update_fields: dict = {}
|
update_fields = _header_fields(data)
|
||||||
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
|
|
||||||
if update_fields:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
@@ -364,16 +393,7 @@ async def update_current_version(
|
|||||||
data: RecipeUpdate,
|
data: RecipeUpdate,
|
||||||
) -> RecipeVersion:
|
) -> RecipeVersion:
|
||||||
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||||
# Apply header updates (name, description, image_path)
|
update_fields = _header_fields(data)
|
||||||
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
|
|
||||||
if update_fields:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Route the image to the worker, map the outputs onto the quotes, save.
|
||||||
|
|
||||||
|
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
|
||||||
|
to the worker over HTTP and stays ignorant of how a number was produced.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
"""The one place that talks to the worker; the tests replace it.
|
||||||
|
|
||||||
|
The worker's fault must not become the API's fault (spec's edge-case
|
||||||
|
table: "il worker isola il guasto, l'API resta in piedi, il task
|
||||||
|
riporta l'errore"). A stopped worker raises `httpx.ConnectError`; a job
|
||||||
|
past the timeout raises `httpx.ReadTimeout` - both would otherwise
|
||||||
|
propagate past this function and surface to the operator as a naked
|
||||||
|
500. The response shape is validated here too: a proxy answering 200
|
||||||
|
with something that is not the worker's JSON contract must not turn
|
||||||
|
into a bare KeyError further down.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.vision_worker_url}/run",
|
||||||
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
|
data={"graph": json.dumps(graph)},
|
||||||
|
)
|
||||||
|
except httpx.ConnectError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc}",
|
||||||
|
) from exc
|
||||||
|
except httpx.ReadTimeout as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||||
|
)
|
||||||
|
missing = {"outputs", "failures", "engine_version"} - report.keys()
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=(
|
||||||
|
"the vision worker's answer was malformed: missing "
|
||||||
|
f"{', '.join(sorted(missing))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
task: RecipeTask,
|
||||||
|
image_bytes: bytes,
|
||||||
|
measured_by: int,
|
||||||
|
*,
|
||||||
|
version_id: int,
|
||||||
|
lot_number: str | None = None,
|
||||||
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
) -> tuple[VisionResult, list[Measurement]]:
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordered by marker_number - the number the operator sees on the annotated
|
||||||
|
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
|
||||||
|
# all key off it). Which quote is saved first must never be an accident of
|
||||||
|
# how SQLite/MySQL happens to return rows.
|
||||||
|
quotes = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.order_by(RecipeSubtask.marker_number)
|
||||||
|
)).scalars().all()
|
||||||
|
if not quotes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="no quote on this task names a vision output",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per
|
||||||
|
# quote. `save_measurement`'s own gate models "moving on" between
|
||||||
|
# measurements taken one after another - a real later moment where the
|
||||||
|
# operator could leave a bad reading behind. A camera acquisition produces
|
||||||
|
# every quote of this task at the same instant: there is no "moving on"
|
||||||
|
# between them, so applying the sequential rule inside the batch would
|
||||||
|
# block on a fail this very request just saved. Check it once here,
|
||||||
|
# against the whole set this acquisition is about to produce, and pass
|
||||||
|
# enforce_tolerance_gate=False to every save_measurement call below.
|
||||||
|
#
|
||||||
|
# Checked before the worker runs (M3): a blocked operator finds out
|
||||||
|
# without burning a whole vision execution first - the worker is the
|
||||||
|
# expensive part, the gate check is not.
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
quote_ids = {q.id for q in quotes}
|
||||||
|
if blocking is not None and blocking.subtask_id not in quote_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = await _call_worker(image_bytes, task.vision_json)
|
||||||
|
outputs = report["outputs"]
|
||||||
|
|
||||||
|
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||||
|
# than a piece not measured at all.
|
||||||
|
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
|
||||||
|
if missing:
|
||||||
|
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(
|
||||||
|
f"the graph did not produce {', '.join(missing)}"
|
||||||
|
+ (f" (tools that failed: {failed})" if failed else "")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = VisionResult(
|
||||||
|
task_id=task.id,
|
||||||
|
engine_version=report["engine_version"],
|
||||||
|
executed_on="server",
|
||||||
|
graph_snapshot=task.vision_json,
|
||||||
|
duration_ms=report.get("duration_ms"),
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
saved: list[Measurement] = []
|
||||||
|
for quote in quotes:
|
||||||
|
saved.append(await measurement_service.save_measurement(
|
||||||
|
db,
|
||||||
|
subtask_id=quote.id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=measured_by,
|
||||||
|
value=float(outputs[quote.vision_output]),
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
input_method="camera",
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=result.id,
|
||||||
|
enforce_tolerance_gate=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result, saved
|
||||||
|
|
||||||
|
|
||||||
|
async def save_reference_image(
|
||||||
|
db: AsyncSession,
|
||||||
|
task_id: int,
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
note: str | None = None,
|
||||||
|
station_id: int | None = None,
|
||||||
|
device_code: str | None = None,
|
||||||
|
calibration_snapshot: dict | None = None,
|
||||||
|
extension: str = "png",
|
||||||
|
) -> VisionReferenceImage:
|
||||||
|
"""Keep an image so the graph can be composed and re-tried without a camera.
|
||||||
|
|
||||||
|
`extension` must match the upload's real content type (the router derives
|
||||||
|
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
|
||||||
|
would be a lie the filename tells forever.
|
||||||
|
"""
|
||||||
|
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
|
||||||
|
# rest of the codebase uses the former (see production_export_service).
|
||||||
|
folder = settings.upload_path / "vision" / "reference" / str(task_id)
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f"{uuid4().hex}.{extension}"
|
||||||
|
(folder / name).write_bytes(image_bytes)
|
||||||
|
|
||||||
|
reference = VisionReferenceImage(
|
||||||
|
task_id=task_id,
|
||||||
|
path=str(Path("vision") / "reference" / str(task_id) / name),
|
||||||
|
note=note,
|
||||||
|
station_id=station_id,
|
||||||
|
device_code=device_code,
|
||||||
|
calibration_snapshot=calibration_snapshot,
|
||||||
|
)
|
||||||
|
db.add(reference)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(reference)
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
|
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
|
||||||
|
"""Run the graph and save nothing. Composing is not measuring."""
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
return await _call_worker(image_bytes, task.vision_json)
|
||||||
@@ -224,6 +224,10 @@ async def create_test_recipe(
|
|||||||
code=code,
|
code=code,
|
||||||
name=name,
|
name=name,
|
||||||
description="A recipe for testing",
|
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,
|
created_by=user_id,
|
||||||
)
|
)
|
||||||
session.add(recipe)
|
session.add(recipe)
|
||||||
@@ -243,6 +247,10 @@ async def create_test_recipe(
|
|||||||
version_id=version.id,
|
version_id=version.id,
|
||||||
order_index=0,
|
order_index=0,
|
||||||
title="Test Task",
|
title="Test Task",
|
||||||
|
# Declared, like any task since the type stopped being deduced: this one has
|
||||||
|
# a quote under it, so a fixture that called it a note would be modelling
|
||||||
|
# exactly the inconsistency point 2 removed.
|
||||||
|
task_type="measure",
|
||||||
directive="Measure the part",
|
directive="Measure the part",
|
||||||
description="First measurement task",
|
description="First measurement task",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""Point 5: a quote out of tolerance stops the line until someone says otherwise.
|
||||||
|
|
||||||
|
The gate existed on screen and nowhere else - a modal that opened, checked a
|
||||||
|
password and closed, while the measurement had already been saved. Dismissing it
|
||||||
|
was enough to carry on. These tests are about the part that cannot be dismissed.
|
||||||
|
"""
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.services import auth_service
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
GOOD = 10.0 # inside every limit of the fixture quote
|
||||||
|
OUT = 99.0 # far outside
|
||||||
|
|
||||||
|
|
||||||
|
async def _supervisor(db_session, username="capo-tol", 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 _two_quotes(db_session, recipe_id: int):
|
||||||
|
"""The fixture recipe plus a second quote, so there is a 'next' one."""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
first = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
|
||||||
|
second = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=2, description="Seconda quota",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db_session.add(second)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(second)
|
||||||
|
return version.id, first, second
|
||||||
|
|
||||||
|
|
||||||
|
async def _measure(client, user, subtask_id, version_id, value, run_id=None):
|
||||||
|
body = {"subtask_id": subtask_id, "version_id": version_id, "value": value}
|
||||||
|
if run_id is not None:
|
||||||
|
body["production_run_id"] = run_id
|
||||||
|
return await client.post(
|
||||||
|
"/api/measurements/", headers=auth_headers(user), json=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pending(client, user, version_id, run_id=None):
|
||||||
|
params = {"version_id": version_id}
|
||||||
|
if run_id is not None:
|
||||||
|
params["production_run_id"] = run_id
|
||||||
|
return await client.get(
|
||||||
|
"/api/measurements/pending-authorisation",
|
||||||
|
headers=auth_headers(user), params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The block
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_next_quote_is_refused_while_one_is_out_of_tolerance(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NEXT")
|
||||||
|
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
bad = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||||
|
assert bad.status_code == 200
|
||||||
|
assert bad.json()["pass_fail"] == "fail"
|
||||||
|
|
||||||
|
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||||
|
assert refused.status_code == 409
|
||||||
|
assert "supervisor" in refused.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_same_quote_can_be_measured_again(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
"""The caliper slips, the piece is reseated. Trying again is not going on."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AGAIN")
|
||||||
|
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||||
|
again = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||||
|
assert again.status_code == 200, "rimisurare la stessa quota deve restare possibile"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_good_reading_of_the_same_quote_releases_the_block(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
"""Only the latest reading of a quote counts: it is in tolerance now."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-FIXED")
|
||||||
|
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version_id, GOOD)
|
||||||
|
|
||||||
|
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||||
|
assert ahead.status_code == 200
|
||||||
|
|
||||||
|
# Both readings stay on record - how many are allowed is point 4's business.
|
||||||
|
rows = (await db_session.execute(
|
||||||
|
select(Measurement).where(Measurement.subtask_id == first.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_warning_does_not_block(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
"""Outside the warning limits but inside tolerance is still inside tolerance."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-WARN")
|
||||||
|
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
warned = await _measure(client, measurement_tec_user, first.id, version_id, 10.4)
|
||||||
|
assert warned.json()["pass_fail"] == "warning"
|
||||||
|
|
||||||
|
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||||
|
assert ahead.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pending_authorisation_names_the_blocking_quote(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
"""What the screen asks on load, so a reload is not a way past the gate."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-PEND")
|
||||||
|
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
assert (await _pending(client, measurement_tec_user, version_id)).json() is None
|
||||||
|
|
||||||
|
bad = (await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, OUT,
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
pending = (await _pending(client, measurement_tec_user, version_id)).json()
|
||||||
|
assert pending["id"] == bad["id"]
|
||||||
|
assert pending["subtask_id"] == first.id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The authorisation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supervisor_authorisation_is_recorded_and_releases_the_block(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AUTH")
|
||||||
|
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||||
|
supervisor, password = await _supervisor(db_session)
|
||||||
|
|
||||||
|
bad = (await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, OUT,
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
authorised = await client.post(
|
||||||
|
f"/api/measurements/{bad['id']}/authorise",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert authorised.status_code == 200, authorised.text
|
||||||
|
body = authorised.json()
|
||||||
|
# Who said yes is on the measurement itself, which is the whole point.
|
||||||
|
assert body["supervisor_id"] == supervisor.id
|
||||||
|
assert body["authorised_at"] is not None
|
||||||
|
|
||||||
|
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||||
|
assert ahead.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authorisation_refuses_someone_who_is_not_a_supervisor(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NOSUP")
|
||||||
|
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
bad = (await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, OUT,
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/measurements/{bad['id']}/authorise",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": maker_user.username,
|
||||||
|
"supervisor_password": "testpassword123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
# And the block is still there.
|
||||||
|
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||||
|
assert refused.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authorisation_refuses_wrong_credentials(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-BADPW")
|
||||||
|
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||||
|
supervisor, _password = await _supervisor(db_session, username="capo-badpw")
|
||||||
|
|
||||||
|
bad = (await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, OUT,
|
||||||
|
)).json()
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/measurements/{bad['id']}/authorise",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": "sbagliata",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_there_is_nothing_to_authorise_on_a_good_measurement(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-GOOD")
|
||||||
|
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-good")
|
||||||
|
|
||||||
|
good = (await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, GOOD,
|
||||||
|
)).json()
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/measurements/{good['id']}/authorise",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The production run
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cycle_cannot_close_over_an_unauthorised_fail(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""Closing the cycle would be another way of walking past the quote."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CYC")
|
||||||
|
recipe.measurement_interval_minutes = 10
|
||||||
|
station = Station(
|
||||||
|
code="ST-TOL", name="Stazione", active=True, created_by=admin_user.id,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
refused = await client.post(
|
||||||
|
f"/api/production-runs/{run['id']}/cycle",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert refused.status_code == 409
|
||||||
|
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-cyc")
|
||||||
|
blocking = (await _pending(
|
||||||
|
client, measurement_tec_user, version_id, run_id=run["id"],
|
||||||
|
)).json()
|
||||||
|
await client.post(
|
||||||
|
f"/api/measurements/{blocking['id']}/authorise",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed = await client.post(
|
||||||
|
f"/api/production-runs/{run['id']}/cycle",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert allowed.status_code == 200
|
||||||
|
assert allowed.json()["cycle_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_statistics_file_carries_the_decision(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||||
|
tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
"""The file has to show the failure and who let it stand, not just the failure."""
|
||||||
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CSV")
|
||||||
|
station = Station(
|
||||||
|
code="ST-CSV", name="Stazione", active=True, created_by=admin_user.id,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-csv")
|
||||||
|
creds = {
|
||||||
|
"supervisor_username": supervisor.username, "supervisor_password": password,
|
||||||
|
}
|
||||||
|
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
bad = (await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
|
||||||
|
)).json()
|
||||||
|
await client.post(
|
||||||
|
f"/api/measurements/{bad['id']}/authorise",
|
||||||
|
headers=auth_headers(measurement_tec_user), json=creds,
|
||||||
|
)
|
||||||
|
|
||||||
|
closed = (await client.post(
|
||||||
|
f"/api/production-runs/{run['id']}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user), json=creds,
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
text = (tmp_path / closed["statistics_path"]).read_text(encoding="utf-8-sig")
|
||||||
|
header, row = [line for line in text.splitlines() if line.strip()]
|
||||||
|
assert "authorised_by" in header and "authorised_at" in header
|
||||||
|
assert str(supervisor.id) in row
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_fail_from_another_production_does_not_block_this_one(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||||
|
tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
"""The run is the boundary: an old production must not stop a new one."""
|
||||||
|
# Closing a run writes the statistics file; pointed at tmp_path so the test
|
||||||
|
# does not leave one in the repository's uploads directory.
|
||||||
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-SCOPE")
|
||||||
|
station = Station(
|
||||||
|
code="ST-SCOPE", name="Stazione", active=True, created_by=admin_user.id,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
first_run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
await _measure(
|
||||||
|
client, measurement_tec_user, first.id, version_id, OUT,
|
||||||
|
run_id=first_run["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-scope")
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{first_run['id']}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"supervisor_username": supervisor.username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
second_run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
resp = await _measure(
|
||||||
|
client, measurement_tec_user, second.id, version_id, GOOD,
|
||||||
|
run_id=second_run["id"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
@@ -13,6 +13,7 @@ from sqlalchemy import select
|
|||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.recipe import Recipe
|
from src.backend.models.orm.recipe import Recipe
|
||||||
from src.backend.models.orm.station import Station
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
from src.backend.services import auth_service, production_service
|
from src.backend.services import auth_service, production_service
|
||||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
@@ -63,6 +64,29 @@ async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None:
|
|||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _only_task(db_session, version_id: int):
|
||||||
|
"""The single task the fixture recipe comes with - a measurement one."""
|
||||||
|
row = await db_session.execute(
|
||||||
|
select(RecipeTask)
|
||||||
|
.where(RecipeTask.version_id == version_id)
|
||||||
|
.order_by(RecipeTask.order_index)
|
||||||
|
)
|
||||||
|
return row.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_task(
|
||||||
|
db_session, version_id: int, order_index: int, title: str, task_type: str = "measure",
|
||||||
|
) -> RecipeTask:
|
||||||
|
"""A further task in the sequence, of a declared type."""
|
||||||
|
task = RecipeTask(
|
||||||
|
version_id=version_id, order_index=order_index, title=title, task_type=task_type,
|
||||||
|
)
|
||||||
|
db_session.add(task)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
async def _open(client, user, station, recipe, **extra):
|
async def _open(client, user, station, recipe, **extra):
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/production-runs",
|
"/api/production-runs",
|
||||||
@@ -209,6 +233,138 @@ async def test_cycle_restarts_the_interval_and_counts(
|
|||||||
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
|
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
|
||||||
|
|
||||||
|
|
||||||
|
async def test_measurement_tasks_travel_with_the_run(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""Where the measurement is cannot be guessed by the client: the run says so.
|
||||||
|
|
||||||
|
This is what lets a screen bring the operator back to the measurement when the
|
||||||
|
interval expires, whatever page they wandered off to.
|
||||||
|
"""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-SEQ")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SEQ")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
first = await _only_task(db_session, opened["version_id"])
|
||||||
|
# A drawing between the two measurements, to prove it is left out.
|
||||||
|
await _add_task(db_session, opened["version_id"], 1, "Disegno", "drawing")
|
||||||
|
second = await _add_task(db_session, opened["version_id"], 2, "Seconda misura")
|
||||||
|
|
||||||
|
body = (await client.get(
|
||||||
|
"/api/production-runs/current",
|
||||||
|
params={"station_code": station.code},
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)).json()
|
||||||
|
assert body["measurement_task_ids"] == [first.id, second.id]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cycle_closes_only_on_the_last_measurement_task(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""Restarting at the first would measure three times as often as configured."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-MULTI")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-MULTI")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
run_id = opened["id"]
|
||||||
|
first = await _only_task(db_session, opened["version_id"])
|
||||||
|
second = await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
|
||||||
|
headers = auth_headers(measurement_tec_user)
|
||||||
|
|
||||||
|
# Four minutes of the interval have gone by when the first task is finished.
|
||||||
|
await _advance_clock(db_session, run_id, timedelta(minutes=4))
|
||||||
|
|
||||||
|
mid = (await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": first.id},
|
||||||
|
)).json()
|
||||||
|
assert mid["cycle_count"] == 0, "il ciclo non e' ancora finito"
|
||||||
|
assert 5 * 60 <= mid["seconds_to_next_measurement"] <= 6 * 60, (
|
||||||
|
"l'intervallo non deve ripartire a meta' ciclo"
|
||||||
|
)
|
||||||
|
|
||||||
|
end = (await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": second.id},
|
||||||
|
)).json()
|
||||||
|
assert end["cycle_count"] == 1
|
||||||
|
assert 9 * 60 <= end["seconds_to_next_measurement"] <= 10 * 60
|
||||||
|
|
||||||
|
events = (await client.get(
|
||||||
|
f"/api/production-runs/{run_id}", headers=headers,
|
||||||
|
)).json()["events"]
|
||||||
|
by_type = {e["event_type"]: e for e in events}
|
||||||
|
assert by_type["task_measured"]["task_id"] == first.id
|
||||||
|
assert by_type["cycle_completed"]["task_id"] == second.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cycle_without_a_task_closes_as_before(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""A caller that knows nothing of the sequence keeps the old behaviour."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-PLAIN")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PLAIN")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
|
||||||
|
|
||||||
|
body = (await client.post(
|
||||||
|
f"/api/production-runs/{opened['id']}/cycle",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)).json()
|
||||||
|
assert body["cycle_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rimisura - turning the piece over
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remeasure_does_not_buy_another_interval(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""A second reading of the same part is not a new cycle."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-AGAIN")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGAIN")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
run_id = opened["id"]
|
||||||
|
task = await _only_task(db_session, opened["version_id"])
|
||||||
|
headers = auth_headers(measurement_tec_user)
|
||||||
|
|
||||||
|
await _advance_clock(db_session, run_id, timedelta(minutes=7))
|
||||||
|
|
||||||
|
body = (await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/remeasure", headers=headers,
|
||||||
|
json={"task_id": task.id},
|
||||||
|
)).json()
|
||||||
|
assert body["cycle_count"] == 0
|
||||||
|
assert 2 * 60 <= body["seconds_to_next_measurement"] <= 3 * 60, (
|
||||||
|
"rigirare il pezzo non deve spostare la scadenza"
|
||||||
|
)
|
||||||
|
|
||||||
|
events = (await client.get(
|
||||||
|
f"/api/production-runs/{run_id}", headers=headers,
|
||||||
|
)).json()["events"]
|
||||||
|
remeasured = [e for e in events if e["event_type"] == "remeasure"]
|
||||||
|
assert len(remeasured) == 1
|
||||||
|
assert remeasured[0]["task_id"] == task.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remeasure_refused_while_the_line_is_stopped(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-AGPAUSE")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGPAUSE")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-again")
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/remeasure",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
async def test_overdue_run_reports_negative_seconds(
|
async def test_overdue_run_reports_negative_seconds(
|
||||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
):
|
):
|
||||||
@@ -441,3 +597,151 @@ async def test_traceability_travels_with_the_run(
|
|||||||
)).json()
|
)).json()
|
||||||
assert body["lot_number"] == "LOT-42"
|
assert body["lot_number"] == "LOT-42"
|
||||||
assert body["serial_number"] == "SN-7"
|
assert body["serial_number"] == "SN-7"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fine produzione: the statistics file (point 6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _measure(client, user, run_id, subtask_id, version_id, value):
|
||||||
|
return await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": value,
|
||||||
|
"lot_number": "LOT-STAT",
|
||||||
|
"production_run_id": run_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _first_subtask(db_session, recipe_id: int):
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
|
||||||
|
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 test_measurement_is_tied_to_the_run(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""Without this link 'the measurements of this production' is not a query."""
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-LINK")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LINK")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
subtask = await _first_subtask(db_session, recipe.id)
|
||||||
|
|
||||||
|
resp = await _measure(
|
||||||
|
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["production_run_id"] == opened["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_emits_the_statistics_file(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.services import production_export_service
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-STAT")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-STAT")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
subtask = await _first_subtask(db_session, recipe.id)
|
||||||
|
await _measure(
|
||||||
|
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
|
||||||
|
)
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-stat")
|
||||||
|
|
||||||
|
closed = (await client.post(
|
||||||
|
f"/api/production-runs/{opened['id']}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
assert closed["statistics_path"], "la chiusura deve produrre il file di statistica"
|
||||||
|
written = tmp_path / closed["statistics_path"]
|
||||||
|
assert written.exists()
|
||||||
|
|
||||||
|
text = written.read_text(encoding="utf-8-sig")
|
||||||
|
lines = [line for line in text.splitlines() if line.strip()]
|
||||||
|
assert len(lines) == 2, "intestazione piu' una misura"
|
||||||
|
assert "production_run_id" in lines[0]
|
||||||
|
# The tolerance limits travel with the row: a pass/fail must stay re-derivable.
|
||||||
|
for column in ("ltl", "lwl", "uwl", "utl", "nominal"):
|
||||||
|
assert column in lines[0]
|
||||||
|
assert "LOT-STAT" in lines[1]
|
||||||
|
assert str(opened["id"]) in lines[1]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_marks_measurements_as_exported(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-SYNC")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SYNC")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
subtask = await _first_subtask(db_session, recipe.id)
|
||||||
|
created = (await _measure(
|
||||||
|
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
|
||||||
|
)).json()
|
||||||
|
assert created["synced_to_csv"] is False
|
||||||
|
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-sync")
|
||||||
|
await client.post(
|
||||||
|
f"/api/production-runs/{opened['id']}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = (await db_session.execute(
|
||||||
|
select(Measurement).where(Measurement.id == created["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.synced_to_csv is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_without_measurements_writes_no_file(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""An empty file would be noise in the folder, not evidence."""
|
||||||
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-EMPTY")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-EMPTY")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
supervisor, password = await _supervisor(db_session, username="capo-empty")
|
||||||
|
|
||||||
|
closed = (await client.post(
|
||||||
|
f"/api/production-runs/{opened['id']}/close",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||||
|
)).json()
|
||||||
|
assert closed["statistics_path"] is None
|
||||||
|
assert not (tmp_path / "statistics").exists()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Point 10: a task left half done has to look different from one never opened.
|
||||||
|
|
||||||
|
The task list showed both the same way, so an operator coming back to a recipe
|
||||||
|
had to remember where they had got to - or measure something twice. These tests
|
||||||
|
are about the count the list now puts on each card.
|
||||||
|
"""
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.recipe import 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 _version_and_task(db_session, recipe_id: int):
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_quote(db_session, task_id: int, marker: int):
|
||||||
|
quote = RecipeSubtask(
|
||||||
|
task_id=task_id, marker_number=marker, description=f"Quota {marker}",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db_session.add(quote)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(quote)
|
||||||
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
async def _measure(client, user, subtask_id, version_id, value=10.0):
|
||||||
|
return await client.post(
|
||||||
|
"/api/measurements/", headers=auth_headers(user),
|
||||||
|
json={"subtask_id": subtask_id, "version_id": version_id, "value": value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _progress(client, user, version_id):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/task-progress",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
params={"version_id": version_id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return {row["task_id"]: row for row in resp.json()["tasks"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_untouched_task_reads_as_none(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task = await _version_and_task(db_session, recipe.id)
|
||||||
|
|
||||||
|
rows = await _progress(client, measurement_tec_user, version.id)
|
||||||
|
assert rows[task.id]["state"] == "none"
|
||||||
|
assert rows[task.id]["measured"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_half_measured_task_reads_as_partial(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task = await _version_and_task(db_session, recipe.id)
|
||||||
|
first = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
await _add_quote(db_session, task.id, 2)
|
||||||
|
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version.id)
|
||||||
|
|
||||||
|
rows = await _progress(client, measurement_tec_user, version.id)
|
||||||
|
assert rows[task.id] == {
|
||||||
|
"task_id": task.id, "quotes": 2, "measured": 1, "state": "partial",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_quote_taken_reads_as_complete(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task = await _version_and_task(db_session, recipe.id)
|
||||||
|
first = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
second = await _add_quote(db_session, task.id, 2)
|
||||||
|
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version.id)
|
||||||
|
await _measure(client, measurement_tec_user, second.id, version.id)
|
||||||
|
|
||||||
|
rows = await _progress(client, measurement_tec_user, version.id)
|
||||||
|
assert rows[task.id]["state"] == "complete"
|
||||||
|
assert rows[task.id]["measured"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_measuring_the_same_quote_twice_does_not_inflate_the_count(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
"""Two readings of one quote are one quote done, not two.
|
||||||
|
|
||||||
|
Re-measuring is allowed - it is how an operator recovers from a caliper that
|
||||||
|
slipped - and counting attempts would show a task of two quotes as finished
|
||||||
|
after one of them had been measured twice.
|
||||||
|
"""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task = await _version_and_task(db_session, recipe.id)
|
||||||
|
first = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
await _add_quote(db_session, task.id, 2)
|
||||||
|
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version.id, 10.0)
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version.id, 10.1)
|
||||||
|
|
||||||
|
rows = await _progress(client, measurement_tec_user, version.id)
|
||||||
|
assert rows[task.id]["measured"] == 1
|
||||||
|
assert rows[task.id]["state"] == "partial"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_documental_tasks_are_left_out(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
"""A note cannot be half read; carrying it as unfinished for ever is noise."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task = await _version_and_task(db_session, recipe.id)
|
||||||
|
note = RecipeTask(
|
||||||
|
version_id=version.id, order_index=5, title="Leggere prima",
|
||||||
|
task_type="note",
|
||||||
|
)
|
||||||
|
db_session.add(note)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(note)
|
||||||
|
|
||||||
|
rows = await _progress(client, measurement_tec_user, version.id)
|
||||||
|
assert note.id not in rows
|
||||||
|
assert task.id in rows
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_measurement_task_without_quotes_is_not_reported_as_missing(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user,
|
||||||
|
):
|
||||||
|
"""Declared as a measurement, no quotes entered yet: nothing to take."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, _task = await _version_and_task(db_session, recipe.id)
|
||||||
|
empty = RecipeTask(
|
||||||
|
version_id=version.id, order_index=6, title="Da compilare",
|
||||||
|
task_type="measure",
|
||||||
|
)
|
||||||
|
db_session.add(empty)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(empty)
|
||||||
|
|
||||||
|
rows = await _progress(client, measurement_tec_user, version.id)
|
||||||
|
assert rows[empty.id]["state"] == "empty"
|
||||||
|
assert rows[empty.id]["quotes"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_another_operators_work_is_not_counted_as_yours(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user, admin_user,
|
||||||
|
):
|
||||||
|
"""Outside a production the boundary is the operator, as it is everywhere else."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task = await _version_and_task(db_session, recipe.id)
|
||||||
|
first = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
|
||||||
|
await _measure(client, admin_user, first.id, version.id)
|
||||||
|
|
||||||
|
rows = await _progress(client, measurement_tec_user, version.id)
|
||||||
|
assert rows[task.id]["state"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inside_a_production_the_run_is_the_boundary(
|
||||||
|
client: AsyncClient, db_session, measurement_tec_user, admin_user,
|
||||||
|
):
|
||||||
|
"""A quote measured before the production opened is not part of it.
|
||||||
|
|
||||||
|
The list is read during the run, and carrying yesterday's readings into it
|
||||||
|
would show a task as finished that this production has not touched.
|
||||||
|
"""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task = await _version_and_task(db_session, recipe.id)
|
||||||
|
first = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
|
||||||
|
# Measured outside any production.
|
||||||
|
await _measure(client, measurement_tec_user, first.id, version.id)
|
||||||
|
|
||||||
|
station = Station(
|
||||||
|
code="ST-PROG", name="Stazione", active=True, created_by=admin_user.id,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
run = (await client.post(
|
||||||
|
"/api/production-runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/task-progress",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
params={"version_id": version.id, "production_run_id": run["id"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = {row["task_id"]: row for row in resp.json()["tasks"]}
|
||||||
|
assert rows[task.id]["state"] == "none"
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": first.id, "version_id": version.id, "value": 10.0,
|
||||||
|
"production_run_id": run["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/task-progress",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
params={"version_id": version.id, "production_run_id": run["id"]},
|
||||||
|
)
|
||||||
|
rows = {row["task_id"]: row for row in resp.json()["tasks"]}
|
||||||
|
assert rows[task.id]["state"] == "complete"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_progress_needs_a_measurement_role(
|
||||||
|
client: AsyncClient, db_session, maker_user, measurement_tec_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, _task = await _version_and_task(db_session, recipe.id)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/task-progress",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
params={"version_id": version.id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for the explicit task type (point 2).
|
||||||
|
|
||||||
|
The type used to be deduced - quotes present meant a measurement - so a measurement
|
||||||
|
task whose quotes had not been entered yet was treated as a note, and the system
|
||||||
|
behaved differently depending on how complete the recipe was. These tests pin the
|
||||||
|
type down as something declared and carried.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _current_version(db_session, recipe_id: int) -> RecipeVersion:
|
||||||
|
row = await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return row.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_task_defaults_to_note(client: AsyncClient, maker_user, db_session):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-T1")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": "Solo una nota"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["task_type"] == "note"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("declared", ["note", "measure", "drawing"])
|
||||||
|
async def test_task_keeps_the_declared_type(
|
||||||
|
client: AsyncClient, maker_user, db_session, declared,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(
|
||||||
|
db_session, user_id=maker_user.id, code=f"REC-{declared}",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": f"Task {declared}", "task_type": declared},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["task_type"] == declared
|
||||||
|
|
||||||
|
|
||||||
|
async def test_measure_task_without_quotes_is_still_a_measure_task(
|
||||||
|
client: AsyncClient, maker_user, db_session,
|
||||||
|
):
|
||||||
|
"""The criterion from the document: an unfinished recipe must not change kind."""
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-NOQ")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
created = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": "Misura da compilare", "task_type": "measure"},
|
||||||
|
)
|
||||||
|
task_id = created.json()["id"]
|
||||||
|
|
||||||
|
fetched = await client.get(f"/api/tasks/{task_id}", headers=auth_headers(maker_user))
|
||||||
|
body = fetched.json()
|
||||||
|
assert body["subtasks"] == [], "il task non ha ancora quote"
|
||||||
|
assert body["task_type"] == "measure", "e resta comunque un task di misura"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_type_is_rejected(client: AsyncClient, maker_user, db_session):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-BAD")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": "x", "task_type": "qualcosa"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_type_can_be_changed(client: AsyncClient, maker_user, db_session):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-CHG")
|
||||||
|
await db_session.commit()
|
||||||
|
created = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": "Nasce nota", "task_type": "note"},
|
||||||
|
)
|
||||||
|
task_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{task_id}",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"task_type": "measure"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["task_type"] == "measure"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_type_survives_copy_on_write_versioning(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
"""A new version must not silently reclassify every task of the recipe."""
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-COW")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
version = await _current_version(db_session, recipe.id)
|
||||||
|
row = await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)
|
||||||
|
first_task = row.scalars().first()
|
||||||
|
|
||||||
|
# Declare it a drawing. The point is that this survives, not how it got set.
|
||||||
|
await client.put(
|
||||||
|
f"/api/tasks/{first_task.id}",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"task_type": "drawing"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Measure on the current version, which forces the next edit to copy-on-write.
|
||||||
|
await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": first_task.subtasks[0].id,
|
||||||
|
"version_id": version.id,
|
||||||
|
"value": 10.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Editing now must create a new version.
|
||||||
|
await client.put(
|
||||||
|
f"/api/recipes/{recipe.id}",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"name": "Rinominata"},
|
||||||
|
)
|
||||||
|
|
||||||
|
new_version = await _current_version(db_session, recipe.id)
|
||||||
|
assert new_version.id != version.id, "l'edit doveva creare una nuova versione"
|
||||||
|
|
||||||
|
copied = await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == new_version.id)
|
||||||
|
)
|
||||||
|
types = sorted(t.task_type for t in copied.scalars().all())
|
||||||
|
assert "drawing" in types, "il tipo dichiarato non e' sopravvissuto alla copia"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_task_list_exposes_the_type(client: AsyncClient, maker_user, db_session):
|
||||||
|
"""The operator's task list reads the type, so it must come over the wire."""
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-LST")
|
||||||
|
await db_session.commit()
|
||||||
|
await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": "Misura", "task_type": "measure"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert all("task_type" in t for t in resp.json())
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Il server non importa VisionSuite, e questo lo verifica.
|
||||||
|
|
||||||
|
Senza qualcuno che lo controlli, il confine si perde in silenzio: basta un
|
||||||
|
import comodo perché l'immagine dell'API torni da cinque gigabyte e il motivo
|
||||||
|
per cui il worker esiste svanisca senza che nessuna prova diventi rossa.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# Full dotted prefixes, not bare roots: "src" alone would forbid every import
|
||||||
|
# the backend legitimately makes of itself (src.backend.*), so the entries
|
||||||
|
# for the vision tree are multi-segment ("src.vision", "src.vision_worker")
|
||||||
|
# while VisionSuite's own top-level packages stay single-segment.
|
||||||
|
#
|
||||||
|
# M2: "Acquire" used to sit here as a stand-in for vs-camera, but it is a
|
||||||
|
# class/SDK name (Balluff's "mvIMPACT Acquire" SDK), never an import root -
|
||||||
|
# it could never match. vs-camera imports under the same `visionsuite`
|
||||||
|
# namespace as vs-core (see
|
||||||
|
# vendor/visionsuite/packages/vs-camera/pyproject.toml: "il codice si importa
|
||||||
|
# come visionsuite.camera...."), which "visionsuite" below already forbids,
|
||||||
|
# so there is nothing separate to add for it.
|
||||||
|
FORBIDDEN = {
|
||||||
|
"visionsuite", "pm2d", "dxf_compare", "torch",
|
||||||
|
"src.vision", "src.vision_worker",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _imported_modules(source: Path) -> set[str]:
|
||||||
|
"""Full dotted module paths this file imports - not just their first
|
||||||
|
segment. `from src.vision.runner import ...` must be checked against
|
||||||
|
"src.vision.runner" (inside the forbidden "src.vision"), not against
|
||||||
|
"src" alone, which is never forbidden since the backend imports itself
|
||||||
|
constantly.
|
||||||
|
"""
|
||||||
|
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
|
||||||
|
modules: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
modules.update(alias.name for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||||
|
modules.add(node.module)
|
||||||
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden_hits(modules: set[str]) -> set[str]:
|
||||||
|
"""Which FORBIDDEN entries a set of imported module paths triggers.
|
||||||
|
|
||||||
|
A module counts as forbidden if it equals a forbidden entry exactly, or
|
||||||
|
lives inside it ("src.vision.runner" is inside "src.vision").
|
||||||
|
"""
|
||||||
|
hits: set[str] = set()
|
||||||
|
for module in modules:
|
||||||
|
for entry in FORBIDDEN:
|
||||||
|
if module == entry or module.startswith(entry + "."):
|
||||||
|
hits.add(entry)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_server_never_imports_visionsuite():
|
||||||
|
offenders = []
|
||||||
|
for source in BACKEND.rglob("*.py"):
|
||||||
|
if "tests" in source.parts or "migrations" in source.parts:
|
||||||
|
continue
|
||||||
|
forbidden = _forbidden_hits(_imported_modules(source))
|
||||||
|
if forbidden:
|
||||||
|
offenders.append(f"{source.relative_to(BACKEND)}: {sorted(forbidden)}")
|
||||||
|
|
||||||
|
assert offenders == [], (
|
||||||
|
"il server deve restare cieco alla visione; usa il worker via HTTP:\n"
|
||||||
|
+ "\n".join(offenders)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_submodule_is_pinned():
|
||||||
|
"""Server e stazione devono montare lo stesso commit: qui c'è la fonte."""
|
||||||
|
gitmodules = Path(__file__).resolve().parents[3] / ".gitmodules"
|
||||||
|
assert gitmodules.exists(), "vendor/visionsuite non è un sottomodulo"
|
||||||
|
assert "vendor/visionsuite" in gitmodules.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_module_path_import_is_caught_not_just_its_root(tmp_path):
|
||||||
|
"""M2: `from src.vision.runner import ...` inside src/backend/** must be
|
||||||
|
caught. Checking only the first dotted segment ("src") would miss it -
|
||||||
|
"src" itself is never forbidden, the backend imports itself all the time.
|
||||||
|
This is the blind spot the reviewer found: same repository, no install
|
||||||
|
needed, the most tempting shortcut of all.
|
||||||
|
"""
|
||||||
|
offender = tmp_path / "sneaky.py"
|
||||||
|
offender.write_text("from src.vision.runner import run_graph\n")
|
||||||
|
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_vision_worker_import_is_also_caught(tmp_path):
|
||||||
|
offender = tmp_path / "sneaky.py"
|
||||||
|
offender.write_text("import src.vision_worker.main\n")
|
||||||
|
assert _forbidden_hits(_imported_modules(offender)) == {"src.vision_worker"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unrelated_src_backend_import_is_not_caught(tmp_path):
|
||||||
|
"""The fix must not turn every self-import of the backend into an
|
||||||
|
offender - only the vision subtrees are forbidden."""
|
||||||
|
innocent = tmp_path / "innocent.py"
|
||||||
|
innocent.write_text("from src.backend.config import settings\n")
|
||||||
|
assert _forbidden_hits(_imported_modules(innocent)) == set()
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""C1: the vision graph must survive copy-on-write, and creation must save it.
|
||||||
|
|
||||||
|
The spec assumed versioning was free for `vision_json`/`vision_output` - "un
|
||||||
|
campo in piu nella copia, non una logica nuova". `_copy_tasks_to_version`
|
||||||
|
copies an explicit field list that omitted both fields, so the first edit to
|
||||||
|
a recipe that already has measurements silently drops the camera graph from
|
||||||
|
the new version. The creation paths (`POST /api/recipes/{id}/tasks` and
|
||||||
|
`POST /api/tasks/{id}/subtasks`) have the same shape of bug: `TaskCreate` and
|
||||||
|
`SubtaskCreate` declare the fields, but the routers never pass them to the
|
||||||
|
ORM constructors, so the API answers 200 and silently does not store them.
|
||||||
|
|
||||||
|
Each test file here reads on its own - the same idiom `test_vision_graph.py`
|
||||||
|
and `test_recipes.py` already use.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
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 RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
|
||||||
|
"""Give the current version a measurement, so an update takes the
|
||||||
|
copy-on-write path instead of updating in place."""
|
||||||
|
version_id = (await db.execute(
|
||||||
|
select(RecipeVersion.id).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
subtask_id = (await db.execute(
|
||||||
|
select(RecipeSubtask.id)
|
||||||
|
.join(RecipeSubtask.task)
|
||||||
|
.where(RecipeSubtask.task.has(version_id=version_id))
|
||||||
|
)).scalar_one()
|
||||||
|
db.add(Measurement(
|
||||||
|
subtask_id=subtask_id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=user_id,
|
||||||
|
value=10.0,
|
||||||
|
pass_fail="pass",
|
||||||
|
deviation=0.0,
|
||||||
|
input_method="manual",
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _current_task_and_subtask(db: AsyncSession, recipe_id: int):
|
||||||
|
version = (await db.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_on_write_preserves_the_vision_graph(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||||
|
):
|
||||||
|
"""A recipe edit that triggers copy-on-write must not drop the camera graph."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, subtask = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# A measurement on v1 forces the next PUT onto the copy-on-write path.
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/recipes/{recipe.id}",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"change_notes": "Edit that must not drop the graph"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
version, new_task, new_subtask = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
assert version.version_number == 2, "the edit should have created v2"
|
||||||
|
assert new_task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
assert new_subtask.vision_output == "t1.diameter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creating_a_task_persists_its_vision_graph(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||||
|
):
|
||||||
|
"""POST /api/recipes/{id}/tasks must store vision_json and vision_output,
|
||||||
|
not just accept and echo them back."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={
|
||||||
|
"title": "Camera task",
|
||||||
|
"task_type": "camera_measure",
|
||||||
|
"vision_json": {"schema_version": 1, "name": "p", "tools": []},
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"marker_number": 1,
|
||||||
|
"description": "Diameter",
|
||||||
|
"nominal": 10.0,
|
||||||
|
"vision_output": "t1.diameter",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["vision_json"] == {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
assert body["subtasks"][0]["vision_output"] == "t1.diameter"
|
||||||
|
|
||||||
|
# Reload from the database - the response reflects the ORM object right
|
||||||
|
# after flush, so this catches a constructor that dropped the field.
|
||||||
|
db_session.expunge_all()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == body["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
sub = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().one()
|
||||||
|
assert task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
assert sub.vision_output == "t1.diameter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creating_a_subtask_persists_its_vision_output(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, maker_user: User,
|
||||||
|
):
|
||||||
|
"""POST /api/tasks/{id}/subtasks (the standalone endpoint) must also store it."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/tasks/{task.id}/subtasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={
|
||||||
|
"marker_number": 2,
|
||||||
|
"description": "Radius",
|
||||||
|
"nominal": 5.0,
|
||||||
|
"vision_output": "t1.radius",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["vision_output"] == "t1.radius"
|
||||||
|
|
||||||
|
db_session.expunge_all()
|
||||||
|
sub = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.marker_number == 2)
|
||||||
|
)).scalar_one()
|
||||||
|
assert sub.vision_output == "t1.radius"
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""A quote from a camera is a measurement like any other: same verdict, same gate."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
GOOD = 10.0 # inside every limit of the fixture quote
|
||||||
|
OUT = 99.0 # far outside
|
||||||
|
|
||||||
|
|
||||||
|
def an_image() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
async def two_vision_quotes(db_session, recipe_id: int):
|
||||||
|
"""The fixture recipe's task, with a second quote mapped to a vision output.
|
||||||
|
|
||||||
|
Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both
|
||||||
|
quotes belong to a single camera acquisition instead of two separate
|
||||||
|
manual readings.
|
||||||
|
"""
|
||||||
|
version, task, first = await recipe_parts(db_session, recipe_id)
|
||||||
|
first.vision_output = "t1.diameter"
|
||||||
|
|
||||||
|
second = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=2, description="Second quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
vision_output="t1.radius",
|
||||||
|
)
|
||||||
|
db_session.add(second)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(second)
|
||||||
|
return version, task, first, second
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker_says(monkeypatch):
|
||||||
|
"""The worker does not run in tests: its response is faked here."""
|
||||||
|
def _install(outputs: dict, failures: list | None = None):
|
||||||
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"outputs": outputs,
|
||||||
|
"failures": failures or [],
|
||||||
|
"engine_version": "a" * 40,
|
||||||
|
"duration_ms": 12,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", fake_call,
|
||||||
|
)
|
||||||
|
return _install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_camera_quote_becomes_a_measurement(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
# The fixture creates the quote with nominal 10 and tight limits around it.
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = (await db_session.execute(
|
||||||
|
select(Measurement).where(Measurement.subtask_id == subtask.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(saved) == 1
|
||||||
|
assert saved[0].pass_fail == "pass"
|
||||||
|
assert saved[0].input_method == "camera"
|
||||||
|
assert saved[0].vision_result_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_execution_records_which_engine_produced_it(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.engine_version == "a" * 40
|
||||||
|
assert result.executed_on == "server"
|
||||||
|
assert result.graph_snapshot == task.vision_json
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_missing_output_names_itself(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""No half measurement: if an output is missing, the error names it."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.radius": 5.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "t1.diameter" in response.json()["detail"]
|
||||||
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_task_without_a_graph_is_refused(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "graph" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per quote.
|
||||||
|
# A camera acquisition produces every quote of the task at the same instant -
|
||||||
|
# there is no "moving on" between them, so the sequential gate must not fire
|
||||||
|
# inside the batch. It still has to fire between two separate acquisitions.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""The quote evaluated first fails; the one after it must still be saved."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
by_subtask = {
|
||||||
|
m.subtask_id: m.pass_fail
|
||||||
|
for m in (await db_session.execute(select(Measurement))).scalars().all()
|
||||||
|
}
|
||||||
|
assert by_subtask == {first.id: "fail", second.id: "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_pending_fail_outside_the_batch_still_blocks(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""A fail left over from something else this camera task does not cover."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
# A third quote on the same task, not part of this acquisition (no vision
|
||||||
|
# output), already failed and is waiting on a supervisor.
|
||||||
|
third = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=3, description="Not a vision quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db_session.add(third)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(third)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=third.id, version_id=version.id,
|
||||||
|
measured_by=measurement_tec_user.id, value=OUT,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "supervisor" in response.json()["detail"].lower()
|
||||||
|
assert (await db_session.execute(
|
||||||
|
select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id]))
|
||||||
|
)).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch,
|
||||||
|
):
|
||||||
|
"""M3: a blocked operator must not burn a whole vision execution to learn
|
||||||
|
they are blocked. The gate is checked before the worker runs, not after."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
third = RecipeSubtask(
|
||||||
|
task_id=task.id, marker_number=3, description="Not a vision quote",
|
||||||
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||||
|
)
|
||||||
|
db_session.add(third)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(third)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=third.id, version_id=version.id,
|
||||||
|
measured_by=measurement_tec_user.id, value=OUT,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
raise AssertionError("the worker must not run while the operator is blocked")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "supervisor" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_new_acquisition_can_remeasure_its_own_pending_quote(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""Re-shooting the same task is the way out, same as re-measuring by hand."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
|
||||||
|
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
|
||||||
|
first_shot = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p1.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert first_shot.status_code == 200
|
||||||
|
|
||||||
|
# first.id is now pending authorisation - but it belongs to this same
|
||||||
|
# task, so a new acquisition of it is not "moving on".
|
||||||
|
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
||||||
|
second_shot = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p2.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert second_shot.status_code == 200
|
||||||
|
|
||||||
|
readings = (await db_session.execute(
|
||||||
|
select(Measurement)
|
||||||
|
.where(Measurement.subtask_id == first.id)
|
||||||
|
.order_by(Measurement.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert [m.pass_fail for m in readings] == ["fail", "pass"]
|
||||||
|
|
||||||
|
assert await measurement_service.pending_authorisation(
|
||||||
|
db_session, version.id, measurement_tec_user.id,
|
||||||
|
) is None
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Il grafo di visione vive sul task, e la quota sa quale uscita la riempie."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.tests.conftest import create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone.
|
||||||
|
|
||||||
|
Versione corrente, il suo unico task, la sua prima quota. È l'idioma che
|
||||||
|
`test_out_of_tolerance.py` usa già, ripetuto perché ogni file di prova si
|
||||||
|
legge da solo.
|
||||||
|
"""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_task_carries_a_vision_graph(db_session, maker_user):
|
||||||
|
"""Il grafo si salva e si rilegge identico: il server non lo interpreta."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-1")
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
graph = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "diametro del foro",
|
||||||
|
"tools": [{"id": "t1", "kind": "DetectCircle", "name": "foro"}],
|
||||||
|
}
|
||||||
|
task.vision_json = graph
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
reloaded = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == task.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert reloaded.vision_json == graph
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subtask_names_the_output_that_fills_it(db_session, maker_user):
|
||||||
|
"""La quota dichiara quale uscita del grafo le compete."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-2")
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
reloaded = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert reloaded.vision_output == "t1.diameter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_typo_is_gone():
|
||||||
|
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
|
||||||
|
assert "dxf_compare" in TASK_TYPES
|
||||||
|
assert "xf_compare" not in TASK_TYPES
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""The Maker composes at a desk: image saved, graph tried, no measurement."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
def an_image() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker_says(monkeypatch):
|
||||||
|
def _install(outputs: dict):
|
||||||
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
return {"outputs": outputs, "failures": [],
|
||||||
|
"engine_version": "b" * 40, "duration_ms": 7}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", fake_call,
|
||||||
|
)
|
||||||
|
return _install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
"""Keep reference images out of the real repo tree during tests.
|
||||||
|
|
||||||
|
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
|
||||||
|
replaces the left operand of `/` when the right one is absolute - so
|
||||||
|
redirecting the bare `upload_dir` setting to pytest's absolute `tmp_path`
|
||||||
|
is enough to redirect the resolved path too.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_reference_image_keeps_its_provenance(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("ref.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id), "note": "sample part"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = (await db_session.execute(
|
||||||
|
select(VisionReferenceImage)
|
||||||
|
)).scalars().one()
|
||||||
|
assert saved.task_id == task.id
|
||||||
|
assert saved.note == "sample part"
|
||||||
|
assert saved.acquired_at is not None
|
||||||
|
assert saved.path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_runs_the_graph_and_saves_no_measurement(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": 42.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("ref.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["outputs"]["t1.diameter"] == 42.0
|
||||||
|
# This is the whole point of this test: composing does not measure.
|
||||||
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""I3: the three vision endpoints must not accept an unbounded or wrong-typed
|
||||||
|
upload. `/execute`, `/preview` and `/reference-images` read the whole image
|
||||||
|
into memory with no size cap, and `/reference-images` wrote whatever arrived
|
||||||
|
to disk with a `.png` extension regardless of its real type - a PDF is not
|
||||||
|
something a vision graph can measure.
|
||||||
|
|
||||||
|
Reuses the project's existing convention (`validate_file_size`,
|
||||||
|
`ALLOWED_IMAGE_TYPES` from `files.py`) instead of inventing a second one.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
"""Keep the reference image this file stores out of the real repo tree.
|
||||||
|
|
||||||
|
The same guard `test_vision_reference.py` carries: without it, the test that
|
||||||
|
proves a jpeg is stored with its own extension leaves a real file under
|
||||||
|
`uploads/vision/reference/`, so every suite run dirties the working tree.
|
||||||
|
|
||||||
|
`settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib
|
||||||
|
replaces the left operand of `/` when the right one is absolute - so
|
||||||
|
redirecting the bare `upload_dir` setting is enough to redirect the
|
||||||
|
resolved path too.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def a_png() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def a_jpeg() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (64, 64), color=0).save(buffer, format="JPEG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_parts(db_session, recipe_id: int):
|
||||||
|
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||||
|
)).scalars().first()
|
||||||
|
subtask = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().first()
|
||||||
|
return version, task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker_says(monkeypatch):
|
||||||
|
def _install(outputs: dict):
|
||||||
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
return {"outputs": outputs, "failures": [],
|
||||||
|
"engine_version": "c" * 40, "duration_ms": 5}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service._call_worker", fake_call,
|
||||||
|
)
|
||||||
|
return _install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_rejects_a_non_image_upload(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
|
||||||
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_rejects_an_oversized_upload(
|
||||||
|
client, db_session, measurement_tec_user, worker_says, monkeypatch,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", a_png(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (await db_session.execute(select(VisionResult))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_rejects_a_non_image_upload(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
await db_session.commit()
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reference_images_rejects_a_non_image_upload(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.pdf", b"%PDF-1.4 not an image", "application/pdf")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (await db_session.execute(
|
||||||
|
select(VisionReferenceImage)
|
||||||
|
)).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reference_image_keeps_the_extension_matching_its_real_type(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
"""A jpeg upload must not be written to disk as `<uuid>.png`."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.jpg", a_jpeg(), "image/jpeg")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||||
|
assert saved.path.endswith(".jpg")
|
||||||
|
assert (settings.upload_path / saved.path).exists()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""I2: a broken worker must answer with a named status, never a naked 500.
|
||||||
|
|
||||||
|
`_call_worker` mapped a non-200 HTTP response to 502, but let
|
||||||
|
`httpx.ConnectError` (worker stopped) and `httpx.ReadTimeout` (job past the
|
||||||
|
120s budget) propagate unhandled - with no exception handler in main.py that
|
||||||
|
turns those into anything but FastAPI's generic 500. It also read
|
||||||
|
`report["outputs"]`, `report["failures"]` and `report["engine_version"]`
|
||||||
|
without checking the response had that shape at all, so a proxy answering 200
|
||||||
|
with something else (HTML, an empty body) would raise a bare KeyError instead
|
||||||
|
of a diagnosable error.
|
||||||
|
|
||||||
|
The spec's edge-case table: "il worker isola il guasto, l'API resta in
|
||||||
|
piedi, il task riporta l'errore" - the worker's fault must not become the
|
||||||
|
API's fault.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.backend.services.vision_service import _call_worker
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
"""Stands in for httpx.Response: only what `_call_worker` touches."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, body=..., text: str = "", raise_on_json: bool = False):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._body = body
|
||||||
|
self.text = text
|
||||||
|
self._raise_on_json = raise_on_json
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if self._raise_on_json:
|
||||||
|
raise ValueError("not JSON")
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncClient:
|
||||||
|
"""Stands in for httpx.AsyncClient: post() does whatever the test wants."""
|
||||||
|
|
||||||
|
def __init__(self, *, response=None, raises=None):
|
||||||
|
self._response = response
|
||||||
|
self._raises = raises
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, *args, **kwargs):
|
||||||
|
if self._raises is not None:
|
||||||
|
raise self._raises
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
def _install(monkeypatch, *, response=None, raises=None):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service.httpx.AsyncClient",
|
||||||
|
lambda *a, **k: _FakeAsyncClient(response=response, raises=raises),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_stopped_worker_answers_502_not_a_naked_500(monkeypatch):
|
||||||
|
_install(monkeypatch, raises=httpx.ConnectError("Connection refused"))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 502
|
||||||
|
assert "unreachable" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_job_past_the_time_budget_answers_504(monkeypatch):
|
||||||
|
_install(monkeypatch, raises=httpx.ReadTimeout("timed out"))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 504
|
||||||
|
assert "time" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_response_missing_the_expected_keys_is_reported_not_raised_raw(monkeypatch):
|
||||||
|
"""A proxy answering 200 with an unexpected shape must not KeyError."""
|
||||||
|
_install(monkeypatch, response=_FakeResponse(200, body={"outputs": {}}))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 502
|
||||||
|
assert "malformed" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_response_that_is_not_a_json_object_is_reported(monkeypatch):
|
||||||
|
"""200 with HTML or a bare list, say - not a dict at all."""
|
||||||
|
_install(monkeypatch, response=_FakeResponse(200, body=["not", "a", "dict"]))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 502
|
||||||
|
assert "malformed" in excinfo.value.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_well_formed_response_still_passes_through(monkeypatch):
|
||||||
|
body = {"outputs": {"t1.diameter": 10.0}, "failures": [], "engine_version": "a" * 40}
|
||||||
|
_install(monkeypatch, response=_FakeResponse(200, body=body))
|
||||||
|
|
||||||
|
report = await _call_worker(b"img", {"schema_version": 1, "tools": []})
|
||||||
|
|
||||||
|
assert report == body
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Flask, redirect, url_for, session, request
|
from flask import Flask, redirect, url_for, session, request
|
||||||
from flask_babel import Babel
|
from flask_babel import Babel
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup, escape
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
|
# **bold**, the whole of the markup a task description understands besides the
|
||||||
|
# line break. DOTALL so a phrase that wraps onto the next line still closes.
|
||||||
|
_BOLD_RX = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
|
||||||
|
|
||||||
|
# Punto 12. Nothing loads from outside: every library ships with the install
|
||||||
|
# (static/vendor/), because the shop floor network has no way out and a page that
|
||||||
|
# waits for a CDN there is a white screen, not a slow one.
|
||||||
|
#
|
||||||
|
# unsafe-inline the templates carry their Alpine components inline
|
||||||
|
# unsafe-eval Alpine 3 and Plotly both compile expressions at runtime
|
||||||
|
# blob: PDF.js runs its worker from a blob, Fabric exports canvases
|
||||||
|
# data: thumbnails and canvas exports are inlined
|
||||||
|
CSP = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"font-src 'self'; "
|
||||||
|
"img-src 'self' data: blob:; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"worker-src 'self' blob:; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_locale():
|
def get_locale():
|
||||||
"""Get user's preferred language from session or Accept-Language header."""
|
"""Get user's preferred language from session or Accept-Language header."""
|
||||||
@@ -74,6 +101,24 @@ def create_app() -> Flask:
|
|||||||
referrer = None
|
referrer = None
|
||||||
return redirect(referrer or url_for("auth.login"))
|
return redirect(referrer or url_for("auth.login"))
|
||||||
|
|
||||||
|
@app.template_filter("rich_text")
|
||||||
|
def rich_text_filter(value):
|
||||||
|
"""Render a task description keeping its line breaks and its bold.
|
||||||
|
|
||||||
|
Whoever writes a recipe pastes from the PDF of the technical sheet, and the
|
||||||
|
text used to arrive flattened. Two conventions carry it: a blank line is a
|
||||||
|
line break, **like this** is bold.
|
||||||
|
|
||||||
|
No HTML is ever stored or trusted - the text is escaped first and the only
|
||||||
|
tags in the result are the ones produced here. That is the sanitisation:
|
||||||
|
there is nothing to sanitise, because nothing is accepted.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return Markup("")
|
||||||
|
escaped = str(escape(str(value)))
|
||||||
|
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
|
||||||
|
return Markup(bolded.replace("\n", "<br>"))
|
||||||
|
|
||||||
@app.template_filter("tojson_attr")
|
@app.template_filter("tojson_attr")
|
||||||
def tojson_attr_filter(value):
|
def tojson_attr_filter(value):
|
||||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||||
@@ -92,6 +137,25 @@ def create_app() -> Flask:
|
|||||||
)
|
)
|
||||||
return Markup(rv)
|
return Markup(rv)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def security_headers(response):
|
||||||
|
"""Same-origin only, on the pages the browser actually loads.
|
||||||
|
|
||||||
|
The policy existed on the backend, which serves the API; the HTML comes
|
||||||
|
from here and carried no policy at all. So the rule that says «nothing
|
||||||
|
from outside» was written where it could not be broken and absent where
|
||||||
|
it could.
|
||||||
|
|
||||||
|
It is also the guard that makes point 12 stay done: a CDN tag added to a
|
||||||
|
template months from now is refused by the browser here, at a desk with a
|
||||||
|
network, instead of on a shop floor that has none.
|
||||||
|
"""
|
||||||
|
response.headers.setdefault("Content-Security-Policy", CSP)
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
return response
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_globals():
|
def inject_globals():
|
||||||
"""Inject global variables into all templates."""
|
"""Inject global variables into all templates."""
|
||||||
|
|||||||
@@ -119,6 +119,112 @@ def select_recipe():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_traceability() -> tuple[str, str]:
|
||||||
|
"""Carry lot and serial from the query string into the session."""
|
||||||
|
lot_number = request.args.get("lot_number", session.get("lot_number", ""))
|
||||||
|
serial_number = request.args.get(
|
||||||
|
"serial_number", session.get("serial_number", ""),
|
||||||
|
)
|
||||||
|
if lot_number:
|
||||||
|
session["lot_number"] = lot_number
|
||||||
|
if serial_number:
|
||||||
|
session["serial_number"] = serial_number
|
||||||
|
return lot_number, serial_number
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_traceability(recipe: dict, lot: str, serial: str) -> list[str]:
|
||||||
|
"""The compulsory fields this recipe is still owed."""
|
||||||
|
missing = []
|
||||||
|
if recipe.get("requires_lot") and not (lot or "").strip():
|
||||||
|
missing.append(_("lotto"))
|
||||||
|
if recipe.get("requires_serial") and not (serial or "").strip():
|
||||||
|
missing.append(_("seriale"))
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_tasks(recipe_id: int) -> list[dict] | None:
|
||||||
|
"""Tasks of a recipe's current version in execution order, or None on error."""
|
||||||
|
resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return None
|
||||||
|
tasks = resp if isinstance(resp, list) else resp.get("items", [])
|
||||||
|
return sorted(tasks, key=lambda t: (t.get("order_index", 0), t.get("id", 0)))
|
||||||
|
|
||||||
|
|
||||||
|
def _task_progress(version_id, run_id) -> dict[int, dict]:
|
||||||
|
"""Quotes already taken per measurement task, keyed by task id.
|
||||||
|
|
||||||
|
Never fatal: a list that cannot say which task was left half done is still a
|
||||||
|
usable list, and losing the whole page over a badge would be the worse trade.
|
||||||
|
"""
|
||||||
|
if not version_id:
|
||||||
|
return {}
|
||||||
|
params = {"version_id": version_id}
|
||||||
|
if run_id:
|
||||||
|
params["production_run_id"] = run_id
|
||||||
|
resp = api_client.get("/api/measurements/task-progress", params=params)
|
||||||
|
if not isinstance(resp, dict) or resp.get("error"):
|
||||||
|
return {}
|
||||||
|
return {row["task_id"]: row for row in resp.get("tasks", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_run_id() -> int | None:
|
||||||
|
"""The production open at this station, if there is one."""
|
||||||
|
station_code, _overridden = _current_station()
|
||||||
|
if not station_code:
|
||||||
|
return None
|
||||||
|
# The endpoint answers with the run itself, or null when nothing is running.
|
||||||
|
resp = api_client.get(
|
||||||
|
"/api/production-runs/current", params={"station_code": station_code},
|
||||||
|
)
|
||||||
|
if not isinstance(resp, dict) or resp.get("error"):
|
||||||
|
return None
|
||||||
|
return resp.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route: start the guided sequence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@measure_bp.route("/start/<int:recipe_id>")
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def start_sequence(recipe_id: int):
|
||||||
|
"""Open the first task of a recipe and let the operator walk the sequence.
|
||||||
|
|
||||||
|
Choosing a recipe used to land on the full task list, which asked the operator
|
||||||
|
to pick a starting point before they had done anything. The sequence is the
|
||||||
|
normal way through; the list is still there, one level down, for when they need
|
||||||
|
to see the whole of it.
|
||||||
|
"""
|
||||||
|
lot_number, serial_number = _remember_traceability()
|
||||||
|
|
||||||
|
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||||
|
if recipe_resp.get("error"):
|
||||||
|
flash(
|
||||||
|
_("Ricetta non trovata: %(detail)s",
|
||||||
|
detail=recipe_resp.get("detail", "")),
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("measure.select_recipe"))
|
||||||
|
|
||||||
|
missing = _missing_traceability(recipe_resp, lot_number, serial_number)
|
||||||
|
if missing:
|
||||||
|
flash(
|
||||||
|
_("Compila prima: %(fields)s", fields=", ".join(missing)),
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for(
|
||||||
|
"measure.select_recipe", recipe=recipe_resp.get("code", ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
tasks = _ordered_tasks(recipe_id)
|
||||||
|
if not tasks:
|
||||||
|
flash(_("Questa ricetta non ha ancora task definiti."), "error")
|
||||||
|
return redirect(url_for("measure.task_list", recipe_id=recipe_id))
|
||||||
|
|
||||||
|
return redirect(url_for("measure.task_execute", task_id=tasks[0]["id"]))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Route: Task list for a recipe
|
# Route: Task list for a recipe
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -127,17 +233,7 @@ def select_recipe():
|
|||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
def task_list(recipe_id: int):
|
def task_list(recipe_id: int):
|
||||||
"""Task list for selected recipe."""
|
"""Task list for selected recipe."""
|
||||||
# Persist lot/serial from query params into session
|
lot_number, serial_number = _remember_traceability()
|
||||||
lot_number = request.args.get(
|
|
||||||
"lot_number", session.get("lot_number", ""),
|
|
||||||
)
|
|
||||||
serial_number = request.args.get(
|
|
||||||
"serial_number", session.get("serial_number", ""),
|
|
||||||
)
|
|
||||||
if lot_number:
|
|
||||||
session["lot_number"] = lot_number
|
|
||||||
if serial_number:
|
|
||||||
session["serial_number"] = serial_number
|
|
||||||
|
|
||||||
# Load recipe details
|
# Load recipe details
|
||||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||||
@@ -149,22 +245,19 @@ def task_list(recipe_id: int):
|
|||||||
)
|
)
|
||||||
return redirect(url_for("measure.select_recipe"))
|
return redirect(url_for("measure.select_recipe"))
|
||||||
|
|
||||||
# Load tasks for this recipe
|
tasks = _ordered_tasks(recipe_id)
|
||||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
if tasks is None:
|
||||||
if isinstance(tasks_resp, dict) and tasks_resp.get("error"):
|
flash(_("Errore nel caricamento dei task."), "error")
|
||||||
flash(
|
|
||||||
_("Errore nel caricamento dei task: %(detail)s",
|
|
||||||
detail=tasks_resp.get("detail", "")),
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
tasks = []
|
tasks = []
|
||||||
else:
|
|
||||||
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
|
version = recipe_resp.get("current_version") or {}
|
||||||
|
progress = _task_progress(version.get("id"), _current_run_id())
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"measure/task_list.html",
|
"measure/task_list.html",
|
||||||
recipe=recipe_resp,
|
recipe=recipe_resp,
|
||||||
tasks=tasks,
|
tasks=tasks,
|
||||||
|
progress=progress,
|
||||||
lot_number=lot_number,
|
lot_number=lot_number,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
)
|
)
|
||||||
@@ -195,6 +288,9 @@ def task_execute(task_id: int):
|
|||||||
recipe_id = task_resp.get("recipe_id")
|
recipe_id = task_resp.get("recipe_id")
|
||||||
all_task_ids = []
|
all_task_ids = []
|
||||||
measurement_interval_minutes = None
|
measurement_interval_minutes = None
|
||||||
|
# Allowed until the recipe says otherwise: a recipe that could not be read must
|
||||||
|
# not silently take the keypad away from the operator.
|
||||||
|
allow_manual_input = True
|
||||||
if recipe_id:
|
if recipe_id:
|
||||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||||
if isinstance(tasks_resp, list):
|
if isinstance(tasks_resp, list):
|
||||||
@@ -203,6 +299,7 @@ def task_execute(task_id: int):
|
|||||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||||
if not recipe_resp.get("error"):
|
if not recipe_resp.get("error"):
|
||||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||||
|
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"measure/task_execute.html",
|
"measure/task_execute.html",
|
||||||
@@ -211,6 +308,7 @@ def task_execute(task_id: int):
|
|||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
all_task_ids=all_task_ids,
|
all_task_ids=all_task_ids,
|
||||||
measurement_interval_minutes=measurement_interval_minutes,
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
|
allow_manual_input=allow_manual_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -372,9 +470,14 @@ def save_measurement():
|
|||||||
"serial_number": data.get("serial_number", session.get("serial_number", "")),
|
"serial_number": data.get("serial_number", session.get("serial_number", "")),
|
||||||
"input_method": data.get("input_method", "manual"),
|
"input_method": data.get("input_method", "manual"),
|
||||||
"input_duration_ms": data.get("input_duration_ms"),
|
"input_duration_ms": data.get("input_duration_ms"),
|
||||||
|
# Attaches the measurement to the production under way, so fine produzione
|
||||||
|
# can hand the whole run to the statistics file.
|
||||||
|
"production_run_id": data.get("production_run_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = api_client.post("/api/measurements", data=payload)
|
# Trailing slash matters: the route is declared as "/", so omitting it costs
|
||||||
|
# a 307 redirect on every single measurement saved.
|
||||||
|
resp = api_client.post("/api/measurements/", data=payload)
|
||||||
|
|
||||||
if resp.get("error"):
|
if resp.get("error"):
|
||||||
status_code = resp.get("status_code", 500)
|
status_code = resp.get("status_code", 500)
|
||||||
@@ -387,31 +490,53 @@ def save_measurement():
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Route: Validate supervisor credentials (AJAX)
|
# Routes: Out-of-tolerance authorisation (point 5)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@measure_bp.route("/validate-supervisor", methods=["POST"])
|
@measure_bp.route("/api/measurements/pending-authorisation", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
def validate_supervisor():
|
def api_pending_authorisation():
|
||||||
"""Validate supervisor (capoturno) credentials for out-of-tolerance authorization."""
|
"""Proxy: the out-of-tolerance measurement holding this operator up, or null.
|
||||||
|
|
||||||
|
Asked when the measurement screen loads, so reloading the page finds the same
|
||||||
|
block the server enforces rather than a clean slate.
|
||||||
|
"""
|
||||||
|
params = {"version_id": request.args.get("version_id")}
|
||||||
|
run_id = request.args.get("production_run_id")
|
||||||
|
if run_id:
|
||||||
|
params["production_run_id"] = run_id
|
||||||
|
|
||||||
|
resp = api_client.get("/api/measurements/pending-authorisation", params=params)
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_authorise_measurement(measurement_id: int):
|
||||||
|
"""Proxy: the capoturno lets an out-of-tolerance value stand.
|
||||||
|
|
||||||
|
The credentials go to the endpoint that records the approval on the
|
||||||
|
measurement. Checking them separately, as this used to, left the approval
|
||||||
|
written down nowhere and nothing depending on it.
|
||||||
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = data.get("username", "").strip()
|
username = (data.get("supervisor_username") or "").strip()
|
||||||
password = data.get("password", "")
|
password = data.get("supervisor_password") or ""
|
||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
|
return jsonify({
|
||||||
|
"error": True, "detail": _("Username e password richiesti"),
|
||||||
|
}), 400
|
||||||
|
|
||||||
resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
|
resp = api_client.post(
|
||||||
|
f"/api/measurements/{measurement_id}/authorise",
|
||||||
if resp.get("error"):
|
data={"supervisor_username": username, "supervisor_password": password},
|
||||||
return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
|
)
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
user = resp.get("user", {})
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
is_supervisor = "Supervisor" in (user.get("roles") or [])
|
return jsonify(resp), 200
|
||||||
if not (is_supervisor or user.get("is_admin")):
|
|
||||||
return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403
|
|
||||||
|
|
||||||
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -465,16 +590,88 @@ def api_start_production():
|
|||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
def api_complete_cycle(run_id: int):
|
def api_complete_cycle(run_id: int):
|
||||||
"""Proxy: record a finished measurement cycle and restart the interval."""
|
"""Proxy: record a finished measurement task.
|
||||||
|
|
||||||
|
The task travels with the call: only the last measurement task of the recipe
|
||||||
|
closes the cycle and restarts the interval, and the server is what decides.
|
||||||
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
resp = api_client.post(
|
resp = api_client.post(
|
||||||
f"/api/production-runs/{run_id}/cycle", data={"note": data.get("note")},
|
f"/api/production-runs/{run_id}/cycle",
|
||||||
|
data={"task_id": data.get("task_id"), "note": data.get("note")},
|
||||||
)
|
)
|
||||||
if isinstance(resp, dict) and resp.get("error"):
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
return jsonify(resp), resp.get("status_code", 500)
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/production/<int:run_id>/remeasure", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_remeasure(run_id: int):
|
||||||
|
"""Proxy: the piece was turned over - measure again inside the same cycle."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
resp = api_client.post(
|
||||||
|
f"/api/production-runs/{run_id}/remeasure",
|
||||||
|
data={"task_id": data.get("task_id"), "note": data.get("note")},
|
||||||
|
)
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
def _supervised_action(run_id: int, action: str):
|
||||||
|
"""Forward an action that needs the capoturno's authorisation.
|
||||||
|
|
||||||
|
The credentials go straight to the API, which checks them and records who
|
||||||
|
authorised what on the run's trace. Validating them separately first would
|
||||||
|
leave no such record, and is an extra round trip besides.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get("supervisor_username") or "").strip()
|
||||||
|
password = data.get("supervisor_password") or ""
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({
|
||||||
|
"error": True, "detail": _("Username e password richiesti"),
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
f"/api/production-runs/{run_id}/{action}",
|
||||||
|
data={
|
||||||
|
"supervisor_username": username,
|
||||||
|
"supervisor_password": password,
|
||||||
|
"note": data.get("note"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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/<int:run_id>/pause", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_pause_production(run_id: int):
|
||||||
|
"""Proxy: fermo linea - suspends the run and freezes the countdown."""
|
||||||
|
return _supervised_action(run_id, "pause")
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/production/<int:run_id>/resume", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_resume_production(run_id: int):
|
||||||
|
"""Proxy: restart a stopped line, giving back the time the stop took."""
|
||||||
|
return _supervised_action(run_id, "resume")
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/production/<int:run_id>/close", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_close_production(run_id: int):
|
||||||
|
"""Proxy: fine produzione - closes the run and emits the statistics file."""
|
||||||
|
return _supervised_action(run_id, "close")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Route: File proxy (browser can't send X-API-Key directly)
|
# Route: File proxy (browser can't send X-API-Key directly)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -124,10 +124,66 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Page shell — one frame for every view
|
||||||
|
|
||||||
|
Punto 14. Ogni schermata si disegnava la propria larghezza: sette valori
|
||||||
|
diversi fra le viste, nessuno allineato alla navbar, che sta a 80rem su
|
||||||
|
tutte. Passando da un menu all'altro il blocco di contenuto cambiava
|
||||||
|
larghezza e si spostava rispetto alla barra sopra di sé.
|
||||||
|
|
||||||
|
Qui la cornice è una sola e coincide con quella della navbar. Le pagine
|
||||||
|
che hanno bisogno di una colonna stretta - i moduli - la stringono al loro
|
||||||
|
interno: il bordo della pagina resta dov'è, cambia solo il contenuto.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.tmf-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 80rem; /* == max-w-7xl, la stessa della navbar */
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 1rem; /* == px-4 */
|
||||||
|
padding-block: 1.5rem; /* == py-6 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.tmf-page { padding-inline: 1.5rem; } /* == sm:px-6 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.tmf-page { padding-inline: 2rem; } /* == lg:px-8 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* I moduli restano su una colonna leggibile: un campo di testo largo 1280px non
|
||||||
|
si compila meglio, si compila peggio. Due larghezze in tutto il prodotto -
|
||||||
|
questa e quella sopra - al posto delle sette di prima. */
|
||||||
|
.tmf-page.tmf-page-narrow {
|
||||||
|
max-width: 56rem; /* == max-w-4xl */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Custom Scrollbar
|
Custom Scrollbar
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
/* Lo spazio della barra di scorrimento è riservato sempre, anche quando la
|
||||||
|
pagina non scorre. Senza, ogni passaggio fra una vista lunga e una corta -
|
||||||
|
e ogni ingresso nella schermata di misura, che blocca lo scorrimento -
|
||||||
|
spostava di 8px tutto il contenuto centrato, navbar compresa. */
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* La schermata di misura è alta quanto la finestra vera, non quanto quella
|
||||||
|
teorica: su Android e iOS `100vh` è l'altezza che la pagina avrebbe con la
|
||||||
|
barra dell'indirizzo nascosta, quindi su un tablet il piede - dove stanno
|
||||||
|
«Fine ciclo misura» e il tastierino - finisce sotto il bordo dello schermo, e
|
||||||
|
ricompare quando la barra si ritrae. `100dvh` segue la finestra reale. */
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
body.h-screen {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Webkit (Chrome, Safari, Edge) */
|
/* Webkit (Chrome, Safari, Edge) */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* Used for measurement data entry in task_execute.html
|
* Used for measurement data entry in task_execute.html
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function numpad() {
|
function numpad(options) {
|
||||||
|
var opts = options || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
value: '', // String representation of the current value
|
value: '', // String representation of the current value
|
||||||
@@ -13,9 +15,16 @@ function numpad() {
|
|||||||
maxIntDigits: 6, // Maximum integer digits
|
maxIntDigits: 6, // Maximum integer digits
|
||||||
maxDecDigits: 6, // Maximum decimal digits
|
maxDecDigits: 6, // Maximum decimal digits
|
||||||
|
|
||||||
|
/* Whether a value may be typed at all, as the recipe declares it. When false
|
||||||
|
the keypad shows no digits and a value that looks typed is refused here as
|
||||||
|
well as by the server: the caliper is the instrument. Defaults to allowed,
|
||||||
|
so a caller that says nothing gets the behaviour that came before. */
|
||||||
|
allowManual: opts.allowManual !== false,
|
||||||
|
|
||||||
// HID burst detection (USB caliper vs manual typing)
|
// HID burst detection (USB caliper vs manual typing)
|
||||||
_lastKeyTime: 0, // Timestamp of last keystroke
|
_lastKeyTime: 0, // Timestamp of last keystroke
|
||||||
_burstCount: 0, // Consecutive fast keystrokes
|
_burstCount: 0, // Consecutive fast keystrokes
|
||||||
|
_enterWasFast: false, // Enter arrived in the same burst as the digits
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the display value with sign
|
* Get the display value with sign
|
||||||
@@ -109,6 +118,20 @@ function numpad() {
|
|||||||
this.value = '';
|
this.value = '';
|
||||||
this.negative = false;
|
this.negative = false;
|
||||||
this.hasDecimal = false;
|
this.hasDecimal = false;
|
||||||
|
this._enterWasFast = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the value in the display got there.
|
||||||
|
*
|
||||||
|
* A wedge caliper sends its digits and the Enter that follows them as one
|
||||||
|
* burst; a person is slower on both. The Enter is the stronger of the two
|
||||||
|
* signals: a short reading like "9.5" is only three keystrokes, too few to
|
||||||
|
* judge by count alone, and used to be filed as typed by hand.
|
||||||
|
*/
|
||||||
|
_classifyInput() {
|
||||||
|
if (this._enterWasFast) return 'usb_caliper';
|
||||||
|
return this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,9 +141,18 @@ function numpad() {
|
|||||||
if (!this.hasValue) return;
|
if (!this.hasValue) return;
|
||||||
|
|
||||||
const val = this.numericValue;
|
const val = this.numericValue;
|
||||||
|
const inputMethod = this._classifyInput();
|
||||||
|
|
||||||
// Determine input method: 3+ fast keystrokes = USB caliper burst
|
// The recipe forbids typing: say so and keep the value on screen rather than
|
||||||
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
// clearing it, so the operator sees what was refused. The server refuses the
|
||||||
|
// same request anyway - this is only the earlier, kinder of the two answers.
|
||||||
|
if (inputMethod === 'manual' && !this.allowManual) {
|
||||||
|
this.$dispatch('numpad-rejected', { reason: 'manual_not_allowed', value: val });
|
||||||
|
this._enterWasFast = false;
|
||||||
|
this._burstCount = 0;
|
||||||
|
this._lastKeyTime = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch custom event for parent component to handle
|
// Dispatch custom event for parent component to handle
|
||||||
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
|
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
|
||||||
@@ -199,6 +231,12 @@ function numpad() {
|
|||||||
// Enter - confirm
|
// Enter - confirm
|
||||||
else if (e.key === 'Enter') {
|
else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// Measured before confirming: an Enter that lands within the burst is the
|
||||||
|
// caliper closing its own transmission, not a person reaching for a key.
|
||||||
|
const gap = this._lastKeyTime > 0
|
||||||
|
? performance.now() - this._lastKeyTime
|
||||||
|
: Infinity;
|
||||||
|
this._enterWasFast = gap < 80;
|
||||||
this.confirm();
|
this.confirm();
|
||||||
}
|
}
|
||||||
// Minus sign - toggle sign
|
// Minus sign - toggle sign
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* The measurement clock, shared by every screen an operator can be on.
|
||||||
|
*
|
||||||
|
* Two things the shop floor asked for live here. First, the countdown does not stop
|
||||||
|
* at zero: it keeps going the other way, so how late a measurement is can be read
|
||||||
|
* off the screen instead of worked out. Second, when the interval expires the
|
||||||
|
* measurement comes back on its own, wherever the operator happens to be - the task
|
||||||
|
* list, the summary, the recipe picker.
|
||||||
|
*
|
||||||
|
* The number always comes from the server (seconds_to_next_measurement, already
|
||||||
|
* signed). Ticking locally only keeps the display smooth between round trips, and
|
||||||
|
* every resync overwrites it: a tab that was asleep, or a machine whose clock is
|
||||||
|
* off, still shows the same figure as the station next to it.
|
||||||
|
*
|
||||||
|
* ProductionClock holds the pure parts, used here and by the measurement screen,
|
||||||
|
* which has its own component but must not grow a second copy of these rules.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var ProductionClock = {
|
||||||
|
/* mm:ss of the distance from the deadline, in whichever direction. The sign is
|
||||||
|
carried by the wording around it ("tra" / "in ritardo di"), not by a minus. */
|
||||||
|
format: function (seconds) {
|
||||||
|
var total = Math.abs(Math.round(seconds || 0));
|
||||||
|
var m = Math.floor(total / 60);
|
||||||
|
var s = total % 60;
|
||||||
|
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||||
|
},
|
||||||
|
|
||||||
|
hasClock: function (seconds) {
|
||||||
|
return seconds !== null && seconds !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
isOverdue: function (seconds) {
|
||||||
|
return ProductionClock.hasClock(seconds) && seconds <= 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Three short beeps. Wrapped because a browser refuses to make noise before the
|
||||||
|
page has been touched, and a silent buzzer must not take the screen with it.
|
||||||
|
Whether a light column replaces this is question D-5. */
|
||||||
|
playBuzzer: function () {
|
||||||
|
try {
|
||||||
|
var ctx = new (global.AudioContext || global.webkitAudioContext)();
|
||||||
|
[0, 0.25, 0.5].forEach(function (delay) {
|
||||||
|
var osc = ctx.createOscillator();
|
||||||
|
var gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.frequency.value = 880;
|
||||||
|
osc.type = 'square';
|
||||||
|
gain.gain.value = 0.3;
|
||||||
|
osc.start(ctx.currentTime + delay);
|
||||||
|
osc.stop(ctx.currentTime + delay + 0.15);
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* The task the operator has to be taken back to: the first measurement task of
|
||||||
|
the recipe. Null when the recipe has none, which is the whole reason this is
|
||||||
|
a lookup and not an assumption. */
|
||||||
|
measurementUrl: function (run, executeUrlTemplate) {
|
||||||
|
var ids = (run && run.measurement_task_ids) || [];
|
||||||
|
if (!ids.length) return null;
|
||||||
|
return executeUrlTemplate.replace('/0', '/' + ids[0]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
global.ProductionClock = ProductionClock;
|
||||||
|
|
||||||
|
/* How long the operator gets to see why the screen is about to change. Long
|
||||||
|
enough to read it, short enough that it is not a way of putting the
|
||||||
|
measurement off. */
|
||||||
|
var ANNOUNCE_SECONDS = 5;
|
||||||
|
/* The server is asked again on this cadence: the run may have been stopped or
|
||||||
|
closed from another screen, and a sleeping tab drifts. */
|
||||||
|
var RESYNC_MS = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner component for the screens that only watch the clock.
|
||||||
|
*
|
||||||
|
* options.currentUrl - endpoint returning the run open at this station, or null
|
||||||
|
* options.executeUrl - url of a task execution page with 0 as the task id
|
||||||
|
*/
|
||||||
|
global.productionClock = function (options) {
|
||||||
|
var opts = options || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
run: null,
|
||||||
|
seconds: null,
|
||||||
|
announced: false,
|
||||||
|
redirectIn: 0,
|
||||||
|
_tick: null,
|
||||||
|
_resync: null,
|
||||||
|
|
||||||
|
init: function () {
|
||||||
|
var self = this;
|
||||||
|
this.load();
|
||||||
|
this._tick = setInterval(function () { self.onSecond(); }, 1000);
|
||||||
|
this._resync = setInterval(function () { self.load(); }, RESYNC_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy: function () {
|
||||||
|
if (this._tick) clearInterval(this._tick);
|
||||||
|
if (this._resync) clearInterval(this._resync);
|
||||||
|
},
|
||||||
|
|
||||||
|
load: async function () {
|
||||||
|
try {
|
||||||
|
var resp = await fetch(opts.currentUrl);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
var run = await resp.json();
|
||||||
|
this.adopt(run && run.id ? run : null);
|
||||||
|
} catch (e) {
|
||||||
|
// Offline or server down: the page stays usable, just without the clock.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
adopt: function (run) {
|
||||||
|
this.run = run;
|
||||||
|
this.seconds = run ? run.seconds_to_next_measurement : null;
|
||||||
|
if (!run || run.status !== 'running') {
|
||||||
|
// Closed or stopped: nothing is due, and an announcement left on screen
|
||||||
|
// would be telling the operator to go and measure a stopped line.
|
||||||
|
this.announced = false;
|
||||||
|
this.redirectIn = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Arriving on a page that is already late is the same event as going late
|
||||||
|
// while sitting on it.
|
||||||
|
if (this.isDue) this.announce();
|
||||||
|
},
|
||||||
|
|
||||||
|
get running() { return !!this.run && this.run.status === 'running'; },
|
||||||
|
get paused() { return !!this.run && this.run.status === 'paused'; },
|
||||||
|
get visible() {
|
||||||
|
return !!this.run && ProductionClock.hasClock(this.seconds);
|
||||||
|
},
|
||||||
|
get isDue() { return this.running && ProductionClock.isOverdue(this.seconds); },
|
||||||
|
get display() { return ProductionClock.format(this.seconds); },
|
||||||
|
get measurementUrl() {
|
||||||
|
return ProductionClock.measurementUrl(this.run, opts.executeUrl || '');
|
||||||
|
},
|
||||||
|
|
||||||
|
onSecond: function () {
|
||||||
|
var wasAnnounced = this.announced;
|
||||||
|
|
||||||
|
if (this.running && ProductionClock.hasClock(this.seconds)) {
|
||||||
|
// Past zero it keeps counting, into the negative: that is the figure the
|
||||||
|
// operator reads as "how long I am late by".
|
||||||
|
this.seconds--;
|
||||||
|
if (this.seconds <= 0) this.announce();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasAnnounced && this.redirectIn > 0) {
|
||||||
|
this.redirectIn--;
|
||||||
|
if (this.redirectIn === 0) this.goToMeasurement();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
announce: function () {
|
||||||
|
if (this.announced) return;
|
||||||
|
this.announced = true;
|
||||||
|
ProductionClock.playBuzzer();
|
||||||
|
// Without a measurement task to go to there is nothing to announce beyond
|
||||||
|
// the banner going red, and a redirect to nowhere would be a broken link.
|
||||||
|
this.redirectIn = this.measurementUrl ? ANNOUNCE_SECONDS : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
goToMeasurement: function () {
|
||||||
|
var url = this.measurementUrl;
|
||||||
|
if (url) global.location.href = url;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Task descriptions: line breaks and bold, and nothing else.
|
||||||
|
*
|
||||||
|
* Whoever writes a recipe pastes from the PDF of the technical sheet, and the text
|
||||||
|
* used to arrive flattened - every line run into the next, every emphasis lost, to
|
||||||
|
* be put back by hand each time. Two conventions carry it now: a newline is a line
|
||||||
|
* break, **like this** is bold.
|
||||||
|
*
|
||||||
|
* No HTML is stored or trusted. The text is escaped first and the only tags in the
|
||||||
|
* result are the ones produced here, so a description that contains <script> shows
|
||||||
|
* those characters and does nothing. The server-side filter (rich_text in app.py)
|
||||||
|
* follows exactly these rules, so a description reads the same whether the page
|
||||||
|
* was rendered by Flask or by Alpine.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Escaped text, then the two tags we add ourselves. Order matters: escaping
|
||||||
|
after the substitution would turn our own <strong> into visible text. */
|
||||||
|
function richText(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
return escapeHtml(value)
|
||||||
|
.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrap whatever is selected in a textarea in ** **, the way a B button does.
|
||||||
|
Dispatching 'input' is what keeps x-model in step: assigning to value alone
|
||||||
|
changes the DOM and leaves Alpine with the old string. */
|
||||||
|
function wrapSelectionBold(el) {
|
||||||
|
if (!el) return;
|
||||||
|
var start = el.selectionStart;
|
||||||
|
var end = el.selectionEnd;
|
||||||
|
var text = el.value || '';
|
||||||
|
el.value = text.slice(0, start) + '**' + text.slice(start, end) + '**' + text.slice(end);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.focus();
|
||||||
|
// Leave the cursor around the same words, now inside the markers.
|
||||||
|
el.selectionStart = start + 2;
|
||||||
|
el.selectionEnd = end + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.richText = richText;
|
||||||
|
global.wrapSelectionBold = wrapSelectionBold;
|
||||||
|
})(window);
|
||||||
+43
-1
@@ -64,7 +64,49 @@ db5ff4db83e580426280e9337a58dc57d3a83784a1b03ad80914651594441d52 fonts/jetbrain
|
|||||||
```
|
```
|
||||||
|
|
||||||
Verifica: `sha256sum -c` da questa cartella, oppure
|
Verifica: `sha256sum -c` da questa cartella, oppure
|
||||||
`sha256sum *.js fonts/*.woff2`.
|
`sha256sum *.js fonts/*.woff2`. Il confronto è anche un test
|
||||||
|
(`tests/test_offline.py::test_the_shipped_libraries_are_the_ones_that_were_checked`):
|
||||||
|
una libreria sostituita sul posto tiene lo stesso nome e la stessa riga in tabella,
|
||||||
|
e l'impronta è l'unica parte che se ne accorge.
|
||||||
|
|
||||||
|
## La regola che tiene il punto chiuso
|
||||||
|
|
||||||
|
Le copie locali risolvono il problema una volta; quello che lo tiene risolto è la
|
||||||
|
**Content-Security-Policy** servita dal client Flask (`app.py`, `CSP`), che ammette
|
||||||
|
solo l'origine locale. Prima esisteva solo sul backend — cioè sulle risposte API,
|
||||||
|
non sulle pagine che il browser disegna: la regola stava scritta dove non poteva
|
||||||
|
essere infranta e mancava dove poteva.
|
||||||
|
|
||||||
|
Con la policy in vigore, un `<script src="https://cdn…">` aggiunto fra sei mesi viene
|
||||||
|
rifiutato dal browser **alla scrivania**, dove c'è la rete e l'errore si legge in
|
||||||
|
console, invece che in reparto dove la rete non c'è.
|
||||||
|
|
||||||
|
## Tailwind
|
||||||
|
|
||||||
|
Tailwind non sta in questa cartella: è compilato in fase di build dell'immagine
|
||||||
|
(`Dockerfile.frontend`) e il CSS finito viene servito da `static/css/tailwind.css`.
|
||||||
|
Vale però lo stesso vincolo — la versione è fissata a `tailwindcss@3.4.19`. Con
|
||||||
|
`tailwindcss@3` ogni ricostruzione poteva prendere una minor diversa e produrre un
|
||||||
|
foglio di stile diverso senza che nessuno l'avesse validato.
|
||||||
|
|
||||||
|
Nota per l'installazione on-premise: la **costruzione** dell'immagine richiede rete
|
||||||
|
(npm, apt, uv). È l'esecuzione a non richiederla. In reparto va portata l'immagine
|
||||||
|
già costruita, non il repository da compilare sul posto.
|
||||||
|
|
||||||
|
## Una sesta libreria, oggi non caricata
|
||||||
|
|
||||||
|
`static/js/barcode.js` e `templates/components/barcode_scanner.html` implementano lo
|
||||||
|
scanner QR **con la fotocamera** e si appoggiano a `html5-qrcode`, che non è mai
|
||||||
|
caricata da nessuna pagina: il componente non è incluso da nessuna parte e la
|
||||||
|
libreria non è in questa cartella. A runtime non rompe niente — è codice morto.
|
||||||
|
|
||||||
|
La lettura del barcode che l'operatore usa davvero è un campo di testo in
|
||||||
|
`measure/select_recipe.html`: il lettore hardware la digita, e non serve nessuna
|
||||||
|
libreria.
|
||||||
|
|
||||||
|
Se un giorno si accende lo scanner da fotocamera, `html5-qrcode` va portata qui
|
||||||
|
**prima**, altrimenti in reparto quel pulsante mostra «scanner_lib_not_loaded» e
|
||||||
|
basta.
|
||||||
|
|
||||||
## Aggiornare una libreria
|
## Aggiornare una libreria
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
<div class="tmf-page tmf-page-narrow"
|
||||||
x-data="systemSettings()">
|
x-data="systemSettings()">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
window.__allRecipes = {{ all_recipes|tojson }};
|
window.__allRecipes = {{ all_recipes|tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
<div class="tmf-page"
|
||||||
x-data="stationManagement(window.__stations, window.__allRecipes)">
|
x-data="stationManagement(window.__stations, window.__allRecipes)">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<script>window.__users = {{ users|tojson }};</script>
|
<script>window.__users = {{ users|tojson }};</script>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
<div class="tmf-page"
|
||||||
x-data="userManagement(window.__users)">
|
x-data="userManagement(window.__users)">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %}
|
{% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
|
<div class="tmf-page tmf-page-narrow">
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
|
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
|
||||||
|
{#
|
||||||
|
allow_manual_input comes from the recipe. When it is false the digits are not
|
||||||
|
drawn at all - not hidden with CSS: they are the only unambiguously typed way
|
||||||
|
in, and markup that is merely hidden is still markup that can be shown again.
|
||||||
|
|
||||||
|
The keyboard listener stays either way: that is how the USB caliper arrives, and
|
||||||
|
the component still checks at confirm time, because a wedge and a keyboard are
|
||||||
|
the same device as far as the browser is concerned.
|
||||||
|
#}
|
||||||
|
{% set allow_manual = allow_manual_input if allow_manual_input is defined else true %}
|
||||||
<div
|
<div
|
||||||
x-data="numpad()"
|
x-data="numpad({ allowManual: {{ allow_manual|tojson }} })"
|
||||||
@keydown.window="handleKeydown($event)"
|
@keydown.window="handleKeydown($event)"
|
||||||
class="numpad-container w-full max-w-sm mx-auto"
|
class="numpad-container w-full max-w-sm mx-auto"
|
||||||
>
|
>
|
||||||
@@ -18,6 +28,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not allow_manual %}
|
||||||
|
<!-- Caliper only: no digits, just the corrections and the confirm -->
|
||||||
|
<div>
|
||||||
|
<div class="mb-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg
|
||||||
|
bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
|
||||||
|
<svg class="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-medium text-primary-700 dark:text-primary-300">
|
||||||
|
{{ _('Misura solo da calibro: questa ricetta non ammette valori digitati') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<button @click="backspace()" type="button"
|
||||||
|
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
>⌫</button>
|
||||||
|
<button @click="clearAll()" type="button"
|
||||||
|
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-measure-fail hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
>C</button>
|
||||||
|
<button @click="confirm()" type="button" :disabled="!hasValue"
|
||||||
|
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
||||||
|
>✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
<!-- Keypad Grid 4x4 -->
|
<!-- Keypad Grid 4x4 -->
|
||||||
<div class="grid grid-cols-4 gap-2">
|
<div class="grid grid-cols-4 gap-2">
|
||||||
<!-- Row 1: 7 8 9 ⌫ -->
|
<!-- Row 1: 7 8 9 ⌫ -->
|
||||||
@@ -118,4 +154,5 @@
|
|||||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
||||||
>✓</button>
|
>✓</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{#
|
||||||
|
The measurement clock, for every screen that is not the measurement itself.
|
||||||
|
|
||||||
|
Two requirements of 28/07 are in here. The countdown is always on screen while a
|
||||||
|
production is open, and past zero it turns round and shows how long the line has
|
||||||
|
been over the interval - in red, because a late measurement that looks like an
|
||||||
|
early one is how it stays late. And when the interval expires the measurement
|
||||||
|
comes back on its own: the operator is told, and taken there.
|
||||||
|
|
||||||
|
The measurement screen has its own component and does not include this: being
|
||||||
|
already on the measurement is the one place there is nothing to announce.
|
||||||
|
#}
|
||||||
|
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||||
|
|
||||||
|
<div x-data="productionClock({
|
||||||
|
currentUrl: '{{ url_for('measure.api_current_production') }}',
|
||||||
|
executeUrl: '{{ url_for('measure.task_execute', task_id=0) }}'
|
||||||
|
})"
|
||||||
|
x-init="init()"
|
||||||
|
x-cloak>
|
||||||
|
|
||||||
|
{# ---- Countdown bar: sticky, so scrolling never hides it ---- #}
|
||||||
|
<div x-show="visible"
|
||||||
|
x-transition
|
||||||
|
class="sticky top-0 z-30 -mx-4 sm:-mx-6 lg:-mx-8 mb-4 px-4 py-2 border-b shadow-sm"
|
||||||
|
:class="isDue
|
||||||
|
? 'bg-red-50 dark:bg-red-900/30 border-red-400 dark:border-red-700'
|
||||||
|
: paused
|
||||||
|
? 'bg-amber-100 dark:bg-amber-900/40 border-amber-500'
|
||||||
|
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'">
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||||
|
|
||||||
|
<svg class="w-5 h-5 shrink-0"
|
||||||
|
:class="isDue ? 'text-red-600 animate-pulse' : 'text-amber-600'"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{# In time #}
|
||||||
|
<template x-if="!isDue && !paused">
|
||||||
|
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
{{ _('Prossima misurazione tra') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# Over the interval: the delay is the headline, not a footnote #}
|
||||||
|
<template x-if="isDue">
|
||||||
|
<span class="text-sm font-bold text-red-800 dark:text-red-200">
|
||||||
|
{{ _('Misurazione in ritardo di') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# Line stopped: the figure is frozen, and says so. Frozen past the interval
|
||||||
|
it is a delay held in place, not a wait - the wording has to say which. #}
|
||||||
|
<template x-if="paused && seconds > 0">
|
||||||
|
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||||
|
{{ _('Linea ferma — conto alla rovescia congelato a') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="paused && seconds <= 0">
|
||||||
|
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||||
|
{{ _('Linea ferma') }} — {{ _('Misurazione in ritardo di') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span class="text-lg font-bold font-mono px-2 py-0.5 rounded"
|
||||||
|
:class="isDue
|
||||||
|
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900/40'
|
||||||
|
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40'"
|
||||||
|
x-text="display"></span>
|
||||||
|
|
||||||
|
<span class="text-xs"
|
||||||
|
:class="isDue ? 'text-red-600 dark:text-red-300' : 'text-amber-600 dark:text-amber-400'">
|
||||||
|
({{ _('Ciclo') }} #<span x-text="run ? run.cycle_count : 0"></span>)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Time to measure: announced, then taken there ---- #}
|
||||||
|
<div x-show="announced && redirectIn > 0"
|
||||||
|
x-transition
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center border-2 border-red-500">
|
||||||
|
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
||||||
|
<svg class="w-8 h-8 text-red-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">
|
||||||
|
{{ _('È ora di misurare') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] mb-5">
|
||||||
|
{{ _('Ritorno alla misurazione tra') }}
|
||||||
|
<span class="font-bold font-mono" x-text="redirectIn"></span> s
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button @click="goToMeasurement()"
|
||||||
|
class="btn w-full justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-bold py-2.5">
|
||||||
|
{{ _('Vai alla misura') }}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{#
|
||||||
|
The whole formatting toolbar a task description gets: bold, plus the reminder
|
||||||
|
that a line break is a line break.
|
||||||
|
|
||||||
|
Whoever writes a recipe pastes from the PDF of the technical sheet; the text used
|
||||||
|
to arrive flattened and had to be put back by hand every time. `target` is the
|
||||||
|
x-ref of the textarea this toolbar acts on.
|
||||||
|
#}
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button type="button"
|
||||||
|
@click="wrapSelectionBold($refs.{{ target }})"
|
||||||
|
title="{{ _('Grassetto: racchiude il testo selezionato fra **') }}"
|
||||||
|
class="px-2 py-0.5 rounded border border-[var(--border-color)]
|
||||||
|
text-sm font-bold leading-none
|
||||||
|
text-[var(--text-secondary)] hover:text-primary hover:border-primary
|
||||||
|
transition-colors">
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
<span class="text-[11px] text-[var(--text-muted)]">
|
||||||
|
{{ _('**grassetto** · a capo = a capo') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %}
|
{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
<div class="tmf-page tmf-page-narrow">
|
||||||
<div class="mt-20 p-8 bg-[var(--bg-card)] rounded-xl shadow-lg text-center border border-red-200 dark:border-red-800">
|
<div class="mt-20 p-8 bg-[var(--bg-card)] rounded-xl shadow-lg text-center border border-red-200 dark:border-red-800">
|
||||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||||
bg-red-50 dark:bg-red-900/30 mb-6">
|
bg-red-50 dark:bg-red-900/30 mb-6">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{% set current_version = recipe.current_version if recipe and recipe.current_version else None %}
|
{% set current_version = recipe.current_version if recipe and recipe.current_version else None %}
|
||||||
{% set versions = recipe.versions if recipe and recipe.versions else [] %}
|
{% set versions = recipe.versions if recipe and recipe.versions else [] %}
|
||||||
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
|
<div class="tmf-page tmf-page-narrow"
|
||||||
x-data="recipeEditor()"
|
x-data="recipeEditor()"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
|
|
||||||
@@ -248,6 +248,48 @@
|
|||||||
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Regole di misura: cosa la ricetta pretende dall'operatore -->
|
||||||
|
<div class="sm:col-span-2 pt-2 border-t border-[var(--border-color)]">
|
||||||
|
<p class="tmf-label mb-2">{{ _('Regole di misura') }}</p>
|
||||||
|
<div class="grid sm:grid-cols-3 gap-3">
|
||||||
|
|
||||||
|
<label class="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="requiresLot"
|
||||||
|
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||||
|
<span>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">{{ _('Lotto obbligatorio') }}</span>
|
||||||
|
<span class="block text-xs text-[var(--text-muted)]">
|
||||||
|
{{ _('Senza lotto la produzione non parte') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="requiresSerial"
|
||||||
|
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||||
|
<span>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">{{ _('Seriale obbligatorio') }}</span>
|
||||||
|
<span class="block text-xs text-[var(--text-muted)]">
|
||||||
|
{{ _('Senza seriale la produzione non parte') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{# Off by default: the caliper is the instrument, and a value that
|
||||||
|
happens to be in tolerance must not be enterable by hand. #}
|
||||||
|
<label class="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="allowManualInput"
|
||||||
|
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||||
|
<span>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">{{ _('Consenti inserimento manuale') }}</span>
|
||||||
|
<span class="block text-xs text-[var(--text-muted)]">
|
||||||
|
{{ _('Se disattivo si misura solo col calibro') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -486,6 +528,13 @@ function recipeEditor() {
|
|||||||
// ---- Measurement interval ----
|
// ---- Measurement interval ----
|
||||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||||
|
|
||||||
|
// ---- Rules of measurement (points 8 and 9) ----
|
||||||
|
// A new recipe forbids typing: the caliper is the instrument, and permission
|
||||||
|
// to type is what has to be given, not taken away.
|
||||||
|
requiresLot: {{ (recipe.requires_lot|default(false) if recipe else false)|tojson }},
|
||||||
|
requiresSerial: {{ (recipe.requires_serial|default(false) if recipe else false)|tojson }},
|
||||||
|
allowManualInput: {{ (recipe.allow_manual_input|default(false) if recipe else false)|tojson }},
|
||||||
|
|
||||||
// ---- File upload (preview image) ----
|
// ---- File upload (preview image) ----
|
||||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||||
uploadingFile: false,
|
uploadingFile: false,
|
||||||
@@ -512,6 +561,12 @@ function recipeEditor() {
|
|||||||
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rules of measurement. Sent every time, including when false: these are the
|
||||||
|
// settings an operator gets told about, so turning one off has to travel.
|
||||||
|
payload.requires_lot = !!this.requiresLot;
|
||||||
|
payload.requires_serial = !!this.requiresSerial;
|
||||||
|
payload.allow_manual_input = !!this.allowManualInput;
|
||||||
|
|
||||||
// Include image_path for preview thumbnail
|
// Include image_path for preview thumbnail
|
||||||
if (this.currentFilePath) {
|
if (this.currentFilePath) {
|
||||||
payload.image_path = this.currentFilePath;
|
payload.image_path = this.currentFilePath;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<script>window.__recipeListData = {{ recipes|tojson }};</script>
|
<script>window.__recipeListData = {{ recipes|tojson }};</script>
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl"
|
<div class="tmf-page"
|
||||||
x-data="{
|
x-data="{
|
||||||
recipes: window.__recipeListData,
|
recipes: window.__recipeListData,
|
||||||
search: '{{ search or '' }}',
|
search: '{{ search or '' }}',
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</script>
|
</script>
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
|
<div class="tmf-page"
|
||||||
x-data="recipePreview()"
|
x-data="recipePreview()"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
<h2 class="font-semibold text-[var(--text-primary)]">{{ task.title }}</h2>
|
<h2 class="font-semibold text-[var(--text-primary)]">{{ task.title }}</h2>
|
||||||
{% if task.directive or task.description %}
|
{% if task.directive or task.description %}
|
||||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||||
{{ task.directive or task.description }}
|
{{ (task.directive or task.description)|rich_text }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
connectionError: {{ _("Errore di connessione al server")|tojson }}
|
connectionError: {{ _("Errore di connessione al server")|tojson }}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
|
<div class="tmf-page"
|
||||||
x-data="taskDrawing()"
|
x-data="taskDrawing()"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
|
<div class="tmf-page"
|
||||||
x-data="taskEditor()"
|
x-data="taskEditor()"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
|
|
||||||
@@ -270,6 +270,18 @@
|
|||||||
class="tmf-input"
|
class="tmf-input"
|
||||||
placeholder="{{ _('Es. Controllo dimensionale flangia') }}">
|
placeholder="{{ _('Es. Controllo dimensionale flangia') }}">
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
|
||||||
|
<select x-model="newTask.task_type" class="tmf-input">
|
||||||
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<!-- Direttiva -->
|
<!-- Direttiva -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Direttiva') }}</label>
|
<label class="tmf-label">{{ _('Direttiva') }}</label>
|
||||||
@@ -280,8 +292,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Descrizione -->
|
<!-- Descrizione -->
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||||
|
{% with target = 'newTaskDescription' %}
|
||||||
|
{% include "components/rich_text_toolbar.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
<textarea x-model="newTask.description"
|
<textarea x-model="newTask.description"
|
||||||
|
x-ref="newTaskDescription"
|
||||||
class="tmf-input text-sm"
|
class="tmf-input text-sm"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
@@ -507,7 +525,10 @@
|
|||||||
<template x-if="task.description">
|
<template x-if="task.description">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
||||||
<span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
|
{# Shown the way the operator will see it - the point of the
|
||||||
|
formatting is that the maker can check it here. #}
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]"
|
||||||
|
x-html="richText(task.description)"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,6 +537,14 @@
|
|||||||
<!-- Edit Task Extra Fields (when in edit mode) -->
|
<!-- Edit Task Extra Fields (when in edit mode) -->
|
||||||
<template x-if="editingTask === task.id">
|
<template x-if="editingTask === task.id">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label">{{ _('Tipo') }}</label>
|
||||||
|
<select x-model="editTaskData.task_type" class="tmf-input text-sm">
|
||||||
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Direttiva') }}</label>
|
<label class="tmf-label">{{ _('Direttiva') }}</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
@@ -524,8 +553,14 @@
|
|||||||
placeholder="{{ _('Direttiva opzionale...') }}">
|
placeholder="{{ _('Direttiva opzionale...') }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||||
|
{% with target = 'editTaskDescription' %}
|
||||||
|
{% include "components/rich_text_toolbar.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
<textarea x-model="editTaskData.description"
|
<textarea x-model="editTaskData.description"
|
||||||
|
x-ref="editTaskDescription"
|
||||||
class="tmf-input text-sm"
|
class="tmf-input text-sm"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
@@ -1056,6 +1091,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||||
|
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
@@ -1084,8 +1121,8 @@ function taskEditor() {
|
|||||||
|
|
||||||
// ---- Task CRUD state ----
|
// ---- Task CRUD state ----
|
||||||
editingTask: null,
|
editingTask: null,
|
||||||
editTaskData: { title: '', directive: '', description: '' },
|
editTaskData: { title: '', directive: '', description: '', task_type: 'note' },
|
||||||
newTask: { title: '', directive: '', description: '' },
|
newTask: { title: '', directive: '', description: '', task_type: 'note' },
|
||||||
|
|
||||||
// ---- Subtask CRUD state ----
|
// ---- Subtask CRUD state ----
|
||||||
editingSubtaskId: null,
|
editingSubtaskId: null,
|
||||||
@@ -1207,7 +1244,7 @@ function taskEditor() {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
resetNewTask() {
|
resetNewTask() {
|
||||||
this.newTask = { title: '', directive: '', description: '' };
|
this.newTask = { title: '', directive: '', description: '', task_type: 'note' };
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1298,6 +1335,7 @@ function taskEditor() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: this.newTask.title.trim(),
|
title: this.newTask.title.trim(),
|
||||||
|
task_type: this.newTask.task_type || 'note',
|
||||||
directive: this.newTask.directive.trim() || null,
|
directive: this.newTask.directive.trim() || null,
|
||||||
description: this.newTask.description.trim() || null
|
description: this.newTask.description.trim() || null
|
||||||
})
|
})
|
||||||
@@ -1328,6 +1366,7 @@ function taskEditor() {
|
|||||||
this.editingTask = task.id;
|
this.editingTask = task.id;
|
||||||
this.editTaskData = {
|
this.editTaskData = {
|
||||||
title: task.title,
|
title: task.title,
|
||||||
|
task_type: task.task_type || 'note',
|
||||||
directive: task.directive || '',
|
directive: task.directive || '',
|
||||||
description: task.description || ''
|
description: task.description || ''
|
||||||
};
|
};
|
||||||
@@ -1337,7 +1376,7 @@ function taskEditor() {
|
|||||||
|
|
||||||
cancelEditTask() {
|
cancelEditTask() {
|
||||||
this.editingTask = null;
|
this.editingTask = null;
|
||||||
this.editTaskData = { title: '', directive: '', description: '' };
|
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note' };
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateTask(taskId) {
|
async updateTask(taskId) {
|
||||||
@@ -1354,6 +1393,7 @@ function taskEditor() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: this.editTaskData.title.trim(),
|
title: this.editTaskData.title.trim(),
|
||||||
|
task_type: this.editTaskData.task_type || 'note',
|
||||||
directive: this.editTaskData.directive.trim() || null,
|
directive: this.editTaskData.directive.trim() || null,
|
||||||
description: this.editTaskData.description.trim() || null
|
description: this.editTaskData.description.trim() || null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
|
<div class="tmf-page tmf-page-narrow"
|
||||||
x-data="versionHistory()"
|
x-data="versionHistory()"
|
||||||
x-init="loadMeasurementCounts()"
|
x-init="loadMeasurementCounts()"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<script>window.__selectRecipeData = {{ recipes|tojson }};</script>
|
<script>window.__selectRecipeData = {{ recipes|tojson }};</script>
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
|
<div class="tmf-page"
|
||||||
x-data="{
|
x-data="{
|
||||||
recipes: window.__selectRecipeData,
|
recipes: window.__selectRecipeData,
|
||||||
search: '{{ auto_recipe_code }}',
|
search: '{{ auto_recipe_code }}',
|
||||||
@@ -22,8 +22,20 @@
|
|||||||
(r.description || '').toLowerCase().includes(q)
|
(r.description || '').toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
/* Which compulsory traceability fields this recipe is still missing.
|
||||||
|
Empty means the operator can start. The server refuses the same thing
|
||||||
|
when the production opens - this is only the earlier answer. */
|
||||||
|
missingTraceability(recipe) {
|
||||||
|
const missing = [];
|
||||||
|
if (recipe.requires_lot && !this.lot_number.trim()) missing.push('{{ _('lotto') }}');
|
||||||
|
if (recipe.requires_serial && !this.serial_number.trim()) missing.push('{{ _('seriale') }}');
|
||||||
|
return missing;
|
||||||
|
},
|
||||||
|
/* Straight into the first task. Choosing a recipe used to open the full
|
||||||
|
task list, which asked the operator to decide where to begin before they
|
||||||
|
had done anything; the list is still one tap away from inside the task. */
|
||||||
buildTaskUrl(recipeId) {
|
buildTaskUrl(recipeId) {
|
||||||
let url = '/measure/tasks/' + recipeId + '?';
|
let url = '/measure/start/' + recipeId + '?';
|
||||||
const params = [];
|
const params = [];
|
||||||
if (this.lot_number) params.push('lot_number=' + encodeURIComponent(this.lot_number));
|
if (this.lot_number) params.push('lot_number=' + encodeURIComponent(this.lot_number));
|
||||||
if (this.serial_number) params.push('serial_number=' + encodeURIComponent(this.serial_number));
|
if (this.serial_number) params.push('serial_number=' + encodeURIComponent(this.serial_number));
|
||||||
@@ -47,6 +59,14 @@
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}';
|
this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}';
|
||||||
} else {
|
} else {
|
||||||
|
// The scanner is a way in like any other: a recipe that demands a lot
|
||||||
|
// demands it here too, or the rule would last exactly as long as it
|
||||||
|
// took someone to reach for the barcode reader.
|
||||||
|
const missing = this.missingTraceability(data);
|
||||||
|
if (missing.length) {
|
||||||
|
this.barcodeError = '{{ _("Compila prima:") }} ' + missing.join(', ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.barcodeModal = false;
|
this.barcodeModal = false;
|
||||||
this.barcodeInput = '';
|
this.barcodeInput = '';
|
||||||
window.location.href = this.buildTaskUrl(data.id);
|
window.location.href = this.buildTaskUrl(data.id);
|
||||||
@@ -59,6 +79,10 @@
|
|||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
|
|
||||||
|
{# A production open at this station keeps its clock on screen even here: the
|
||||||
|
interval runs whether or not the operator is looking at the measurement. #}
|
||||||
|
{% include "components/production_clock.html" %}
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||||
@@ -148,7 +172,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="lot_number"
|
x-model="lot_number"
|
||||||
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
placeholder="{{ _('Es. LOT-2026-001') }}"
|
||||||
class="tmf-input font-mono">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,7 +186,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="serial_number"
|
x-model="serial_number"
|
||||||
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
placeholder="{{ _('Es. SN-000123') }}"
|
||||||
class="tmf-input font-mono">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,16 +268,38 @@
|
|||||||
<!-- Card Footer -->
|
<!-- Card Footer -->
|
||||||
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
|
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
|
||||||
rounded-b-xl">
|
rounded-b-xl">
|
||||||
<a :href="buildTaskUrl(recipe.id)"
|
|
||||||
|
{# What this recipe insists on before anyone measures. Said on the card
|
||||||
|
rather than after the fact: a measurement taken without its lot cannot
|
||||||
|
be traced back, and finding that out later is finding it out too late. #}
|
||||||
|
<template x-if="missingTraceability(recipe).length">
|
||||||
|
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||||
|
</svg>
|
||||||
|
<span x-text="'{{ _('Compila prima:') }} ' + missingTraceability(recipe).join(', ')"></span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)"
|
||||||
|
:aria-disabled="missingTraceability(recipe).length > 0"
|
||||||
|
:class="missingTraceability(recipe).length
|
||||||
|
? 'opacity-40 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'group-hover:shadow-md'"
|
||||||
class="btn btn-primary w-full justify-center text-sm font-semibold
|
class="btn btn-primary w-full justify-center text-sm font-semibold
|
||||||
group-hover:shadow-md transition-shadow duration-200">
|
transition-shadow duration-200">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||||
</svg>
|
|
||||||
{{ _('Seleziona') }}
|
|
||||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
{{ _('AVVIA IN SEQUENZA') }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# The whole list, for when the operator wants to see it or go back to a
|
||||||
|
task left unfinished. Deliberately the quiet one of the two. #}
|
||||||
|
<a :href="'/measure/tasks/' + recipe.id"
|
||||||
|
class="mt-2 block text-center text-xs text-[var(--text-secondary)]
|
||||||
|
hover:text-primary transition-colors">
|
||||||
|
{{ _('Vedi tutti i task') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
{% block title %}{{ _('Riepilogo') }} - {{ recipe.name }}{% endblock %}
|
{% block title %}{{ _('Riepilogo') }} - {{ recipe.name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div class="tmf-page">
|
||||||
|
{# The summary is where an operator lingers: the clock has to be here as well. #}
|
||||||
|
{% include "components/production_clock.html" %}
|
||||||
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav class="mb-6" aria-label="Breadcrumb">
|
<nav class="mb-6" aria-label="Breadcrumb">
|
||||||
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@
|
|||||||
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
|
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
|
<div class="tmf-page">
|
||||||
|
|
||||||
|
{# The clock follows the operator here too, and brings them back when it expires. #}
|
||||||
|
{% include "components/production_clock.html" %}
|
||||||
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav class="mb-6" aria-label="Breadcrumb">
|
<nav class="mb-6" aria-label="Breadcrumb">
|
||||||
@@ -72,18 +75,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: AVVIA + Traceability -->
|
<!-- Right: AVVIA + Traceability -->
|
||||||
|
{#
|
||||||
|
Avvia does not start while the recipe is still owed its traceability.
|
||||||
|
The selection screen asks for it first, but this page can be reached
|
||||||
|
directly - by a bookmark, or by going back - and the rule has to hold
|
||||||
|
on whichever door the operator comes through.
|
||||||
|
#}
|
||||||
|
{% set needs_lot = recipe.requires_lot|default(false) and not lot_number %}
|
||||||
|
{% set needs_serial = recipe.requires_serial|default(false) and not serial_number %}
|
||||||
|
{% set missing_trace = ([_('lotto')] if needs_lot else [])
|
||||||
|
+ ([_('seriale')] if needs_serial else []) %}
|
||||||
<div class="flex flex-col gap-3 sm:items-end shrink-0">
|
<div class="flex flex-col gap-3 sm:items-end shrink-0">
|
||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
|
{% if missing_trace %}
|
||||||
|
<a href="{{ url_for('measure.select_recipe', recipe=recipe.code) }}"
|
||||||
|
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||||
|
bg-steel-400 text-white px-8 py-3 rounded-xl cursor-pointer">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Compila prima:') }} {{ missing_trace|join(', ') }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('measure.start_sequence', recipe_id=recipe.id) }}"
|
||||||
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||||
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
|
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
|
||||||
px-8 py-3 rounded-xl transition-all duration-200">
|
px-8 py-3 rounded-xl transition-all duration-200">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('AVVIA') }}
|
{{ _('AVVIA IN SEQUENZA') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if lot_number %}
|
{% if lot_number %}
|
||||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||||
@@ -138,7 +162,12 @@
|
|||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div class="space-y-2.5">
|
<div class="space-y-2.5">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
{# How far this task got. A task walked away from halfway through used to look
|
||||||
|
exactly like one never opened; this is what tells them apart. #}
|
||||||
|
{% set prog = (progress|default({})).get(task.id) %}
|
||||||
|
<div class="tmf-card transition-all duration-200 group
|
||||||
|
{% if prog and prog.state == 'partial' %}border-amber-300 dark:border-amber-700
|
||||||
|
{% else %}hover:border-primary/30{% endif %}">
|
||||||
<div class="p-3 sm:p-4">
|
<div class="p-3 sm:p-4">
|
||||||
<div class="flex flex-row items-center gap-3">
|
<div class="flex flex-row items-center gap-3">
|
||||||
|
|
||||||
@@ -159,14 +188,52 @@
|
|||||||
<!-- Directive -->
|
<!-- Directive -->
|
||||||
{% if task.directive or task.description %}
|
{% if task.directive or task.description %}
|
||||||
<p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
|
<p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
|
||||||
{{ task.directive or task.description }}
|
{{ (task.directive or task.description)|rich_text }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Indicators (top-right): measurements + attachment -->
|
<!-- Indicators (top-right): type + measurements + attachment -->
|
||||||
<div class="shrink-0 self-start flex items-center gap-2.5">
|
<div class="shrink-0 self-start flex items-center gap-2.5">
|
||||||
{% if task.subtask_count is defined or task.subtasks %}
|
{# The declared type, so the operator sees what a task is before opening
|
||||||
|
it - and a measurement task still reads as one before its quotes are in. #}
|
||||||
|
{% set task_type_labels = {
|
||||||
|
'measure': _('Misura'),
|
||||||
|
'drawing': _('Disegno'),
|
||||||
|
'note': _('Nota'),
|
||||||
|
'dxf_compare': _('Confronto profilo'),
|
||||||
|
'camera_measure': _('Misura camera'),
|
||||||
|
} %}
|
||||||
|
{% set is_measure = task.task_type in ('measure', 'camera_measure') %}
|
||||||
|
{% if task.task_type %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium
|
||||||
|
{% if is_measure %}bg-primary-50 dark:bg-primary-900/20 text-primary
|
||||||
|
{% else %}bg-steel-100 dark:bg-steel-800 text-steel-600 dark:text-steel-300{% endif %}">
|
||||||
|
{{ task_type_labels.get(task.task_type, task.task_type) }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prog and prog.state in ('partial', 'complete') %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold
|
||||||
|
{% if prog.state == 'complete' %}
|
||||||
|
bg-measure-pass/10 text-measure-pass
|
||||||
|
{% else %}
|
||||||
|
bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300
|
||||||
|
{% endif %}">
|
||||||
|
{% if prog.state == 'complete' %}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Completato') }}
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l2.5 2.5M12 21a9 9 0 110-18 9 9 0 010 18z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Incompiuto') }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="font-mono">{{ prog.measured }}/{{ prog.quotes }}</span>
|
||||||
|
</span>
|
||||||
|
{% elif task.subtask_count is defined or task.subtasks %}
|
||||||
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
|
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
|
||||||
title="{{ _('misurazioni') }}">
|
title="{{ _('misurazioni') }}">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
@@ -191,23 +258,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Button (Maker only — operators start the guided flow with AVVIA) -->
|
{#
|
||||||
{% if current_user and 'Maker' in current_user.get('roles', []) %}
|
Opening one task on its own. The sequence is the normal way through the
|
||||||
|
recipe - this is the way back to a task left unfinished, which is why the
|
||||||
|
operator has it now and not only the Maker: a list that says "incompiuto"
|
||||||
|
and gives you no way there would be a complaint, not a feature.
|
||||||
|
#}
|
||||||
<div class="shrink-0 sm:ml-4">
|
<div class="shrink-0 sm:ml-4">
|
||||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
class="btn btn-secondary gap-2 w-full sm:w-auto justify-center
|
||||||
group-hover:shadow-md transition-shadow duration-200">
|
group-hover:shadow-md transition-shadow duration-200">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
</svg>
|
|
||||||
{{ _('Visualizza Task') }}
|
|
||||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
{{ _('Visualizza singolo TASK') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-6"
|
<div class="tmf-page"
|
||||||
x-data="spcDashboard()"
|
x-data="spcDashboard()"
|
||||||
x-init="init()">
|
x-init="init()">
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,27 @@ def logged_in_client(flask_app):
|
|||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def api_get_router(routes: dict, default=None):
|
||||||
|
"""A ``get`` side effect that answers by endpoint instead of by turn.
|
||||||
|
|
||||||
|
A list of responses in call order breaks the moment a page asks the server one
|
||||||
|
more question - which is how every page grows. Matching on the endpoint says
|
||||||
|
what a test actually means: this is what /api/recipes/3 replies.
|
||||||
|
|
||||||
|
Keys are matched as substrings of the endpoint, longest first, so
|
||||||
|
``/api/recipes/3/tasks`` wins over ``/api/recipes/3``.
|
||||||
|
"""
|
||||||
|
ordered = sorted(routes.items(), key=lambda kv: -len(kv[0]))
|
||||||
|
|
||||||
|
def _get(endpoint, *args, **kwargs):
|
||||||
|
for prefix, response in ordered:
|
||||||
|
if prefix in endpoint:
|
||||||
|
return response() if callable(response) else response
|
||||||
|
return {} if default is None else default
|
||||||
|
|
||||||
|
return _get
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_api_client():
|
def mock_api_client():
|
||||||
"""Patch the api_client singleton used in blueprints.
|
"""Patch the api_client singleton used in blueprints.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Point 14: the frame must not move when the operator changes view.
|
||||||
|
|
||||||
|
The report was «le dimensioni delle viste cambiano a seconda del menu». On the
|
||||||
|
code it was seven different page widths across seventeen templates, none of them
|
||||||
|
the width of the navbar sitting above them, plus a scrollbar that came and went.
|
||||||
|
|
||||||
|
This test guards the part that is easy to undo: the next page someone writes will
|
||||||
|
copy the shell from a neighbour, and if a neighbour has gone back to declaring its
|
||||||
|
own `container mx-auto ... max-w-5xl` the drift starts again.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
TEMPLATES = Path(__file__).resolve().parents[1] / "templates"
|
||||||
|
|
||||||
|
# Two views own their geometry on purpose, and say why here so that a reader does
|
||||||
|
# not have to guess whether they were forgotten:
|
||||||
|
# login — a full-viewport splash, no navbar to line up with;
|
||||||
|
# task_execute — the measurement screen, a fixed full-height instrument panel
|
||||||
|
# that must not scroll while an operator is measuring.
|
||||||
|
EXEMPT = {"auth/login.html", "measure/task_execute.html"}
|
||||||
|
|
||||||
|
# What a page must not go back to doing: setting its own width and padding.
|
||||||
|
FORBIDDEN = ("container mx-auto", "max-w-2xl mx-auto", "max-w-3xl mx-auto",
|
||||||
|
"max-w-4xl mx-auto", "max-w-5xl mx-auto", "max-w-6xl mx-auto",
|
||||||
|
"max-w-7xl mx-auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _views():
|
||||||
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||||
|
rel = path.relative_to(TEMPLATES).as_posix()
|
||||||
|
if rel.startswith("components/") or rel == "base.html":
|
||||||
|
continue
|
||||||
|
yield rel, path
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_view_uses_the_shared_shell():
|
||||||
|
"""One frame, declared in one place, so it cannot drift per page."""
|
||||||
|
missing = [
|
||||||
|
rel for rel, path in _views()
|
||||||
|
if rel not in EXEMPT and "tmf-page" not in path.read_text(encoding="utf-8")
|
||||||
|
]
|
||||||
|
assert not missing, f"viste senza la cornice condivisa: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rel,path", list(_views()))
|
||||||
|
def test_no_view_declares_its_own_width(rel, path):
|
||||||
|
if rel in EXEMPT:
|
||||||
|
pytest.skip("geometria propria, documentata in EXEMPT")
|
||||||
|
html = path.read_text(encoding="utf-8")
|
||||||
|
found = [f for f in FORBIDDEN if f in html]
|
||||||
|
assert not found, (
|
||||||
|
f"{rel} torna a dichiarare la propria larghezza ({found}): "
|
||||||
|
"usare .tmf-page / .tmf-page-narrow"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shell_matches_the_navbar():
|
||||||
|
"""The content lines up with the bar above it, or the page looks like it has
|
||||||
|
two widths at once - which is what the operator was seeing."""
|
||||||
|
navbar = (TEMPLATES / "components/navbar.html").read_text(encoding="utf-8")
|
||||||
|
assert "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" in navbar, (
|
||||||
|
"la navbar ha cambiato cornice: allineare .tmf-page in themes.css"
|
||||||
|
)
|
||||||
|
css = (
|
||||||
|
TEMPLATES.parent / "static/css/themes.css"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert "max-width: 80rem;" in css # 80rem == max-w-7xl
|
||||||
|
assert "scrollbar-gutter: stable;" in css
|
||||||
@@ -4,6 +4,8 @@ Covers recipe selection, task list, login requirement, and measurement submissio
|
|||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
|
||||||
class TestSelectRecipe:
|
class TestSelectRecipe:
|
||||||
"""GET /measure/select tests."""
|
"""GET /measure/select tests."""
|
||||||
@@ -36,22 +38,206 @@ class TestTaskList:
|
|||||||
|
|
||||||
def test_task_list_renders(self, logged_in_client, mock_api_client):
|
def test_task_list_renders(self, logged_in_client, mock_api_client):
|
||||||
"""Task list page renders with recipe and task data."""
|
"""Task list page renders with recipe and task data."""
|
||||||
# First call: recipe details (dict), second call: tasks list.
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
# The route calls tasks_resp.get("error") so the mock must return
|
"/api/recipes/1/tasks": {
|
||||||
# a dict (not a bare list) to avoid AttributeError.
|
|
||||||
mock_api_client.get.side_effect = [
|
|
||||||
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
|
||||||
{
|
|
||||||
"items": [
|
"items": [
|
||||||
{"id": 1, "title": "Task 1", "order_index": 0},
|
{"id": 1, "title": "Task 1", "order_index": 0},
|
||||||
{"id": 2, "title": "Task 2", "order_index": 1},
|
{"id": 2, "title": "Task 2", "order_index": 1},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||||
|
"/api/production-runs/current": None,
|
||||||
|
})
|
||||||
|
|
||||||
resp = logged_in_client.get("/measure/tasks/1")
|
resp = logged_in_client.get("/measure/tasks/1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_task_list_marks_the_task_left_half_done(
|
||||||
|
self, logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
"""A task with some of its quotes taken says so, and says how many."""
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["language"] = "it"
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/1/tasks": [
|
||||||
|
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
||||||
|
{"id": 8, "title": "Nota", "order_index": 1, "task_type": "note"},
|
||||||
|
],
|
||||||
|
"/api/recipes/1": {
|
||||||
|
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
||||||
|
"current_version": {"id": 42, "version_number": 1},
|
||||||
|
},
|
||||||
|
"/api/production-runs/current": None,
|
||||||
|
"/api/measurements/task-progress": {
|
||||||
|
"tasks": [
|
||||||
|
{"task_id": 7, "quotes": 3, "measured": 1, "state": "partial"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/tasks/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert "Incompiuto" in html
|
||||||
|
assert "1/3" in html
|
||||||
|
|
||||||
|
def test_task_list_survives_a_progress_lookup_that_fails(
|
||||||
|
self, logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
"""Losing the badges must not lose the page."""
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/1/tasks": [
|
||||||
|
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
||||||
|
],
|
||||||
|
"/api/recipes/1": {
|
||||||
|
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
||||||
|
"current_version": {"id": 42},
|
||||||
|
},
|
||||||
|
"/api/production-runs/current": None,
|
||||||
|
"/api/measurements/task-progress": {
|
||||||
|
"error": True, "status_code": 500, "detail": "boom",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/tasks/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Misura" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartSequence:
|
||||||
|
"""GET /measure/start/<recipe_id> — the operator's way in."""
|
||||||
|
|
||||||
|
def test_start_goes_to_the_first_task(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/1/tasks": [
|
||||||
|
{"id": 31, "title": "Nota", "order_index": 1},
|
||||||
|
{"id": 30, "title": "Prima", "order_index": 0},
|
||||||
|
],
|
||||||
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/start/1?lot_number=L1")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"].endswith("/measure/execute/30")
|
||||||
|
|
||||||
|
def test_start_remembers_the_traceability(self, logged_in_client, mock_api_client):
|
||||||
|
"""The list used to be where lot and serial were stored; the sequence
|
||||||
|
skips it, so the storing has to happen on the way in."""
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
|
||||||
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||||
|
})
|
||||||
|
|
||||||
|
logged_in_client.get("/measure/start/1?lot_number=L9&serial_number=S9")
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
assert sess["lot_number"] == "L9"
|
||||||
|
assert sess["serial_number"] == "S9"
|
||||||
|
|
||||||
|
def test_start_refuses_without_the_compulsory_lot(
|
||||||
|
self, logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
"""Point 8 holds on this door too: it is a way into the measurement."""
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
|
||||||
|
"/api/recipes/1": {
|
||||||
|
"id": 1, "code": "REC-001", "name": "Test Recipe",
|
||||||
|
"requires_lot": True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/start/1")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "/measure/select" in resp.headers["Location"]
|
||||||
|
|
||||||
|
def test_start_without_tasks_falls_back_to_the_list(
|
||||||
|
self, logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/1/tasks": [],
|
||||||
|
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/start/1")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"].endswith("/measure/tasks/1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskComplete:
|
||||||
|
"""GET /measure/complete/<recipe_id> tests."""
|
||||||
|
|
||||||
|
def test_task_complete_renders(self, logged_in_client, mock_api_client):
|
||||||
|
"""The summary carries the measurement clock, so it has to render with it."""
|
||||||
|
mock_api_client.get.side_effect = [
|
||||||
|
# The summary serialises the version number, so the recipe carries one.
|
||||||
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||||
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
||||||
|
{"items": []}, # measurements
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"productionClock(" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutOfTolerance:
|
||||||
|
"""Proxies for point 5: the capoturno's answer has to reach the measurement."""
|
||||||
|
|
||||||
|
def test_authorise_forwards_the_credentials(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"id": 9, "supervisor_id": 4}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/measurements/9/authorise",
|
||||||
|
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
endpoint, kwargs = mock_api_client.post.call_args
|
||||||
|
assert endpoint[0] == "/api/measurements/9/authorise"
|
||||||
|
assert kwargs["data"]["supervisor_username"] == "capo"
|
||||||
|
|
||||||
|
def test_authorise_needs_credentials(self, logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/measurements/9/authorise", json={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_authorise_propagates_refusal(self, logged_in_client, mock_api_client):
|
||||||
|
"""A operator who is not a supervisor must not authorise their own value."""
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 403, "detail": "not a supervisor",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/measurements/9/authorise",
|
||||||
|
json={"supervisor_username": "tec", "supervisor_password": "x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_pending_passes_the_scope(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"id": 9, "subtask_id": 21}
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
"/measure/api/measurements/pending-authorisation"
|
||||||
|
"?version_id=5&production_run_id=7",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == 9
|
||||||
|
endpoint, kwargs = mock_api_client.get.call_args
|
||||||
|
assert endpoint[0] == "/api/measurements/pending-authorisation"
|
||||||
|
assert kwargs["params"] == {"version_id": "5", "production_run_id": "7"}
|
||||||
|
|
||||||
|
def test_pending_without_a_run_omits_it(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = None
|
||||||
|
logged_in_client.get(
|
||||||
|
"/measure/api/measurements/pending-authorisation?version_id=5",
|
||||||
|
)
|
||||||
|
assert mock_api_client.get.call_args[1]["params"] == {"version_id": "5"}
|
||||||
|
|
||||||
|
def test_routes_require_login(self, client):
|
||||||
|
for method, url in (
|
||||||
|
("get", "/measure/api/measurements/pending-authorisation?version_id=5"),
|
||||||
|
("post", "/measure/api/measurements/9/authorise"),
|
||||||
|
):
|
||||||
|
resp = getattr(client, method)(url)
|
||||||
|
assert resp.status_code in (302, 401), f"{method} {url}"
|
||||||
|
|
||||||
|
|
||||||
class TestSaveMeasurement:
|
class TestSaveMeasurement:
|
||||||
"""POST /measure/save-measurement tests."""
|
"""POST /measure/save-measurement tests."""
|
||||||
|
|||||||
@@ -140,6 +140,40 @@ def test_complete_cycle_hits_the_run(logged_in_client, monkeypatch):
|
|||||||
assert endpoint[0] == "/api/production-runs/7/cycle"
|
assert endpoint[0] == "/api/production-runs/7/cycle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_cycle_forwards_the_task(logged_in_client, monkeypatch):
|
||||||
|
"""Which task was finished is what tells the server whether the cycle closed."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
logged_in_client.post("/measure/api/production/7/cycle", json={"task_id": 11})
|
||||||
|
assert mock_api.post.call_args[1]["data"]["task_id"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_remeasure_hits_its_own_endpoint(logged_in_client, monkeypatch):
|
||||||
|
"""Turning the piece over must not go down the cycle path and restart the timer."""
|
||||||
|
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/7/remeasure", json={"task_id": 11},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == "/api/production-runs/7/remeasure"
|
||||||
|
assert kwargs["data"]["task_id"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_remeasure_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/remeasure", json={})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_complete_cycle_propagates_paused_conflict(logged_in_client, monkeypatch):
|
def test_complete_cycle_propagates_paused_conflict(logged_in_client, monkeypatch):
|
||||||
measure_mod = _with_station(monkeypatch)
|
measure_mod = _with_station(monkeypatch)
|
||||||
with patch.object(measure_mod, "api_client") as mock_api:
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
@@ -157,6 +191,92 @@ def test_production_routes_require_login(client, monkeypatch):
|
|||||||
("get", "/measure/api/production/current"),
|
("get", "/measure/api/production/current"),
|
||||||
("post", "/measure/api/production/start"),
|
("post", "/measure/api/production/start"),
|
||||||
("post", "/measure/api/production/7/cycle"),
|
("post", "/measure/api/production/7/cycle"),
|
||||||
|
("post", "/measure/api/production/7/remeasure"),
|
||||||
):
|
):
|
||||||
resp = getattr(client, method)(url)
|
resp = getattr(client, method)(url)
|
||||||
assert resp.status_code in (302, 401), f"{method} {url} -> {resp.status_code}"
|
assert resp.status_code in (302, 401), f"{method} {url} -> {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fermo linea, ripresa, fine produzione (point 6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREDS = {"supervisor_username": "capoturno", "supervisor_password": "segreta"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
|
||||||
|
def test_supervised_action_forwards_credentials(logged_in_client, monkeypatch, action):
|
||||||
|
"""Credentials go to the endpoint itself, which records who authorised what."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {**RUN, "status": "paused"}
|
||||||
|
resp = logged_in_client.post(f"/measure/api/production/7/{action}", json=CREDS)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == f"/api/production-runs/7/{action}"
|
||||||
|
assert kwargs["data"]["supervisor_username"] == "capoturno"
|
||||||
|
assert kwargs["data"]["supervisor_password"] == "segreta"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
|
||||||
|
def test_supervised_action_requires_credentials(logged_in_client, monkeypatch, action):
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
resp = logged_in_client.post(f"/measure/api/production/7/{action}", json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code", [401, 403])
|
||||||
|
def test_supervised_action_propagates_refusal(logged_in_client, monkeypatch, status_code):
|
||||||
|
"""A non-supervisor must not be able to stop or close a production."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
"error": True, "status_code": status_code, "detail": "no",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post("/measure/api/production/7/pause", json=CREDS)
|
||||||
|
assert resp.status_code == status_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_returns_the_statistics_path(logged_in_client, monkeypatch):
|
||||||
|
"""Fine produzione emits the file, and the path comes back to the client."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
**RUN, "status": "closed",
|
||||||
|
"statistics_path": "statistics/production_7_20260728_170000.csv",
|
||||||
|
"seconds_to_next_measurement": None,
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post("/measure/api/production/7/close", json=CREDS)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "closed"
|
||||||
|
assert body["statistics_path"].endswith(".csv")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_route_is_not_shadowed_by_the_supervised_routes(logged_in_client, monkeypatch):
|
||||||
|
"""/7/cycle must still reach the cycle handler, not a supervised action."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
logged_in_client.post("/measure/api/production/7/cycle", json={})
|
||||||
|
assert mock_api.post.call_args[0][0] == "/api/production-runs/7/cycle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_measurement_attaches_the_run(logged_in_client, monkeypatch):
|
||||||
|
"""Every value taken during a production must carry its run id."""
|
||||||
|
measure_mod = _with_station(monkeypatch)
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {"id": 1, "pass_fail": "pass"}
|
||||||
|
logged_in_client.post(
|
||||||
|
"/measure/save-measurement",
|
||||||
|
json={
|
||||||
|
"subtask_id": 21, "version_id": 5, "value": 10.0,
|
||||||
|
"production_run_id": 7,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
# Trailing slash: without it every measurement pays a 307 redirect.
|
||||||
|
assert endpoint[0] == "/api/measurements/"
|
||||||
|
assert kwargs["data"]["production_run_id"] == 7
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Point 12: the install must work on a network with no way out.
|
||||||
|
|
||||||
|
The factory floor at Tràfilo has no internet. A page that fetches a library from
|
||||||
|
a CDN there does not load slowly, it does not load: the interface never starts.
|
||||||
|
|
||||||
|
The libraries were brought in-house; this is what keeps them there. The failure
|
||||||
|
mode this guards against is not dramatic - someone adds a `<script src="https://
|
||||||
|
cdn...">` because it is the quickest way to try something, it works at their desk,
|
||||||
|
and nobody finds out until the machine is on the shop floor.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TEMPLATES = APP_ROOT / "templates"
|
||||||
|
STATIC = APP_ROOT / "static"
|
||||||
|
|
||||||
|
# Anything loaded by the browser: src=, href=, url(), importScripts, workerSrc.
|
||||||
|
_EXTERNAL_ASSET_RX = re.compile(
|
||||||
|
r"""(?:src|href)\s*=\s*["']https?://""", re.I,
|
||||||
|
)
|
||||||
|
_EXTERNAL_URL_RX = re.compile(r"""https?://[^\s"'<>)]+""")
|
||||||
|
|
||||||
|
# Hosts a comment may legitimately mention: the check is about what gets fetched,
|
||||||
|
# not about what gets written down. Only documentation links live here.
|
||||||
|
_DOC_HOSTS = (
|
||||||
|
"tailwindcss.com", "github.com", "bugzilla.mozilla.org",
|
||||||
|
"bugs.chromium.org", "bugs.webkit.org", "www.w3.org", "schema.org",
|
||||||
|
"developer.mozilla.org",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tracked_files():
|
||||||
|
"""The files git knows about, or None when git cannot answer.
|
||||||
|
|
||||||
|
Being outside `vendor/` is not the same as being ours. A scratch copy of a
|
||||||
|
library dropped into `static/js/` to debug something looks first-party and is
|
||||||
|
not: it is not in the repository, it never reaches the shop floor, and its
|
||||||
|
comments are not our comments. Trackedness is the honest test for that.
|
||||||
|
|
||||||
|
Trackedness, and not `.gitignore`: an ignore pattern says nothing about a
|
||||||
|
file that is already tracked, so matching patterns would be the wrong
|
||||||
|
question.
|
||||||
|
|
||||||
|
When git cannot answer we scan everything, as before. A guard test that goes
|
||||||
|
quiet the moment it loses its footing is worse than one that cries wolf.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
listed = subprocess.run(
|
||||||
|
["git", "ls-files", "-z"],
|
||||||
|
cwd=APP_ROOT, capture_output=True, text=True, check=True,
|
||||||
|
).stdout
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return {(APP_ROOT / name).resolve() for name in listed.split("\0") if name}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_party_files(suffixes):
|
||||||
|
tracked = _tracked_files()
|
||||||
|
|
||||||
|
def is_ours(path):
|
||||||
|
return tracked is None or path.resolve() in tracked
|
||||||
|
|
||||||
|
for path in sorted(STATIC.rglob("*")):
|
||||||
|
if path.suffix in suffixes and "vendor" not in path.parts and is_ours(path):
|
||||||
|
yield path
|
||||||
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||||
|
if is_ours(path):
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_template_loads_anything_from_the_network():
|
||||||
|
"""A src= or href= pointing outside is the whole bug, in one line."""
|
||||||
|
offenders = []
|
||||||
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||||
|
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
if _EXTERNAL_ASSET_RX.search(line):
|
||||||
|
offenders.append(f"{path.relative_to(APP_ROOT)}:{n}")
|
||||||
|
assert not offenders, f"asset caricati dalla rete: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_first_party_script_calls_out():
|
||||||
|
"""Not only tags: a fetch() or a workerSrc reaches the network just the same."""
|
||||||
|
offenders = []
|
||||||
|
for path in _first_party_files({".js", ".css"}):
|
||||||
|
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
for url in _EXTERNAL_URL_RX.findall(line):
|
||||||
|
if any(host in url for host in _DOC_HOSTS):
|
||||||
|
continue
|
||||||
|
offenders.append(f"{path.relative_to(APP_ROOT)}:{n} {url}")
|
||||||
|
assert not offenders, f"riferimenti esterni: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_libraries_are_actually_in_the_box():
|
||||||
|
"""Repointing the tags without shipping the files is the same white screen."""
|
||||||
|
vendor = STATIC / "vendor"
|
||||||
|
missing = [
|
||||||
|
name for name in (
|
||||||
|
"alpinejs-3.15.12.min.js",
|
||||||
|
"plotly-2.32.0.min.js",
|
||||||
|
"pdf-3.11.174.min.js",
|
||||||
|
"pdf.worker-3.11.174.min.js",
|
||||||
|
"fabric-5.3.1.min.js",
|
||||||
|
"fonts.css",
|
||||||
|
)
|
||||||
|
if not (vendor / name).is_file()
|
||||||
|
]
|
||||||
|
assert not missing, f"librerie dichiarate ma non presenti: {missing}"
|
||||||
|
assert list((vendor / "fonts").glob("*.woff2")), "i font non sono nel pacchetto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pdf_worker_is_local_everywhere_it_is_set():
|
||||||
|
"""The trap of this point: the library loads from disk and its worker does not.
|
||||||
|
|
||||||
|
Everything looks right until someone opens a drawing.
|
||||||
|
"""
|
||||||
|
setters = []
|
||||||
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if "workerSrc" not in text:
|
||||||
|
continue
|
||||||
|
setters.append(path.relative_to(APP_ROOT).as_posix())
|
||||||
|
for n, line in enumerate(text.splitlines(), 1):
|
||||||
|
if "workerSrc" in line:
|
||||||
|
assert "vendor/pdf.worker" in line, (
|
||||||
|
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
|
||||||
|
)
|
||||||
|
# Four templates render PDFs; if that number changes the new one needs the
|
||||||
|
# worker set too, and this is where you find out.
|
||||||
|
assert len(setters) == 4, f"template che impostano il worker: {setters}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_libraries_are_the_ones_that_were_checked():
|
||||||
|
"""The fingerprints in VERSIONS.md are the record of what was validated.
|
||||||
|
|
||||||
|
A library swapped in place - a hurried patch, a bad merge, something worse -
|
||||||
|
keeps the same filename and the same version in the table. The hash is the
|
||||||
|
only part that notices.
|
||||||
|
"""
|
||||||
|
vendor = STATIC / "vendor"
|
||||||
|
recorded = dict(
|
||||||
|
(name, digest)
|
||||||
|
for digest, name in (
|
||||||
|
line.split(maxsplit=1)
|
||||||
|
for line in (
|
||||||
|
l.strip() for l in
|
||||||
|
(vendor / "VERSIONS.md").read_text(encoding="utf-8").splitlines()
|
||||||
|
)
|
||||||
|
if re.fullmatch(r"[0-9a-f]{64}\s+\S+", line)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert recorded, "VERSIONS.md non elenca più le impronte"
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
wrong = []
|
||||||
|
for name, digest in recorded.items():
|
||||||
|
path = vendor / name
|
||||||
|
if not path.is_file():
|
||||||
|
wrong.append(f"{name}: assente")
|
||||||
|
continue
|
||||||
|
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if actual != digest:
|
||||||
|
wrong.append(f"{name}: {actual} != {digest}")
|
||||||
|
assert not wrong, f"librerie diverse da quelle validate: {wrong}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_versions_are_pinned():
|
||||||
|
"""No `3.x.x`: a library that updates itself changes an audit system without
|
||||||
|
anyone validating it. The filenames on disk carry the version."""
|
||||||
|
vendor = STATIC / "vendor"
|
||||||
|
unpinned = [
|
||||||
|
p.name for p in vendor.glob("*.js")
|
||||||
|
if not re.search(r"-\d+\.\d+\.\d+(\.min)?\.js$", p.name)
|
||||||
|
]
|
||||||
|
assert not unpinned, f"librerie senza versione nel nome: {unpinned}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The policy that keeps it that way
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
PAGES = ["/auth/login", "/"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", PAGES)
|
||||||
|
def test_every_page_carries_the_policy(client, path):
|
||||||
|
resp = client.get(path, follow_redirects=False)
|
||||||
|
csp = resp.headers.get("Content-Security-Policy", "")
|
||||||
|
assert "default-src 'self'" in csp
|
||||||
|
assert "connect-src 'self'" in csp
|
||||||
|
# No CDN host may be allowed back in through the policy itself.
|
||||||
|
assert "http://" not in csp and "https://" not in csp
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_policy_allows_what_the_app_actually_needs(client):
|
||||||
|
"""A policy that breaks the interface gets removed, and then it protects
|
||||||
|
nothing. These are the four allowances the libraries genuinely require."""
|
||||||
|
csp = client.get("/auth/login").headers["Content-Security-Policy"]
|
||||||
|
assert "'unsafe-eval'" in csp # Alpine 3, Plotly
|
||||||
|
assert "'unsafe-inline'" in csp # componenti Alpine nei template
|
||||||
|
assert "worker-src 'self' blob:" in csp # worker di PDF.js
|
||||||
|
assert "img-src 'self' data: blob:" in csp # canvas Fabric, miniature
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Point 10: the operator's way through a recipe.
|
||||||
|
|
||||||
|
The sequence is the normal path and the list is a place you go on purpose. What
|
||||||
|
these tests hold on to is the part that is easy to undo by accident: the way in
|
||||||
|
lands on a task, the list says which task was left unfinished and lets the
|
||||||
|
operator back into it, «Fine ciclo misura» is visible before it is usable, and
|
||||||
|
nothing in any of it opens a door to the statistics (point 15).
|
||||||
|
"""
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
TASK = {
|
||||||
|
"id": 11,
|
||||||
|
"recipe_id": 3,
|
||||||
|
"version_id": 5,
|
||||||
|
"task_type": "measure",
|
||||||
|
"title": "Quota",
|
||||||
|
"directive": None,
|
||||||
|
"description": None,
|
||||||
|
"file_path": None,
|
||||||
|
"file_type": None,
|
||||||
|
"annotations_json": None,
|
||||||
|
"subtasks": [{
|
||||||
|
"id": 21, "marker_number": 1, "description": "Altezza", "order_index": 0,
|
||||||
|
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
||||||
|
"unit": "mm",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
RECIPE = {
|
||||||
|
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||||
|
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||||
|
"current_version": {"id": 5, "version_number": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _italian(client):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["language"] = "it"
|
||||||
|
|
||||||
|
|
||||||
|
def _operator_only(client):
|
||||||
|
"""Strip the Maker role: this is what the shop floor account looks like."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
user = dict(sess["user"])
|
||||||
|
user["roles"] = ["MeasurementTec"]
|
||||||
|
user["is_admin"] = False
|
||||||
|
sess["user"] = user
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_page(client, mock_api_client):
|
||||||
|
_italian(client)
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/tasks/11": TASK,
|
||||||
|
"/api/recipes/3/tasks": [{"id": 11, "order_index": 0}],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
})
|
||||||
|
resp = client.get("/measure/execute/11")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_page(client, mock_api_client, progress=None):
|
||||||
|
_italian(client)
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/3/tasks": [
|
||||||
|
{"id": 11, "title": "Misura", "order_index": 0, "task_type": "measure"},
|
||||||
|
],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
"/api/production-runs/current": None,
|
||||||
|
"/api/measurements/task-progress": {"tasks": progress or []},
|
||||||
|
})
|
||||||
|
resp = client.get("/measure/tasks/3")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The way in: a recipe opens on a task, not on a list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_selection_screen_starts_the_sequence(
|
||||||
|
logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
):
|
||||||
|
_italian(logged_in_client)
|
||||||
|
# The page refuses to render without a station, which is the right answer on a
|
||||||
|
# real terminal and only an obstacle here.
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
mock_api_client.get_station_recipes.return_value = []
|
||||||
|
resp = logged_in_client.get("/measure/select")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert "'/measure/start/'" in html
|
||||||
|
# The full list is still reachable, deliberately as the quiet option.
|
||||||
|
assert "'/measure/tasks/'" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
|
||||||
|
html = _list_page(logged_in_client, mock_api_client)
|
||||||
|
assert "/measure/start/3" in html
|
||||||
|
assert "AVVIA IN SEQUENZA" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The list: second level, and it says what was left unfinished
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_operator_can_open_a_single_task(logged_in_client, mock_api_client):
|
||||||
|
"""Without this, «incompiuto» would be a label with no way to act on it."""
|
||||||
|
_operator_only(logged_in_client)
|
||||||
|
html = _list_page(logged_in_client, mock_api_client)
|
||||||
|
assert "Visualizza singolo TASK" in html
|
||||||
|
assert "/measure/execute/11" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_half_measured_task_is_marked(logged_in_client, mock_api_client):
|
||||||
|
html = _list_page(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
progress=[{"task_id": 11, "quotes": 3, "measured": 2, "state": "partial"}],
|
||||||
|
)
|
||||||
|
assert "Incompiuto" in html
|
||||||
|
assert "2/3" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finished_task_is_marked(logged_in_client, mock_api_client):
|
||||||
|
html = _list_page(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
progress=[{"task_id": 11, "quotes": 3, "measured": 3, "state": "complete"}],
|
||||||
|
)
|
||||||
|
assert "Completato" in html
|
||||||
|
assert "3/3" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_untouched_task_says_nothing(logged_in_client, mock_api_client):
|
||||||
|
"""A list that shouts at every row is a list nobody reads."""
|
||||||
|
html = _list_page(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
progress=[{"task_id": 11, "quotes": 3, "measured": 0, "state": "none"}],
|
||||||
|
)
|
||||||
|
assert "Incompiuto" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inside the task: «Completato» forward, «Fine ciclo misura» visible but shut
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_header_offers_completato_not_the_summary(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client)
|
||||||
|
assert 'x-data="taskExecute()"' in html
|
||||||
|
# The header control moves the operator on and is gated by the same rule as
|
||||||
|
# the footer, rather than jumping out of the sequence to a recap.
|
||||||
|
assert 'canLeaveTask' in html
|
||||||
|
assert '@click="goToNextTask()"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_fine_ciclo_is_shown_before_it_can_be_used(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
"""It used to appear only once the task was already finished."""
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client)
|
||||||
|
assert 'x-show="!cycleConfirmed"' in html
|
||||||
|
assert ':disabled="!isComplete"' in html
|
||||||
|
assert "Fine ciclo misura" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_missing_quotes_are_counted_out_loud(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client)
|
||||||
|
assert "missingQuotesLabel" in html
|
||||||
|
assert "Mancano" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Point 15: none of this may open a way to the statistics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_operator_flow_never_links_to_the_statistics(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
_operator_only(logged_in_client)
|
||||||
|
pages = [
|
||||||
|
_list_page(logged_in_client, mock_api_client),
|
||||||
|
_execute_page(logged_in_client, mock_api_client),
|
||||||
|
]
|
||||||
|
for html in pages:
|
||||||
|
assert "/statistics" not in html
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""The screens that carry the rules of a recipe (points 8, 9 and 11).
|
||||||
|
|
||||||
|
The server refuses what the recipe forbids; these tests are about the operator
|
||||||
|
being told before the refusal rather than after it - a keypad that is not there,
|
||||||
|
a start button that will not start, a description that reads the way it was
|
||||||
|
written.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Point 11 - line breaks and bold, and nothing else
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _render(flask_app, template_string, **context):
|
||||||
|
with flask_app.test_request_context():
|
||||||
|
return flask_app.jinja_env.from_string(template_string).render(**context)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rich_text_keeps_line_breaks(flask_app):
|
||||||
|
out = _render(flask_app, "{{ v|rich_text }}", v="prima\nseconda")
|
||||||
|
assert out == "prima<br>seconda"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rich_text_makes_bold(flask_app):
|
||||||
|
out = _render(flask_app, "{{ v|rich_text }}", v="quota **critica** qui")
|
||||||
|
assert out == "quota <strong>critica</strong> qui"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rich_text_escapes_everything_else(flask_app):
|
||||||
|
"""No HTML is accepted, so there is nothing to sanitise away later."""
|
||||||
|
out = _render(flask_app, "{{ v|rich_text }}", v="<script>alert(1)</script>")
|
||||||
|
assert "<script>" not in out
|
||||||
|
assert "<script>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_rich_text_cannot_be_tricked_into_tags(flask_app):
|
||||||
|
"""Bold markers around a tag must not reassemble it."""
|
||||||
|
out = _render(flask_app, "{{ v|rich_text }}", v="**<b onclick='x'>ciao</b>**")
|
||||||
|
assert "onclick" not in out or "'" in out
|
||||||
|
assert "<b " not in out
|
||||||
|
assert out.startswith("<strong><b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rich_text_of_nothing_is_nothing(flask_app):
|
||||||
|
assert _render(flask_app, "{{ v|rich_text }}", v=None) == ""
|
||||||
|
assert _render(flask_app, "{{ v|rich_text }}", v="") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Point 9 - the keypad is not offered when the recipe forbids typing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
TASK = {
|
||||||
|
"id": 11,
|
||||||
|
"recipe_id": 3,
|
||||||
|
"version_id": 5,
|
||||||
|
"task_type": "measure",
|
||||||
|
"title": "Quota",
|
||||||
|
"directive": None,
|
||||||
|
"description": "prima riga\n**grassetto**",
|
||||||
|
"file_path": None,
|
||||||
|
"file_type": None,
|
||||||
|
"annotations_json": None,
|
||||||
|
"subtasks": [{
|
||||||
|
"id": 21, "marker_number": 1, "description": "Altezza", "order_index": 0,
|
||||||
|
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
||||||
|
"unit": "mm",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_page(client, mock_api_client, allow_manual):
|
||||||
|
mock_api_client.get.side_effect = [
|
||||||
|
TASK,
|
||||||
|
[{"id": 11, "order_index": 0}],
|
||||||
|
{
|
||||||
|
"id": 3, "measurement_interval_minutes": 30,
|
||||||
|
"allow_manual_input": allow_manual,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
resp = client.get("/measure/execute/11")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_keypad_digits_are_absent_when_typing_is_forbidden(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client, allow_manual=False)
|
||||||
|
assert "allowManual: false" in html
|
||||||
|
assert "addDigit('7')" not in html, "i tasti numerici non devono essere in pagina"
|
||||||
|
# The corrections and the confirm stay: a wrong caliper reading has to be
|
||||||
|
# clearable, and the value still has to be confirmable.
|
||||||
|
assert "clearAll()" in html
|
||||||
|
assert "confirm()" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_keypad_is_there_when_the_recipe_allows_typing(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
|
||||||
|
assert "allowManual: true" in html
|
||||||
|
assert "addDigit('7')" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_keypad_stays_when_the_recipe_cannot_be_read(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
"""A recipe that failed to load must not silently take the keypad away."""
|
||||||
|
mock_api_client.get.side_effect = [
|
||||||
|
TASK,
|
||||||
|
[{"id": 11, "order_index": 0}],
|
||||||
|
{"error": True, "detail": "boom"},
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/measure/execute/11")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "allowManual: true" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Point 11 on the measurement screen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_operator_sees_the_description_formatted(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
"""It was written for the operator, and until now only the maker saw it."""
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
|
||||||
|
assert "prima riga<br><strong>grassetto</strong>" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Point 8 - the start control knows what the recipe demands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _task_list(client, mock_api_client, recipe, session_lot=None):
|
||||||
|
# The fixture session is English; these assertions read the Italian source
|
||||||
|
# strings, so the locale is pinned rather than assumed.
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["language"] = "it"
|
||||||
|
if session_lot is not None:
|
||||||
|
sess["lot_number"] = session_lot
|
||||||
|
mock_api_client.get.side_effect = [
|
||||||
|
recipe,
|
||||||
|
[{"id": 11, "title": "Task", "order_index": 0, "task_type": "measure"}],
|
||||||
|
]
|
||||||
|
resp = client.get("/measure/tasks/3")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvia_does_not_start_without_the_required_lot(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
"""This page is reachable directly, so the rule has to hold here too."""
|
||||||
|
html = _task_list(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
|
||||||
|
)
|
||||||
|
assert "Compila prima" in html
|
||||||
|
# The way in is closed, not merely greyed: it leads back to where the lot is
|
||||||
|
# asked for. (The per-task links below belong to the Maker's own view.)
|
||||||
|
assert "/measure/select?recipe=REC-1" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvia_starts_once_the_lot_is_there(logged_in_client, mock_api_client):
|
||||||
|
html = _task_list(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
|
||||||
|
session_lot="LOT-1",
|
||||||
|
)
|
||||||
|
assert "Compila prima" not in html
|
||||||
|
assert "/measure/select?recipe=REC-1" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvia_is_untouched_when_nothing_is_required(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
):
|
||||||
|
html = _task_list(
|
||||||
|
logged_in_client, mock_api_client,
|
||||||
|
{"id": 3, "code": "REC-1", "name": "Libera"},
|
||||||
|
)
|
||||||
|
assert "Compila prima" not in html
|
||||||
|
assert "/measure/select?recipe=REC-1" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_screen_carries_the_traceability_rules(
|
||||||
|
logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import config
|
||||||
|
importlib.reload(config)
|
||||||
|
import blueprints.measure
|
||||||
|
importlib.reload(blueprints.measure)
|
||||||
|
|
||||||
|
mock_api_client.get_station_recipes.return_value = [
|
||||||
|
{
|
||||||
|
"id": 1, "code": "REC-001", "name": "Con lotto",
|
||||||
|
"requires_lot": True, "requires_serial": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/measure/select")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert "missingTraceability" in html
|
||||||
|
# The rule travels with the recipe data the cards are built from.
|
||||||
|
assert "requires_lot" in html
|
||||||
@@ -24,6 +24,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
|
||||||
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
|
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
|
||||||
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
|
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
|
||||||
@@ -264,3 +266,69 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
|
|||||||
_node_check(body, f"/measure/execute script[{i}]")
|
_node_check(body, f"/measure/execute script[{i}]")
|
||||||
|
|
||||||
_check_alpine_attributes(html, "/measure/execute")
|
_check_alpine_attributes(html, "/measure/execute")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_recipe_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
|
||||||
|
"""The selection screen decides whether Avvia starts at all.
|
||||||
|
|
||||||
|
Its expressions are inline in x-data, so a broken one there takes the search,
|
||||||
|
the barcode scanner and the traceability check down with it.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import config
|
||||||
|
importlib.reload(config)
|
||||||
|
import blueprints.measure
|
||||||
|
importlib.reload(blueprints.measure)
|
||||||
|
|
||||||
|
_force_italian(logged_in_client)
|
||||||
|
mock_measure_api.get_station_recipes.return_value = [
|
||||||
|
{
|
||||||
|
"id": 1, "code": "REC-001", "name": "Ricetta con l'apostrofo",
|
||||||
|
"description": "Con l'apostrofo anche qui",
|
||||||
|
"requires_lot": True, "requires_serial": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/select")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
|
||||||
|
_node_check(body, f"/measure/select script[{i}]")
|
||||||
|
|
||||||
|
_check_alpine_attributes(html, "/measure/select")
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||||
|
"""The task list carries the shared measurement clock.
|
||||||
|
|
||||||
|
Its bindings are the ones that turn the countdown red and announce the return
|
||||||
|
to the measurement; a broken expression here would take the whole page's Alpine
|
||||||
|
with it, and the operator would simply never be called back.
|
||||||
|
"""
|
||||||
|
_force_italian(logged_in_client)
|
||||||
|
mock_measure_api.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/3/tasks": [{
|
||||||
|
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
|
||||||
|
"task_type": "measure", "subtasks": [], "file_path": None,
|
||||||
|
}],
|
||||||
|
"/api/recipes/3": {
|
||||||
|
"id": 3, "code": "REC-3", "name": "Ricetta", "description": None,
|
||||||
|
"current_version": {"id": 5},
|
||||||
|
},
|
||||||
|
"/api/production-runs/current": None,
|
||||||
|
"/api/measurements/task-progress": {
|
||||||
|
"tasks": [{"task_id": 11, "quotes": 2, "measured": 1, "state": "partial"}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = logged_in_client.get("/measure/tasks/3")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
|
||||||
|
_node_check(body, f"/measure/tasks script[{i}]")
|
||||||
|
|
||||||
|
_check_alpine_attributes(html, "/measure/tasks")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user