Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 787575807d |
@@ -14,7 +14,6 @@ services:
|
|||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
--default-time-zone=+00:00
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ services:
|
|||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
--default-time-zone=+00:00
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ from src.backend.database import get_db
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.models.orm.api_key import ApiKey
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services.auth_service import hash_api_key
|
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||||
from src.backend.utils.tempo import utcnow
|
|
||||||
|
|
||||||
_RENEW_INTERVAL = timedelta(minutes=5)
|
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.models.api.tipi import UtcNaiveInput
|
|
||||||
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -234,8 +232,8 @@ async def get_measurements(
|
|||||||
measured_by: int | None = Query(None),
|
measured_by: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
production_run_id: int | None = Query(None, gt=0),
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -292,8 +290,8 @@ async def export_measurements_csv(
|
|||||||
measured_by: int | None = Query(None),
|
measured_by: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -374,7 +372,7 @@ async def export_measurements_csv(
|
|||||||
neutralise_csv_cell(m.lot_number or ""),
|
neutralise_csv_cell(m.lot_number or ""),
|
||||||
neutralise_csv_cell(m.serial_number or ""),
|
neutralise_csv_cell(m.serial_number or ""),
|
||||||
m.input_method,
|
m.input_method,
|
||||||
iso_utc(m.measured_at),
|
m.measured_at.isoformat(),
|
||||||
m.synced_to_csv,
|
m.synced_to_csv,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -384,6 +382,6 @@ async def export_measurements_csv(
|
|||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
|
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Reports router — PDF report generation endpoints for Metrologist."""
|
"""Reports router — PDF report generation endpoints for Metrologist."""
|
||||||
from src.backend.models.api.tipi import UtcNaiveInput
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
@@ -18,12 +18,11 @@ async def download_spc_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -33,7 +32,6 @@ async def download_spc_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -54,12 +52,11 @@ async def download_measurement_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -69,7 +66,6 @@ async def download_measurement_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ NOT protected by API Key auth (standalone access).
|
|||||||
import hmac
|
import hmac
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from src.backend.utils.tempo import iso_utc, utcnow
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -556,7 +555,7 @@ async def seed_demo_data(body: PasswordBody):
|
|||||||
tec_user = created_users[2] # tec1
|
tec_user = created_users[2] # tec1
|
||||||
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
||||||
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
|
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
|
||||||
base_date = utcnow()
|
base_date = datetime.now()
|
||||||
|
|
||||||
all_subtasks = subtasks_1 + subtasks_2
|
all_subtasks = subtasks_1 + subtasks_2
|
||||||
measurements_created = 0
|
measurements_created = 0
|
||||||
@@ -604,8 +603,8 @@ async def list_users(body: PasswordBody):
|
|||||||
"roles": u.roles or [],
|
"roles": u.roles or [],
|
||||||
"is_admin": u.is_admin,
|
"is_admin": u.is_admin,
|
||||||
"active": u.active,
|
"active": u.active,
|
||||||
"created_at": iso_utc(u.created_at) or None,
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||||
"last_login": iso_utc(u.last_login) or None,
|
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.models.api.tipi import UtcNaiveInput
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from src.backend.models.orm.production import ProductionRun
|
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
@@ -39,7 +37,6 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters.
|
"""Query measurements with common filters.
|
||||||
|
|
||||||
@@ -69,9 +66,6 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
if production_run_id is not None:
|
|
||||||
filters.append(Measurement.production_run_id == production_run_id)
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -106,12 +100,11 @@ async def get_summary(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> SummaryData:
|
) -> SummaryData:
|
||||||
@@ -119,7 +112,6 @@ async def get_summary(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
pass_fail_values = [m.pass_fail for m in measurements]
|
pass_fail_values = [m.pass_fail for m in measurements]
|
||||||
return compute_summary(pass_fail_values)
|
return compute_summary(pass_fail_values)
|
||||||
@@ -130,12 +122,11 @@ async def get_capability(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> CapabilityData:
|
) -> CapabilityData:
|
||||||
@@ -143,7 +134,6 @@ async def get_capability(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
@@ -155,12 +145,11 @@ async def get_control_chart(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ControlChartData:
|
) -> ControlChartData:
|
||||||
@@ -168,7 +157,6 @@ async def get_control_chart(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
timestamps = [m.measured_at for m in measurements]
|
timestamps = [m.measured_at for m in measurements]
|
||||||
@@ -184,12 +172,11 @@ async def get_histogram(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: UtcNaiveInput = Query(None),
|
date_from: datetime | None = Query(None),
|
||||||
date_to: UtcNaiveInput = Query(None),
|
date_to: datetime | None = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
n_bins: int = Query(20, ge=5, le=100),
|
n_bins: int = Query(20, ge=5, le=100),
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -198,7 +185,6 @@ async def get_histogram(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
return compute_histogram(values, n_bins)
|
return compute_histogram(values, n_bins)
|
||||||
@@ -248,49 +234,3 @@ async def get_recipe_subtasks(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return subtasks_list
|
return subtasks_list
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runs")
|
|
||||||
async def get_recipe_runs(
|
|
||||||
recipe_id: int = Query(...),
|
|
||||||
user: User = Depends(require_metrologist),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
|
|
||||||
runs = (await db.execute(
|
|
||||||
select(ProductionRun)
|
|
||||||
.where(ProductionRun.recipe_id == recipe_id)
|
|
||||||
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
|
|
||||||
)).scalars().all()
|
|
||||||
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
|
|
||||||
run_ids = [r.id for r in runs]
|
|
||||||
counts = {}
|
|
||||||
if run_ids:
|
|
||||||
counts = dict((await db.execute(
|
|
||||||
select(Measurement.production_run_id, func.count(Measurement.id))
|
|
||||||
.where(Measurement.production_run_id.in_(run_ids))
|
|
||||||
.group_by(Measurement.production_run_id)
|
|
||||||
)).all())
|
|
||||||
operator_ids = {r.operator_id for r in runs}
|
|
||||||
names = {}
|
|
||||||
if operator_ids:
|
|
||||||
names = dict((await db.execute(
|
|
||||||
select(User.id, User.display_name).where(User.id.in_(operator_ids))
|
|
||||||
)).all())
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"station_id": r.station_id,
|
|
||||||
"station_name": r.station.name if r.station else None,
|
|
||||||
"operator_id": r.operator_id,
|
|
||||||
"operator_name": names.get(r.operator_id),
|
|
||||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
|
||||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
|
||||||
"status": r.status,
|
|
||||||
"lot_number": r.lot_number,
|
|
||||||
"serial_number": r.serial_number,
|
|
||||||
"cycle_count": r.cycle_count,
|
|
||||||
"measurement_count": counts.get(r.id, 0),
|
|
||||||
}
|
|
||||||
for r in runs
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ class Settings(BaseSettings):
|
|||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = _DEFAULT_DB_PASSWORD
|
db_password: str = _DEFAULT_DB_PASSWORD
|
||||||
|
|
||||||
# Presentation time zone (DB is UTC, #42)
|
|
||||||
display_timezone: str = "Europe/Rome"
|
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
server_host: str = "0.0.0.0"
|
server_host: str = "0.0.0.0"
|
||||||
server_port: int = 8000
|
server_port: int = 8000
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Measurement operations."""
|
"""Pydantic schemas for Measurement operations."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -54,8 +54,8 @@ class MeasurementResponse(BaseModel):
|
|||||||
# Who let this value stand, when it was out of tolerance. Null on a value in
|
# 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.
|
# tolerance - and on one that is not and is still waiting for an answer.
|
||||||
supervisor_id: Optional[int] = None
|
supervisor_id: Optional[int] = None
|
||||||
authorised_at: Optional[UtcDateTime] = None
|
authorised_at: Optional[datetime] = None
|
||||||
measured_at: UtcDateTime
|
measured_at: datetime
|
||||||
synced_to_csv: bool
|
synced_to_csv: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ class CycleMeasurementResponse(BaseModel):
|
|||||||
subtask_id: int
|
subtask_id: int
|
||||||
value: float
|
value: float
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
measured_at: UtcDateTime
|
measured_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class CurrentCycleResponse(BaseModel):
|
class CurrentCycleResponse(BaseModel):
|
||||||
@@ -114,8 +114,8 @@ class MeasurementQuery(BaseModel):
|
|||||||
measured_by: Optional[int] = None
|
measured_by: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
date_from: UtcNaiveInput = None
|
date_from: Optional[datetime] = None
|
||||||
date_to: UtcNaiveInput = None
|
date_to: Optional[datetime] = None
|
||||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||||
page: int = Field(1, ge=1)
|
page: int = Field(1, ge=1)
|
||||||
per_page: int = Field(50, ge=1, le=500)
|
per_page: int = Field(50, ge=1, le=500)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for production runs and their events."""
|
"""Pydantic schemas for production runs and their events."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
|
|||||||
supervisor_id: Optional[int]
|
supervisor_id: Optional[int]
|
||||||
task_id: Optional[int] = None
|
task_id: Optional[int] = None
|
||||||
note: Optional[str]
|
note: Optional[str]
|
||||||
created_at: UtcDateTime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunResponse(BaseModel):
|
class ProductionRunResponse(BaseModel):
|
||||||
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
|
|||||||
serial_number: Optional[str]
|
serial_number: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
measurement_interval_minutes: Optional[int]
|
measurement_interval_minutes: Optional[int]
|
||||||
next_measurement_at: Optional[UtcDateTime]
|
next_measurement_at: Optional[datetime]
|
||||||
cycle_count: int
|
cycle_count: int
|
||||||
started_at: UtcDateTime
|
started_at: datetime
|
||||||
paused_at: Optional[UtcDateTime]
|
paused_at: Optional[datetime]
|
||||||
closed_at: Optional[UtcDateTime]
|
closed_at: Optional[datetime]
|
||||||
closed_by: Optional[int]
|
closed_by: Optional[int]
|
||||||
statistics_path: Optional[str] = None
|
statistics_path: Optional[str] = None
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
|
|||||||
# overdue is a fact the operator must see, not deduce.
|
# overdue is a fact the operator must see, not deduce.
|
||||||
seconds_to_next_measurement: Optional[int] = None
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
overdue: bool = False
|
overdue: bool = False
|
||||||
server_time: Optional[UtcDateTime] = None
|
server_time: Optional[datetime] = None
|
||||||
|
|
||||||
# The measurement tasks of this run's version, in execution order. The client
|
# 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
|
# needs them to bring the operator back to the measurement when the interval
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel):
|
|||||||
version_number: int
|
version_number: int
|
||||||
is_current: bool
|
is_current: bool
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: UtcDateTime
|
created_at: datetime
|
||||||
change_notes: Optional[str] = None
|
change_notes: Optional[str] = None
|
||||||
tasks: list["TaskResponse"] = []
|
tasks: list["TaskResponse"] = []
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ class RecipeResponse(BaseModel):
|
|||||||
requires_serial: bool = False
|
requires_serial: bool = False
|
||||||
allow_manual_input: bool = False
|
allow_manual_input: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: UtcDateTime
|
created_at: datetime
|
||||||
active: bool
|
active: bool
|
||||||
current_version: Optional[RecipeVersionResponse] = None
|
current_version: Optional[RecipeVersionResponse] = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
|
|||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
active: bool
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: UtcDateTime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class StationRecipeAssignmentCreate(BaseModel):
|
class StationRecipeAssignmentCreate(BaseModel):
|
||||||
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
|
|||||||
station_id: int
|
station_id: int
|
||||||
recipe_id: int
|
recipe_id: int
|
||||||
assigned_by: int
|
assigned_by: int
|
||||||
assigned_at: UtcDateTime
|
assigned_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class RecipeSummary(BaseModel):
|
class RecipeSummary(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for SPC statistics responses."""
|
"""Pydantic schemas for SPC statistics responses."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
|
|||||||
recipe_id: int
|
recipe_id: int
|
||||||
version_id: Optional[int] = None
|
version_id: Optional[int] = None
|
||||||
subtask_id: Optional[int] = None
|
subtask_id: Optional[int] = None
|
||||||
date_from: UtcNaiveInput = None
|
date_from: Optional[datetime] = None
|
||||||
date_to: UtcNaiveInput = None
|
date_to: Optional[datetime] = None
|
||||||
operator_id: Optional[int] = None
|
operator_id: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
|
|||||||
class ControlChartData(BaseModel):
|
class ControlChartData(BaseModel):
|
||||||
"""Data for X-bar/R control chart."""
|
"""Data for X-bar/R control chart."""
|
||||||
values: list[float]
|
values: list[float]
|
||||||
timestamps: list[UtcDateTime]
|
timestamps: list[datetime]
|
||||||
mean: float
|
mean: float
|
||||||
ucl: float # Upper Control Limit
|
ucl: float # Upper Control Limit
|
||||||
lcl: float # Lower Control Limit
|
lcl: float # Lower Control Limit
|
||||||
@@ -60,7 +60,7 @@ class CapabilityData(BaseModel):
|
|||||||
|
|
||||||
class TrendData(BaseModel):
|
class TrendData(BaseModel):
|
||||||
"""Temporal trend data for capability indices."""
|
"""Temporal trend data for capability indices."""
|
||||||
dates: list[UtcDateTime]
|
dates: list[datetime]
|
||||||
cpk_values: list[Optional[float]]
|
cpk_values: list[Optional[float]]
|
||||||
ppk_values: list[Optional[float]]
|
ppk_values: list[Optional[float]]
|
||||||
n_per_period: list[int]
|
n_per_period: list[int]
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Annotated, Optional
|
|
||||||
|
|
||||||
from pydantic import AfterValidator, PlainSerializer
|
|
||||||
|
|
||||||
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
|
|
||||||
|
|
||||||
UtcDateTime = Annotated[
|
|
||||||
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Input: aware values are converted to UTC naive, naive is taken as UTC.
|
|
||||||
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for User operations."""
|
"""Pydantic schemas for User operations."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
|
|||||||
language_pref: str
|
language_pref: str
|
||||||
theme_pref: str
|
theme_pref: str
|
||||||
active: bool
|
active: bool
|
||||||
created_at: UtcDateTime
|
created_at: datetime
|
||||||
last_login: Optional[UtcDateTime] = None
|
last_login: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordChange(BaseModel):
|
class UserPasswordChange(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for the vision execution response."""
|
"""Pydantic schemas for the vision execution response."""
|
||||||
from src.backend.models.api.tipi import UtcDateTime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
@@ -28,4 +28,4 @@ class VisionReferenceImageResponse(BaseModel):
|
|||||||
note: str | None = None
|
note: str | None = None
|
||||||
station_id: int | None = None
|
station_id: int | None = None
|
||||||
device_code: str | None = None
|
device_code: str | None = None
|
||||||
acquired_at: UtcDateTime
|
acquired_at: datetime
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Authentication service - password hashing, API key management."""
|
"""Authentication service - password hashing, API key management."""
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from src.backend.utils.tempo import utcnow
|
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -79,6 +78,11 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
|||||||
return supervisor
|
return supervisor
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
def hash_api_key(key: str) -> str:
|
def hash_api_key(key: str) -> str:
|
||||||
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
||||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Measurement service - pass/fail calculation, data storage."""
|
"""Measurement service - pass/fail calculation, data storage."""
|
||||||
import math
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.utils.tempo import utcnow
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -344,7 +343,7 @@ async def authorise_measurement(
|
|||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||||
measurement.supervisor_id = supervisor.id
|
measurement.supervisor_id = supervisor.id
|
||||||
measurement.authorised_at = utcnow()
|
measurement.authorised_at = datetime.now()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(measurement)
|
await db.refresh(measurement)
|
||||||
return measurement
|
return measurement
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.utils.tempo import iso_utc, to_local, utcnow
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
@@ -170,9 +169,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
measurement.input_method,
|
measurement.input_method,
|
||||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||||
measurement.measured_by,
|
measurement.measured_by,
|
||||||
iso_utc(measurement.measured_at),
|
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||||
measurement.supervisor_id or "",
|
measurement.supervisor_id or "",
|
||||||
iso_utc(measurement.authorised_at),
|
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||||
])
|
])
|
||||||
|
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
@@ -192,7 +191,7 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
|
|||||||
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
|
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"production_{run.id}_{stamp}.csv"
|
filename = f"production_{run.id}_{stamp}.csv"
|
||||||
# A large run's file can take a moment to write: off the event loop, so
|
# A large run's file can take a moment to write: off the event loop, so
|
||||||
# closing one run does not stall every other request being served.
|
# closing one run does not stall every other request being served.
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ the countdown would be off by the UTC offset. The number is the contract; the
|
|||||||
timestamp is only there for the audit trail.
|
timestamp is only there for the audit trail.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from src.backend.utils.tempo import utcnow
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -35,10 +34,10 @@ from src.backend.services import (
|
|||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
"""Single source of time for runs.
|
"""Single source of time for runs.
|
||||||
|
|
||||||
Naive UTC, matching what the database writes for ``func.now()`` defaults,
|
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
||||||
so a run's started_at and its deadline are comparable.
|
so a run's started_at and its deadline are comparable.
|
||||||
"""
|
"""
|
||||||
return utcnow()
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.utils.tempo import to_local, utcnow
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
@@ -33,11 +32,6 @@ _jinja_env = Environment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _giorno_locale(dt: datetime) -> str:
|
|
||||||
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
|
|
||||||
return to_local(dt).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_measurements(
|
async def _query_measurements(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
@@ -48,7 +42,6 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58
|
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||||
filters = []
|
filters = []
|
||||||
@@ -74,9 +67,6 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
if production_run_id is not None:
|
|
||||||
filters.append(Measurement.production_run_id == production_run_id)
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -285,7 +275,6 @@ async def generate_spc_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate SPC PDF report and return bytes.
|
"""Generate SPC PDF report and return bytes.
|
||||||
|
|
||||||
@@ -295,7 +284,6 @@ async def generate_spc_report(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Compute SPC data
|
# 2. Compute SPC data
|
||||||
@@ -335,15 +323,13 @@ async def generate_spc_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
if production_run_id is not None:
|
|
||||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
|
||||||
|
|
||||||
# 6. Render HTML template
|
# 6. Render HTML template
|
||||||
template = _jinja_env.get_template("spc_report.html")
|
template = _jinja_env.get_template("spc_report.html")
|
||||||
@@ -356,7 +342,7 @@ async def generate_spc_report(
|
|||||||
control_chart_svg=control_chart_svg,
|
control_chart_svg=control_chart_svg,
|
||||||
histogram_svg=histogram_svg,
|
histogram_svg=histogram_svg,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=to_local(utcnow()),
|
generated_at=datetime.now(),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -375,14 +361,12 @@ async def generate_measurement_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate measurement table PDF report and return bytes."""
|
"""Generate measurement table PDF report and return bytes."""
|
||||||
# 1. Query measurements
|
# 1. Query measurements
|
||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Get recipe info
|
# 2. Get recipe info
|
||||||
@@ -411,7 +395,7 @@ async def generate_measurement_report(
|
|||||||
"value": f"{float(m.value):.4f}",
|
"value": f"{float(m.value):.4f}",
|
||||||
"unit": st_info.get("unit", "mm"),
|
"unit": st_info.get("unit", "mm"),
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
||||||
"lot_number": m.lot_number or "—",
|
"lot_number": m.lot_number or "—",
|
||||||
"serial_number": m.serial_number or "—",
|
"serial_number": m.serial_number or "—",
|
||||||
@@ -430,15 +414,13 @@ async def generate_measurement_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
if production_run_id is not None:
|
|
||||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
|
||||||
|
|
||||||
# 7. Render template
|
# 7. Render template
|
||||||
template = _jinja_env.get_template("measurement_report.html")
|
template = _jinja_env.get_template("measurement_report.html")
|
||||||
@@ -448,7 +430,7 @@ async def generate_measurement_report(
|
|||||||
rows=rows,
|
rows=rows,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=to_local(utcnow()),
|
generated_at=datetime.now(),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -211,79 +211,3 @@ class TestDateFilter:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# All measurements should be within this wide range
|
# All measurements should be within this wide range
|
||||||
assert resp.json()["total"] >= 2
|
assert resp.json()["total"] >= 2
|
||||||
|
|
||||||
|
|
||||||
class TestRunFilter:
|
|
||||||
"""#58: statistiche per sessione di produzione."""
|
|
||||||
|
|
||||||
async def _setup(self, db_session, user, metro):
|
|
||||||
from datetime import datetime
|
|
||||||
from src.backend.models.orm.production import ProductionRun
|
|
||||||
from src.backend.models.orm.station import Station
|
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
|
||||||
from sqlalchemy import select
|
|
||||||
recipe = await create_test_recipe(db_session, user.id)
|
|
||||||
version = (await db_session.execute(
|
|
||||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
|
||||||
)).scalars().first()
|
|
||||||
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
|
||||||
db_session.add(station)
|
|
||||||
await db_session.commit()
|
|
||||||
runs = []
|
|
||||||
for i, lot in enumerate(["L1", "L2"]):
|
|
||||||
run = ProductionRun(
|
|
||||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
|
||||||
operator_id=user.id, status="closed", lot_number=lot,
|
|
||||||
started_at=datetime(2026, 1, 1 + i),
|
|
||||||
)
|
|
||||||
db_session.add(run)
|
|
||||||
runs.append(run)
|
|
||||||
await db_session.commit()
|
|
||||||
return recipe, runs
|
|
||||||
|
|
||||||
async def test_filter_by_run_and_unfiltered(
|
|
||||||
self, client, measurement_tec_user, metrologist_user, db_session,
|
|
||||||
):
|
|
||||||
from src.backend.models.orm.measurement import Measurement
|
|
||||||
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
|
||||||
subtask_id, version_id = await _seed_measurements(
|
|
||||||
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
|
||||||
)
|
|
||||||
# Assegna le prime due misure al run 1, la terza al run 2.
|
|
||||||
from sqlalchemy import select
|
|
||||||
ms = (await db_session.execute(
|
|
||||||
select(Measurement).order_by(Measurement.id)
|
|
||||||
)).scalars().all()
|
|
||||||
ms[0].production_run_id = runs[0].id
|
|
||||||
ms[1].production_run_id = runs[0].id
|
|
||||||
ms[2].production_run_id = runs[1].id
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
h = auth_headers(metrologist_user)
|
|
||||||
r1 = await client.get("/api/statistics/summary", headers=h,
|
|
||||||
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
|
||||||
assert r1.json()["total"] == 2
|
|
||||||
r2 = await client.get("/api/statistics/summary", headers=h,
|
|
||||||
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
|
||||||
assert r2.json()["total"] == 1
|
|
||||||
r_all = await client.get("/api/statistics/summary", headers=h,
|
|
||||||
params={"recipe_id": recipe.id})
|
|
||||||
assert r_all.json()["total"] == 3
|
|
||||||
|
|
||||||
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
|
||||||
params={"recipe_id": recipe.id})
|
|
||||||
assert runs_resp.status_code == 200
|
|
||||||
data = runs_resp.json()
|
|
||||||
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
|
||||||
assert [r["measurement_count"] for r in data] == [1, 2]
|
|
||||||
assert data[0]["lot_number"] == "L2"
|
|
||||||
assert data[0]["station_name"] == "Stazione 58"
|
|
||||||
assert data[0]["closed_at"] is None
|
|
||||||
|
|
||||||
async def test_runs_require_metrologist(
|
|
||||||
self, client, measurement_tec_user, db_session,
|
|
||||||
):
|
|
||||||
resp = await client.get("/api/statistics/runs",
|
|
||||||
headers=auth_headers(measurement_tec_user),
|
|
||||||
params={"recipe_id": 1})
|
|
||||||
assert resp.status_code == 403
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.backend.models.orm.user import User
|
|
||||||
from src.backend.services import report_service
|
|
||||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
|
||||||
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
|
|
||||||
|
|
||||||
|
|
||||||
def test_utcnow_naive_and_close_to_utc():
|
|
||||||
now = utcnow()
|
|
||||||
assert now.tzinfo is None
|
|
||||||
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
|
|
||||||
assert abs(delta.total_seconds()) < 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_as_utc_naive():
|
|
||||||
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
|
|
||||||
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
|
|
||||||
assert as_utc_naive(parsed).tzinfo is None
|
|
||||||
naive = datetime(2026, 10, 6, 8, 0)
|
|
||||||
assert as_utc_naive(naive) is naive
|
|
||||||
assert as_utc_naive(None) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_local_is_rome():
|
|
||||||
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
|
|
||||||
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
|
|
||||||
|
|
||||||
|
|
||||||
async def _make_measurement(client, user, recipe_id) -> None:
|
|
||||||
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
|
|
||||||
await client.post(
|
|
||||||
"/api/measurements/",
|
|
||||||
headers=auth_headers(user),
|
|
||||||
json={
|
|
||||||
"subtask_id": tasks[0]["subtasks"][0]["id"],
|
|
||||||
"version_id": tasks[0]["version_id"],
|
|
||||||
"value": 10.0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
|
|
||||||
client: AsyncClient,
|
|
||||||
measurement_tec_user: User,
|
|
||||||
metrologist_user: User,
|
|
||||||
db_session: AsyncSession,
|
|
||||||
):
|
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
||||||
await _make_measurement(client, measurement_tec_user, recipe.id)
|
|
||||||
|
|
||||||
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
|
|
||||||
item = resp.json()["items"][0]
|
|
||||||
assert item["measured_at"].endswith("+00:00")
|
|
||||||
|
|
||||||
# Same instant written with +02:00 and as UTC: identical results.
|
|
||||||
future_utc = "2099-01-01T08:00:00+00:00"
|
|
||||||
future_rome = "2099-01-01T10:00:00+02:00"
|
|
||||||
past_utc = "2000-01-01T08:00:00+00:00"
|
|
||||||
past_rome = "2000-01-01T10:00:00+02:00"
|
|
||||||
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
|
|
||||||
r = await client.get(
|
|
||||||
"/api/measurements/",
|
|
||||||
headers=auth_headers(metrologist_user),
|
|
||||||
params={"date_from": frm, "date_to": to},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["total"] == 1
|
|
||||||
|
|
||||||
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
|
|
||||||
# written as +02:00 (now+3h local wall clock), must exclude it.
|
|
||||||
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
|
|
||||||
from datetime import timedelta
|
|
||||||
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
|
|
||||||
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
|
|
||||||
timezone(timedelta(hours=2))
|
|
||||||
).isoformat()
|
|
||||||
totals = []
|
|
||||||
for frm in (ahead_utc, ahead_rome):
|
|
||||||
r = await client.get(
|
|
||||||
"/api/measurements/",
|
|
||||||
headers=auth_headers(metrologist_user),
|
|
||||||
params={"date_from": frm},
|
|
||||||
)
|
|
||||||
totals.append(r.json()["total"])
|
|
||||||
assert totals == [0, 0]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_report_generated_at_is_local_time(
|
|
||||||
db_session: AsyncSession, metrologist_user: User
|
|
||||||
):
|
|
||||||
recipe = await create_test_recipe(db_session, metrologist_user.id)
|
|
||||||
captured: dict[str, str] = {}
|
|
||||||
|
|
||||||
def fake_render(html: str) -> bytes:
|
|
||||||
captured["html"] = html
|
|
||||||
return b"%PDF"
|
|
||||||
|
|
||||||
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
|
|
||||||
patch.object(report_service, "_render_pdf", side_effect=fake_render):
|
|
||||||
await report_service.generate_measurement_report(db_session, recipe.id)
|
|
||||||
|
|
||||||
assert "Generated 2026-10-06 10:00" in captured["html"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_report_filter_day_is_local():
|
|
||||||
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
|
|
||||||
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from src.backend.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
|
|
||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def as_utc_naive(dt: datetime | None) -> datetime | None:
|
|
||||||
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
|
|
||||||
if dt is None or dt.tzinfo is None:
|
|
||||||
return dt
|
|
||||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def to_utc_aware(dt: datetime) -> datetime:
|
|
||||||
"""Naive (UTC) or aware datetime -> aware UTC."""
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
return dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def iso_utc(dt: datetime | None) -> str:
|
|
||||||
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
|
|
||||||
return to_utc_aware(dt).isoformat() if dt else ""
|
|
||||||
|
|
||||||
|
|
||||||
def to_local(dt: datetime) -> datetime:
|
|
||||||
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
|
|
||||||
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
|
|
||||||
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
|
|||||||
for key in [
|
for key in [
|
||||||
"recipe_id", "version_id", "subtask_id",
|
"recipe_id", "version_id", "subtask_id",
|
||||||
"date_from", "date_to", "operator_id",
|
"date_from", "date_to", "operator_id",
|
||||||
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
|
"lot_number", "serial_number", "n_bins",
|
||||||
]:
|
]:
|
||||||
val = request.args.get(key)
|
val = request.args.get(key)
|
||||||
if val is not None and val != "":
|
if val is not None and val != "":
|
||||||
@@ -127,27 +127,6 @@ def api_subtasks():
|
|||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
@statistics_bp.route("/api/runs")
|
|
||||||
@login_required
|
|
||||||
@role_required("Metrologist")
|
|
||||||
def api_runs():
|
|
||||||
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
|
|
||||||
params = {}
|
|
||||||
recipe_id = request.args.get("recipe_id")
|
|
||||||
if recipe_id:
|
|
||||||
params["recipe_id"] = recipe_id
|
|
||||||
|
|
||||||
resp = api_client.get("/api/statistics/runs", params=params)
|
|
||||||
|
|
||||||
if isinstance(resp, dict) and resp.get("error"):
|
|
||||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
|
||||||
if resp.get("session_expired"):
|
|
||||||
error_body["session_expired"] = True
|
|
||||||
return jsonify(error_body), resp.get("status_code", 500)
|
|
||||||
|
|
||||||
return jsonify(resp)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PROXY REPORT PDF → FastAPI
|
# PROXY REPORT PDF → FastAPI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -158,7 +137,7 @@ def _proxy_report(endpoint: str):
|
|||||||
for key in [
|
for key in [
|
||||||
"recipe_id", "version_id", "subtask_id",
|
"recipe_id", "version_id", "subtask_id",
|
||||||
"date_from", "date_to", "operator_id",
|
"date_from", "date_to", "operator_id",
|
||||||
"lot_number", "serial_number", "production_run_id", # #58
|
"lot_number", "serial_number",
|
||||||
]:
|
]:
|
||||||
val = request.args.get(key)
|
val = request.args.get(key)
|
||||||
if val is not None and val != "":
|
if val is not None and val != "":
|
||||||
|
|||||||
@@ -35,19 +35,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sessione di produzione (#58) -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
|
|
||||||
<select x-model="selectedRunId"
|
|
||||||
:disabled="runs.length === 0"
|
|
||||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
|
||||||
<option value="">{{ _("Tutte le sessioni") }}</option>
|
|
||||||
<template x-for="r in runs" :key="r.id">
|
|
||||||
<option :value="r.id" x-text="runLabel(r)"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subtask -->
|
<!-- Subtask -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||||
@@ -271,8 +258,6 @@ function spcDashboard() {
|
|||||||
// Filter state
|
// Filter state
|
||||||
selectedRecipeId: '',
|
selectedRecipeId: '',
|
||||||
selectedSubtaskId: '',
|
selectedSubtaskId: '',
|
||||||
selectedRunId: '', // #58
|
|
||||||
runs: [], // #58 sessioni della ricetta
|
|
||||||
dateFrom: '',
|
dateFrom: '',
|
||||||
dateTo: '',
|
dateTo: '',
|
||||||
subtasks: [],
|
subtasks: [],
|
||||||
@@ -296,8 +281,6 @@ function spcDashboard() {
|
|||||||
|
|
||||||
async onRecipeChange() {
|
async onRecipeChange() {
|
||||||
this.selectedSubtaskId = '';
|
this.selectedSubtaskId = '';
|
||||||
this.selectedRunId = ''; // #58
|
|
||||||
this.runs = [];
|
|
||||||
this.subtasks = [];
|
this.subtasks = [];
|
||||||
this.hasData = false;
|
this.hasData = false;
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
@@ -307,15 +290,6 @@ function spcDashboard() {
|
|||||||
|
|
||||||
if (!this.selectedRecipeId) return;
|
if (!this.selectedRecipeId) return;
|
||||||
|
|
||||||
// #58: sessioni della ricetta per il filtro «Sessione»
|
|
||||||
try {
|
|
||||||
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
|
|
||||||
const rdata = await rresp.json();
|
|
||||||
if (Array.isArray(rdata)) this.runs = rdata;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading runs:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load subtasks for dropdown
|
// Load subtasks for dropdown
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
||||||
@@ -328,12 +302,6 @@ function spcDashboard() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// #58: etichetta sessione = data, stazione, operatore, lotto
|
|
||||||
runLabel(r) {
|
|
||||||
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
|
||||||
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadData() {
|
async loadData() {
|
||||||
if (!this.selectedRecipeId) return;
|
if (!this.selectedRecipeId) return;
|
||||||
|
|
||||||
@@ -344,7 +312,6 @@ function spcDashboard() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('recipe_id', this.selectedRecipeId);
|
params.set('recipe_id', this.selectedRecipeId);
|
||||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
|
||||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
|
||||||
@@ -450,7 +417,6 @@ function spcDashboard() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('recipe_id', this.selectedRecipeId);
|
params.set('recipe_id', this.selectedRecipeId);
|
||||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
|
||||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
|
||||||
|
|||||||
@@ -112,21 +112,3 @@ class TestSPCProxy:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert "cp" in data
|
assert "cp" in data
|
||||||
|
|
||||||
|
|
||||||
class TestRunsProxy:
|
|
||||||
"""#58: filtro per sessione di produzione."""
|
|
||||||
|
|
||||||
def test_runs_proxy(self, logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
|
|
||||||
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json()[0]["id"] == 5
|
|
||||||
args, kwargs = mock_api_client.get.call_args
|
|
||||||
assert args[0] == "/api/statistics/runs"
|
|
||||||
assert kwargs["params"] == {"recipe_id": "7"}
|
|
||||||
|
|
||||||
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = {"total": 1}
|
|
||||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
|
||||||
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user