Add read-only ERP workplace status adapter

This commit is contained in:
2026-09-05 16:26:15 +02:00
parent b89d57d1c1
commit 9fea9cb6ba
5 changed files with 382 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
"""Read-only ERP current workplace context."""
from production_analytics.erp.gateway import (
CurrentWorkplaceStatus,
ErpReadError,
ErpSettings,
ErpWorkplaceStatusGateway,
)
__all__ = ["CurrentWorkplaceStatus", "ErpReadError", "ErpSettings", "ErpWorkplaceStatusGateway"]
+148
View File
@@ -0,0 +1,148 @@
"""Focused MSSQL boundary for dbo.GRAFANA_WORKPLACE_STATUS."""
import os
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from math import isfinite
import pymssql
from production_analytics.enlyze.exploration import ConfigurationError, load_secret_file
@dataclass(frozen=True, slots=True)
class ErpSettings:
host: str
port: int
database: str
user: str = field(repr=False)
password: str = field(repr=False)
def __post_init__(self) -> None:
for attribute, suffix in (
("host", "HOST"), ("database", "NAME"), ("user", "USER"), ("password", "PASSWORD"),
):
value = getattr(self, attribute)
if not isinstance(value, str) or not value.strip() or "\x00" in value:
raise ConfigurationError(f"ERP_DB_{suffix} must be non-empty without NUL bytes")
if type(self.port) is not int or not 1 <= self.port <= 65535:
raise ConfigurationError("ERP_DB_PORT must be an integer from 1 to 65535")
@classmethod
def from_environment(cls, environment: Mapping[str, str]) -> "ErpSettings":
raw_port = environment.get("ERP_DB_PORT", "")
try:
if not isinstance(raw_port, str):
raise ValueError
port = int(raw_port)
except ValueError:
raise ConfigurationError("ERP_DB_PORT must be an integer from 1 to 65535") from None
return cls(
host=environment.get("ERP_DB_HOST", ""), port=port,
database=environment.get("ERP_DB_NAME", ""),
user=environment.get("ERP_DB_USER", ""),
password=environment.get("ERP_DB_PASSWORD", ""),
)
@classmethod
def from_secret_file(
cls, path: str | os.PathLike[str] = "secrets/erp.env",
*, environment: Mapping[str, str] | None = None,
) -> "ErpSettings":
"""Load dotenv values over the environment, following the existing convention."""
try:
values = load_secret_file(path)
except Exception:
raise ConfigurationError("ERP secret file could not be loaded") from None
base = os.environ if environment is None else environment
return cls.from_environment({**base, **values})
@dataclass(frozen=True, slots=True)
class CurrentWorkplaceStatus:
"""Latest ERP feedback; timestamp and timezone are preserved, with no freshness claim."""
workplace: str
production_order: str
article_number: str | None
article_description: str | None
feedback_timestamp: datetime
order_quantity_m2: float | None
good_quantity_m2: float | None
remaining_quantity_m2: float | None
remaining_time_hours: float | None
remaining_rolls: float | None
class ErpReadError(RuntimeError):
"""Safe operator-facing ERP read or result error."""
_CURRENT_STATUS_SQL = """SELECT
[Arbeitsplatz], [Fertigungsauftragsnummer], [Artikelnummer], [Artikelbezeichnung],
[Zeitstempel], [Auftragsmenge], [Gutmenge], [Restmenge],
[Verbleibende Zeit], [Verbleibende Rollen]
FROM [dbo].[GRAFANA_WORKPLACE_STATUS]
WHERE [Arbeitsplatz] = %s"""
class ErpWorkplaceStatusGateway:
def __init__(self, settings: ErpSettings) -> None:
self._settings = settings
def get_current_workplace_status(self, workplace: str) -> CurrentWorkplaceStatus | None:
if not isinstance(workplace, str) or not workplace.strip() or "\x00" in workplace:
raise ValueError("workplace must be a non-empty string without NUL bytes")
try:
with pymssql.connect(
server=self._settings.host, port=self._settings.port,
database=self._settings.database, user=self._settings.user,
password=self._settings.password, login_timeout=10, timeout=10,
) as connection:
with connection.cursor() as cursor:
cursor.execute(_CURRENT_STATUS_SQL, (workplace,))
# Two rows suffice to detect a violation without selecting an arbitrary row.
rows = cursor.fetchmany(2)
except Exception:
# Driver messages may include credentials; suppress their traceback chain too.
raise ErpReadError("ERP current workplace status read failed") from None
if not rows:
return None
if len(rows) > 1:
raise ErpReadError("ERP current workplace status returned multiple rows")
try:
row = rows[0]
if len(row) != 10 or not isinstance(row[4], datetime):
raise ValueError
return CurrentWorkplaceStatus(
workplace=_text(row[0]), production_order=_text(row[1]),
article_number=_text(row[2], nullable=True),
article_description=_text(row[3], nullable=True),
feedback_timestamp=row[4],
order_quantity_m2=_number(row[5]), good_quantity_m2=_number(row[6]),
remaining_quantity_m2=_number(row[7]), remaining_time_hours=_number(row[8]),
remaining_rolls=_number(row[9]),
)
except Exception:
raise ErpReadError("ERP current workplace status returned an invalid row") from None
def _text(value: object, *, nullable: bool = False) -> str | None:
if value is None and nullable:
return None
if not isinstance(value, str):
raise ValueError
return value.rstrip()
def _number(value: object) -> float | None:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (Decimal, int, float)):
raise ValueError
result = float(value)
if not isfinite(result):
raise ValueError
return result