feat(production): fermo linea e fine produzione fanno finalmente qualcosa
Punto 6 del documento modifiche del 28/07. I due pulsanti esistevano, chiedevano correttamente l'autorizzazione del capoturno, e poi ricadevano su un commento: "handled by GAIA integration (future)". E' la riga di apertura del documento. Ora agiscono sulla produzione aperta: fermo linea sospende e congela il conto alla rovescia, la ripresa lo fa ripartire restituendo il tempo del fermo, fine produzione chiude ed emette il file di statistica. Le credenziali del capoturno vanno direttamente all'endpoint, che le verifica e registra sulla traccia della produzione chi ha autorizzato cosa: validarle a parte non lascerebbe traccia, e sarebbe un giro in piu'. In interfaccia il pulsante di fermo diventa "Riprendi" quando la linea e' ferma, e una banda dichiara lo stato con il valore congelato a video: una linea ferma che sembra in marcia e' il modo in cui si salta un intervallo di misura senza accorgersene. Il file di statistica ha richiesto di legare le misure alla produzione (migrazione 006): Measurement conosceva solo versione, lotto e seriale, nessuno dei quali separa una produzione dalla successiva sulla stessa ricetta e sullo stesso lotto, quindi "le misure dell'intera produzione" non era una query. Il CSV rispetta i separatori configurati in system_settings e porta con se' i limiti di tolleranza: senza quelli un esito pass/fail non e' piu' ricalcolabile dal file a distanza di anni, che e' il senso di un documento per audit. Le misure esportate vengono marcate synced_to_csv. Se la produzione non ha misure non viene scritto nulla: un file vuoto sarebbe rumore nella cartella, non evidenza. L'invio a GAIA resta assente e non abbozzato, con il punto d'innesto dichiarato in close_run: una chiamata vuota che sembra collegata e' peggio di niente. Tutto il resto del punto 6 non dipende dal gestionale e funziona adesso. Anche la 006 e' stata eseguita su SQLite usa e getta prima di essere considerata buona, e anche qui la prova ha trovato un difetto: aggiungere una colonna con foreign key fa emettere ad alembic un ALTER di vincolo, che SQLite rifiuta. Ora usa batch_alter_table con il vincolo nominato, che su MySQL resta un ALTER normale e su SQLite ricostruisce la tabella - cosi' la chiave esterna non va persa per far contento il dialetto dei test. Corretto anche il proxy di salvataggio misura, che chiamava /api/measurements senza slash finale e pagava un redirect 307 a ogni singola misura. Traduzioni: pybabel aveva di nuovo indovinato sette voci marcandole fuzzy, e in italiano "Linea ferma" era diventato "Lingua Preferita" su una banda di sicurezza. Tradotte per esteso in IT ed EN e tolti i flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""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",
|
||||
]
|
||||
|
||||
|
||||
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 "",
|
||||
])
|
||||
|
||||
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}"
|
||||
Reference in New Issue
Block a user