Add read-only ERP workplace status adapter
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user