Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Zootech catalog API — WESP-compatible shape for copied static UI."""
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.zootech.catalog_models import (
|
||||
ZootechDailyComponentNormAdjustment,
|
||||
ZootechDailyIngredientReplacement,
|
||||
ZootechDailyIngredientSkip,
|
||||
ZootechDailyTripSkip,
|
||||
ZootechDailyUnloadingGroupSkip,
|
||||
ZootechFeedDispenser,
|
||||
ZootechFeedingLocation,
|
||||
ZootechFeedingPeriod,
|
||||
ZootechFeedingPoint,
|
||||
ZootechFeedMixer,
|
||||
ZootechPeriodRecipe,
|
||||
ZootechTrip,
|
||||
ZootechUnloadingGroup,
|
||||
)
|
||||
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||
|
||||
TABLE_MODEL_MAP = {
|
||||
"component": ZootechComponent,
|
||||
"recipe": ZootechRecipe,
|
||||
"ingredient": ZootechIngredient,
|
||||
"unloading_group": ZootechUnloadingGroup,
|
||||
"feed_mixer": ZootechFeedMixer,
|
||||
"feed_dispenser": ZootechFeedDispenser,
|
||||
"feeding_location": ZootechFeedingLocation,
|
||||
"feeding_period": ZootechFeedingPeriod,
|
||||
"feeding_point": ZootechFeedingPoint,
|
||||
"period_recipes": ZootechPeriodRecipe,
|
||||
"daily_trip_skip": ZootechDailyTripSkip,
|
||||
"daily_ingredient_skip": ZootechDailyIngredientSkip,
|
||||
"daily_unloading_group_skip": ZootechDailyUnloadingGroupSkip,
|
||||
"daily_ingredient_replacement": ZootechDailyIngredientReplacement,
|
||||
"daily_component_norm_adjustment": ZootechDailyComponentNormAdjustment,
|
||||
"trip": ZootechTrip,
|
||||
}
|
||||
|
||||
|
||||
def _coerce_for_model(model, key: str, value: Any) -> Any:
|
||||
if value is None:
|
||||
return value
|
||||
column = model.__table__.columns.get(key)
|
||||
if column is None:
|
||||
return value
|
||||
if isinstance(column.type, DateTime) and isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return datetime.now(UTC)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_period_recipe_id(record_id: str) -> tuple[str, str]:
|
||||
if ":" in record_id:
|
||||
period_id, recipe_id = record_id.split(":", 1)
|
||||
return period_id, recipe_id
|
||||
return record_id, record_id
|
||||
|
||||
|
||||
def load_catalog_row(enterprise_id: str, table_name: str, record_id: str) -> dict[str, Any] | None:
|
||||
model = TABLE_MODEL_MAP.get(table_name)
|
||||
if not model:
|
||||
return None
|
||||
with session_scope() as db:
|
||||
if model is ZootechPeriodRecipe:
|
||||
period_id, recipe_id = _parse_period_recipe_id(record_id)
|
||||
row = db.scalar(
|
||||
select(model).where(
|
||||
model.enterprise_id == enterprise_id,
|
||||
model.period_id == period_id,
|
||||
model.recipe_id == recipe_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
row = db.scalar(
|
||||
select(model).where(model.enterprise_id == enterprise_id, model.id == record_id)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row, table_name)
|
||||
|
||||
|
||||
def apply_catalog_change(
|
||||
enterprise_id: str,
|
||||
table_name: str,
|
||||
record_id: str,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
version: int,
|
||||
content_hash: str,
|
||||
) -> None:
|
||||
model = TABLE_MODEL_MAP.get(table_name)
|
||||
if not model:
|
||||
return
|
||||
with session_scope() as db:
|
||||
if model is ZootechPeriodRecipe:
|
||||
period_id, recipe_id = _parse_period_recipe_id(record_id)
|
||||
row = db.scalar(
|
||||
select(model).where(
|
||||
model.enterprise_id == enterprise_id,
|
||||
model.period_id == period_id,
|
||||
model.recipe_id == recipe_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
row = db.scalar(
|
||||
select(model).where(model.enterprise_id == enterprise_id, model.id == record_id)
|
||||
)
|
||||
if action == "delete":
|
||||
if row:
|
||||
row.is_deleted = True
|
||||
row.version = version
|
||||
row.content_hash = content_hash
|
||||
row.updated_at = datetime.now(UTC)
|
||||
return
|
||||
data = dict(payload)
|
||||
data["enterprise_id"] = enterprise_id
|
||||
data["version"] = version
|
||||
data["content_hash"] = content_hash
|
||||
data["is_deleted"] = False
|
||||
if model is ZootechPeriodRecipe:
|
||||
data["period_id"] = data.get("period_id") or _parse_period_recipe_id(record_id)[0]
|
||||
data["recipe_id"] = data.get("recipe_id") or _parse_period_recipe_id(record_id)[1]
|
||||
else:
|
||||
data["id"] = record_id
|
||||
if row:
|
||||
_apply_fields(row, data, model)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
allowed = {c.key for c in model.__table__.columns}
|
||||
filtered = {
|
||||
k: _coerce_for_model(model, k, v) for k, v in data.items() if k in allowed
|
||||
}
|
||||
extra = {k: v for k, v in data.items() if k not in allowed}
|
||||
if extra and "payload_json" in allowed:
|
||||
filtered["payload_json"] = json.dumps(extra, ensure_ascii=False)
|
||||
db.add(model(**filtered))
|
||||
|
||||
|
||||
def _apply_fields(row, data: dict[str, Any], model) -> None:
|
||||
allowed = {c.key for c in model.__table__.columns}
|
||||
extra: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key in allowed and key not in ("enterprise_id", "created_at"):
|
||||
setattr(row, key, _coerce_for_model(model, key, value))
|
||||
elif key not in ("enterprise_id", "created_at", "id"):
|
||||
extra[key] = value
|
||||
if model is ZootechRecipe and "ingredients" in data:
|
||||
row.payload_json = json.dumps(data.get("ingredients"), ensure_ascii=False)
|
||||
elif extra and "payload_json" in allowed:
|
||||
row.payload_json = json.dumps(extra, ensure_ascii=False)
|
||||
|
||||
|
||||
def _row_to_dict(row, table_name: str) -> dict[str, Any]:
|
||||
result = {}
|
||||
for col in row.__table__.columns:
|
||||
val = getattr(row, col.key)
|
||||
if isinstance(val, datetime):
|
||||
val = val.isoformat()
|
||||
result[col.key] = val
|
||||
if isinstance(row, ZootechRecipe) and row.payload_json:
|
||||
try:
|
||||
parsed = json.loads(row.payload_json)
|
||||
if isinstance(parsed, list):
|
||||
result["ingredients"] = parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if table_name == "period_recipes":
|
||||
result["id"] = f"{row.period_id}:{row.recipe_id}"
|
||||
elif hasattr(row, "payload_json") and row.payload_json and table_name != "recipe":
|
||||
try:
|
||||
result.update(json.loads(row.payload_json))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return result
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Additional zootech catalog tables (SERVER_MASTER_TABLES parity)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class _CatalogMixin:
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class ZootechUnloadingGroup(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_unloading_group"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_group_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
recipe_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
|
||||
|
||||
class ZootechFeedMixer(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_feed_mixer"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_mixer_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
|
||||
|
||||
class ZootechFeedDispenser(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_feed_dispenser"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_dispenser_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
|
||||
|
||||
class ZootechFeedingLocation(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_feeding_location"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_location_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
|
||||
|
||||
class ZootechFeedingPeriod(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_feeding_period"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_period_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
dispenser_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class ZootechFeedingPoint(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_feeding_point"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_point_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
period_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class ZootechPeriodRecipe(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_period_recipes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("enterprise_id", "period_id", "recipe_id", name="uq_zootech_period_recipes_ent"),
|
||||
)
|
||||
|
||||
period_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
recipe_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class ZootechTrip(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_trip"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_trip_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
mixer_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
recipe_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
|
||||
|
||||
class ZootechDailyTripSkip(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_daily_trip_skip"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_trip_skip_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
|
||||
|
||||
class ZootechDailyIngredientSkip(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_daily_ingredient_skip"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_ingredient_skip_ent_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
|
||||
|
||||
class ZootechDailyUnloadingGroupSkip(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_daily_unloading_group_skip"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_unloading_group_skip_ent_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
|
||||
|
||||
class ZootechDailyIngredientReplacement(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_daily_ingredient_replacement"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_ingredient_replacement_ent_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
|
||||
|
||||
class ZootechDailyComponentNormAdjustment(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_daily_component_norm_adjustment"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_component_norm_adjustment_ent_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
|
||||
|
||||
CATALOG_TABLE_ORDER = [
|
||||
"component",
|
||||
"recipe",
|
||||
"ingredient",
|
||||
"unloading_group",
|
||||
"feed_mixer",
|
||||
"feed_dispenser",
|
||||
"feeding_location",
|
||||
"feeding_period",
|
||||
"feeding_point",
|
||||
"period_recipes",
|
||||
"daily_trip_skip",
|
||||
"daily_ingredient_skip",
|
||||
"daily_unloading_group_skip",
|
||||
"daily_ingredient_replacement",
|
||||
"daily_component_norm_adjustment",
|
||||
"trip",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ZootechComponent(Base):
|
||||
__tablename__ = "zootech_component"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_component_ent_id"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
dry_matter: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
protein: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
energy: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
price: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
external_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class ZootechRecipe(Base):
|
||||
__tablename__ = "zootech_recipe"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_recipe_ent_id"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
heads_per_trip: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
mixing_time: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
trip_percent: Mapped[float] = mapped_column(Float, nullable=False, default=100.0)
|
||||
dry_matter_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
unloading_link_broken: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
target_component_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
ration_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class ZootechIngredient(Base):
|
||||
__tablename__ = "zootech_ingredient"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_ingredient_ent_id"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
recipe_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
component_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
weight_per_head: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
dry_matter: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
dry_matter_per_head: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Orchestrator system metrics in WESP admin-panel shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
_NET_STATE: dict[str, float | int] | None = None
|
||||
|
||||
|
||||
def _human_uptime(seconds: int | None) -> str | None:
|
||||
if seconds is None:
|
||||
return None
|
||||
sec = max(0, int(seconds))
|
||||
days, rem = divmod(sec, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, _ = divmod(rem, 60)
|
||||
parts: list[str] = []
|
||||
if days:
|
||||
parts.append(f"{days}d")
|
||||
if hours or days:
|
||||
parts.append(f"{hours}h")
|
||||
parts.append(f"{minutes}m")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def collect_orchestrator_system_metrics() -> dict[str, Any]:
|
||||
try:
|
||||
import psutil # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
return {
|
||||
"available": False,
|
||||
"message": "Установите пакет psutil (pip install psutil) для метрик CPU/RAM/диска.",
|
||||
}
|
||||
|
||||
global _NET_STATE
|
||||
now = time.time()
|
||||
boot_t = float(psutil.boot_time())
|
||||
host_uptime_sec = int(max(0.0, now - boot_t))
|
||||
|
||||
proc_uptime_sec = None
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
proc_uptime_sec = int(max(0.0, now - float(proc.create_time())))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
sw = psutil.swap_memory()
|
||||
|
||||
disk_block: dict[str, Any] | None
|
||||
try:
|
||||
du = psutil.disk_usage("/")
|
||||
disk_block = {
|
||||
"path": "/",
|
||||
"used": int(du.used),
|
||||
"total": int(du.total),
|
||||
"percent": round(du.percent, 2),
|
||||
}
|
||||
except OSError:
|
||||
disk_block = None
|
||||
|
||||
load_avg = None
|
||||
try:
|
||||
load_avg = [round(x, 2) for x in os.getloadavg()]
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
net = psutil.net_io_counters()
|
||||
upload_bps = 0.0
|
||||
download_bps = 0.0
|
||||
if _NET_STATE is not None and now > float(_NET_STATE["t"]):
|
||||
dt = now - float(_NET_STATE["t"])
|
||||
if dt > 0:
|
||||
upload_bps = max(0.0, (int(net.bytes_sent) - int(_NET_STATE["sent"])) / dt)
|
||||
download_bps = max(0.0, (int(net.bytes_recv) - int(_NET_STATE["recv"])) / dt)
|
||||
_NET_STATE = {"t": now, "sent": int(net.bytes_sent), "recv": int(net.bytes_recv)}
|
||||
|
||||
conn_counts: dict[str, Any] = {"tcp": "—", "udp": "—"}
|
||||
try:
|
||||
conn_counts = {
|
||||
"tcp": len(psutil.net_connections(kind="tcp")),
|
||||
"udp": len(psutil.net_connections(kind="udp")),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
addresses: list[dict[str, Any]] = []
|
||||
try:
|
||||
for name, addrs in psutil.net_if_addrs().items():
|
||||
for addr in addrs:
|
||||
if getattr(addr, "family", None) and str(getattr(addr, "address", "")).strip():
|
||||
addresses.append({"iface": name, "address": addr.address})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(now))),
|
||||
"host": {
|
||||
"boot_time_unix": int(boot_t),
|
||||
"uptime_seconds": host_uptime_sec,
|
||||
"uptime_human": _human_uptime(host_uptime_sec),
|
||||
},
|
||||
"process": {
|
||||
"pid": os.getpid(),
|
||||
"uptime_seconds": proc_uptime_sec,
|
||||
"uptime_human": _human_uptime(proc_uptime_sec) if proc_uptime_sec is not None else None,
|
||||
},
|
||||
"cpu": {
|
||||
"percent": round(float(psutil.cpu_percent(interval=0.1)), 2),
|
||||
"cores": int(psutil.cpu_count(logical=True) or 1),
|
||||
},
|
||||
"memory": {
|
||||
"used": int(vm.used),
|
||||
"total": int(vm.total),
|
||||
"percent": round(vm.percent, 2),
|
||||
},
|
||||
"swap": {
|
||||
"used": int(sw.used),
|
||||
"total": int(sw.total),
|
||||
"percent": round(sw.percent, 2) if sw.total else 0.0,
|
||||
},
|
||||
"disk": disk_block,
|
||||
"network": {
|
||||
"upload_bps": round(upload_bps, 2),
|
||||
"download_bps": round(download_bps, 2),
|
||||
"bytes_sent_total": int(net.bytes_sent),
|
||||
"bytes_recv_total": int(net.bytes_recv),
|
||||
},
|
||||
"load_avg": load_avg,
|
||||
"connections": conn_counts,
|
||||
"addresses": addresses,
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Модуль для расчетов рецептов кормления.
|
||||
|
||||
Перенесен из корневого recipe_calculator.py в пакет app.services
|
||||
без изменения алгоритмов.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _calc_debug_enabled() -> bool:
|
||||
"""
|
||||
Включает подробный трейс расчетов только по флагу окружения.
|
||||
Удобно для диагностики: set CALC_DEBUG=1
|
||||
"""
|
||||
return str(os.getenv("CALC_DEBUG", "")).strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def _calc_log(msg: str, **data) -> None:
|
||||
if not _calc_debug_enabled():
|
||||
return
|
||||
try:
|
||||
if data:
|
||||
logger.info(
|
||||
"[CALC] %s | %s",
|
||||
msg,
|
||||
json.dumps(data, ensure_ascii=False, default=str),
|
||||
)
|
||||
else:
|
||||
logger.info("[CALC] %s", msg)
|
||||
except Exception:
|
||||
# никогда не ломаем расчет из-за логов
|
||||
pass
|
||||
|
||||
|
||||
def round_to_step5(value: float) -> float:
|
||||
"""
|
||||
Округляет значение до кратного 5 кг
|
||||
"""
|
||||
return round(value / 5) * 5
|
||||
|
||||
|
||||
def calculate_ingredients(
|
||||
ingredients: List[Dict[str, Any]],
|
||||
heads_count: int,
|
||||
trip_percent: float,
|
||||
component_dry_matter_map: Optional[Dict[str, float]] = None,
|
||||
) -> List[Dict[str, float]]:
|
||||
"""
|
||||
Рассчитывает веса и сухое вещество для ингредиентов
|
||||
"""
|
||||
calculated_ingredients = []
|
||||
|
||||
_calc_log(
|
||||
"calculate_ingredients:start",
|
||||
heads_count=heads_count,
|
||||
trip_percent=trip_percent,
|
||||
ingredient_count=len(ingredients),
|
||||
)
|
||||
|
||||
for idx, ing in enumerate(ingredients, 1):
|
||||
weight_per_head_raw = ing.get("weightPerHead", 0)
|
||||
weight_per_head = float(weight_per_head_raw)
|
||||
dry_matter_percent = float(ing.get("dryMatter", 0))
|
||||
component_id = ing.get("component_id")
|
||||
_calc_log(
|
||||
"calculate_ingredients:weightPerHead:input",
|
||||
idx=idx,
|
||||
component_id=component_id,
|
||||
weightPerHead_raw=weight_per_head_raw,
|
||||
weightPerHead_raw_type=type(weight_per_head_raw).__name__,
|
||||
weightPerHead_float=weight_per_head,
|
||||
)
|
||||
|
||||
# Получаем dry_matter из компонента, если не указан и есть component_id
|
||||
dm_source = "payload"
|
||||
if dry_matter_percent == 0 and component_id and component_dry_matter_map:
|
||||
if component_id in component_dry_matter_map:
|
||||
dry_matter_percent = component_dry_matter_map[component_id]
|
||||
dm_source = "component_map"
|
||||
|
||||
# Расчеты
|
||||
weight = weight_per_head * heads_count
|
||||
trip_weight = weight * (trip_percent / 100)
|
||||
dry_matter_per_head = weight_per_head * (dry_matter_percent / 100)
|
||||
|
||||
if 0 < weight_per_head < 0.01:
|
||||
rounded_wph = round(weight_per_head, 3)
|
||||
else:
|
||||
rounded_wph = round(weight_per_head, 2)
|
||||
|
||||
if 0 < dry_matter_per_head < 0.01:
|
||||
rounded_dm_per_head = round(dry_matter_per_head, 4)
|
||||
else:
|
||||
rounded_dm_per_head = round(dry_matter_per_head, 4)
|
||||
|
||||
_calc_log(
|
||||
"ingredient:calc",
|
||||
idx=idx,
|
||||
component_id=component_id,
|
||||
dm_source=dm_source,
|
||||
input={
|
||||
"weightPerHead": weight_per_head,
|
||||
"dryMatterPercent": dry_matter_percent,
|
||||
"headsCount": heads_count,
|
||||
"tripPercent": trip_percent,
|
||||
},
|
||||
formula={
|
||||
"totalWeight": "weightPerHead * headsCount",
|
||||
"tripWeight": "totalWeight * (tripPercent/100)",
|
||||
"dryMatterPerHead": "weightPerHead * (dryMatterPercent/100)",
|
||||
},
|
||||
result={
|
||||
"totalWeight": round(weight, 2),
|
||||
"tripWeight": round(trip_weight, 2),
|
||||
"dryMatterPerHead": rounded_dm_per_head,
|
||||
"weightPerHead": rounded_wph,
|
||||
},
|
||||
)
|
||||
_calc_log(
|
||||
"calculate_ingredients:weightPerHead:output",
|
||||
idx=idx,
|
||||
component_id=component_id,
|
||||
weightPerHead_before_round=weight_per_head,
|
||||
rounded_wph=rounded_wph,
|
||||
rounded_wph_type=type(rounded_wph).__name__,
|
||||
)
|
||||
|
||||
calculated_ingredients.append(
|
||||
{
|
||||
"totalWeight": round(weight, 2),
|
||||
"tripWeight": round(trip_weight, 2),
|
||||
"dryMatterPerHead": rounded_dm_per_head,
|
||||
"weightPerHead": rounded_wph,
|
||||
}
|
||||
)
|
||||
|
||||
_calc_log("calculate_ingredients:end", ingredient_count=len(calculated_ingredients))
|
||||
return calculated_ingredients
|
||||
|
||||
|
||||
def calculate_totals(
|
||||
calculated_ingredients: List[Dict[str, float]],
|
||||
ingredients: List[Dict[str, Any]],
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Рассчитывает общие итоги по ингредиентам
|
||||
"""
|
||||
total_weight = 0.0
|
||||
total_trip_weight = 0.0
|
||||
total_dry_matter_per_head = 0.0
|
||||
total_weight_per_head = 0.0
|
||||
|
||||
_calc_log(
|
||||
"calculate_totals:start", ingredient_count=len(calculated_ingredients)
|
||||
)
|
||||
|
||||
for i, calc in enumerate(calculated_ingredients):
|
||||
total_weight += calc["totalWeight"]
|
||||
total_trip_weight += calc["tripWeight"]
|
||||
total_dry_matter_per_head += calc["dryMatterPerHead"]
|
||||
|
||||
if "weightPerHead" in calc:
|
||||
total_weight_per_head += calc["weightPerHead"]
|
||||
elif i < len(ingredients):
|
||||
total_weight_per_head += float(ingredients[i].get("weightPerHead", 0))
|
||||
|
||||
totals = {
|
||||
"totalWeight": round(total_weight, 2),
|
||||
"totalTripWeight": round(total_trip_weight, 2),
|
||||
"totalDryMatterPerHead": round(total_dry_matter_per_head, 2),
|
||||
"totalWeightPerHead": round(total_weight_per_head, 2),
|
||||
}
|
||||
|
||||
_calc_log("calculate_totals:end", totals=totals)
|
||||
return totals
|
||||
|
||||
|
||||
def calculate_unloading_groups(
|
||||
unloading_groups: List[Dict[str, Any]],
|
||||
total_trip_weight: float,
|
||||
heads_count: int,
|
||||
) -> Tuple[List[Dict[str, float]], Dict[str, Any]]:
|
||||
"""
|
||||
Рассчитывает веса для групп выгрузки.
|
||||
"""
|
||||
calculated_groups: List[Dict[str, float]] = []
|
||||
total_percent = 0.0
|
||||
total_heads = 0.0
|
||||
total_weight_kg = 0.0
|
||||
|
||||
for group in unloading_groups:
|
||||
group_type = group.get("distributionType", "percent")
|
||||
value = float(group.get("value", 0))
|
||||
|
||||
calculated_weight = 0.0
|
||||
if group_type == "percent" and value > 0:
|
||||
calculated_weight = (total_trip_weight * value) / 100.0
|
||||
elif group_type == "heads" and value > 0 and heads_count > 0:
|
||||
calculated_weight = (total_trip_weight / heads_count) * value
|
||||
|
||||
calculated_weight_rounded = round_to_step5(calculated_weight)
|
||||
|
||||
calculated_groups.append({"calculatedWeight": calculated_weight_rounded})
|
||||
|
||||
if group_type == "percent":
|
||||
total_percent += value
|
||||
else:
|
||||
total_heads += value
|
||||
|
||||
total_weight_kg += calculated_weight
|
||||
|
||||
unloading_totals = {
|
||||
"totalPercent": round(total_percent, 1),
|
||||
"totalHeads": int(total_heads),
|
||||
"totalWeightKg": round_to_step5(total_weight_kg),
|
||||
}
|
||||
|
||||
return calculated_groups, unloading_totals
|
||||
|
||||
|
||||
def calculate_recipe(
|
||||
ingredients: List[Dict[str, Any]],
|
||||
heads_count: int,
|
||||
trip_percent: float,
|
||||
unloading_groups: Optional[List[Dict[str, Any]]] = None,
|
||||
component_dry_matter_map: Optional[Dict[str, float]] = None,
|
||||
calculate_from_dry_matter: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Главная функция для расчета всего рецепта.
|
||||
"""
|
||||
_calc_log(
|
||||
"calculate_recipe:start",
|
||||
heads_count=heads_count,
|
||||
trip_percent=trip_percent,
|
||||
calculate_from_dry_matter=calculate_from_dry_matter,
|
||||
ingredient_count=len(ingredients or []),
|
||||
unloading_group_count=len(unloading_groups or []),
|
||||
)
|
||||
|
||||
if unloading_groups is None:
|
||||
unloading_groups = []
|
||||
|
||||
if calculate_from_dry_matter:
|
||||
calculated_ingredients = calculate_ingredients_from_dry_matter(
|
||||
ingredients, heads_count, trip_percent, component_dry_matter_map
|
||||
)
|
||||
else:
|
||||
calculated_ingredients = calculate_ingredients(
|
||||
ingredients, heads_count, trip_percent, component_dry_matter_map
|
||||
)
|
||||
|
||||
totals = calculate_totals(calculated_ingredients, ingredients)
|
||||
|
||||
calculated_groups, unloading_totals = calculate_unloading_groups(
|
||||
unloading_groups, totals["totalTripWeight"], heads_count
|
||||
)
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"ingredients": calculated_ingredients,
|
||||
"totals": totals,
|
||||
"unloadingGroups": calculated_groups,
|
||||
"unloadingTotals": unloading_totals,
|
||||
}
|
||||
|
||||
_calc_log("calculate_recipe:end", totals=totals, unloadingTotals=unloading_totals)
|
||||
return result
|
||||
|
||||
|
||||
def calculate_ingredients_from_dry_matter(
|
||||
ingredients: List[Dict[str, Any]],
|
||||
heads_count: int,
|
||||
trip_percent: float,
|
||||
component_dry_matter_map: Optional[Dict[str, float]] = None,
|
||||
) -> List[Dict[str, float]]:
|
||||
"""
|
||||
Рассчитывает веса от сухого вещества (обратный расчет).
|
||||
"""
|
||||
calculated_ingredients: List[Dict[str, float]] = []
|
||||
|
||||
_calc_log(
|
||||
"calculate_ingredients_from_dry_matter:start",
|
||||
heads_count=heads_count,
|
||||
trip_percent=trip_percent,
|
||||
ingredient_count=len(ingredients),
|
||||
)
|
||||
|
||||
for idx, ing in enumerate(ingredients, 1):
|
||||
dry_matter_per_head_raw = ing.get("dryMatterPerHead", 0)
|
||||
dry_matter_per_head = float(dry_matter_per_head_raw)
|
||||
dry_matter_percent = float(ing.get("dryMatter", 0))
|
||||
component_id = ing.get("component_id")
|
||||
_calc_log(
|
||||
"calculate_ingredients_from_dry_matter:input",
|
||||
idx=idx,
|
||||
component_id=component_id,
|
||||
dryMatterPerHead_raw=dry_matter_per_head_raw,
|
||||
dryMatterPercent=dry_matter_percent,
|
||||
)
|
||||
|
||||
dm_source = "payload"
|
||||
if dry_matter_percent == 0 and component_id and component_dry_matter_map:
|
||||
if component_id in component_dry_matter_map:
|
||||
dry_matter_percent = component_dry_matter_map[component_id]
|
||||
dm_source = "component_map"
|
||||
|
||||
EPSILON = 1e-6
|
||||
|
||||
if dry_matter_percent > EPSILON:
|
||||
weight_per_head = (dry_matter_per_head * 100.0) / dry_matter_percent
|
||||
|
||||
if weight_per_head < 0.01 and dry_matter_per_head >= 0.001:
|
||||
weight_per_head = 0.01
|
||||
else:
|
||||
if dry_matter_per_head > EPSILON:
|
||||
_calc_log(
|
||||
"ingredient:warning_zero_dm_percent",
|
||||
idx=idx,
|
||||
component_id=component_id,
|
||||
dry_matter_per_head=dry_matter_per_head,
|
||||
message=(
|
||||
"dry_matter_percent равен 0, но dry_matter_per_head > 0 - "
|
||||
"веса обнулены"
|
||||
),
|
||||
)
|
||||
weight_per_head = 0.0
|
||||
|
||||
weight = weight_per_head * heads_count
|
||||
trip_weight = weight * (trip_percent / 100.0)
|
||||
|
||||
_calc_log(
|
||||
"ingredient:inverse_calc",
|
||||
idx=idx,
|
||||
component_id=component_id,
|
||||
dm_source=dm_source,
|
||||
input={
|
||||
"dryMatterPerHead": dry_matter_per_head,
|
||||
"dryMatterPercent": dry_matter_percent,
|
||||
"headsCount": heads_count,
|
||||
"tripPercent": trip_percent,
|
||||
},
|
||||
)
|
||||
|
||||
calculated_ingredients.append(
|
||||
{
|
||||
"totalWeight": round(weight, 2),
|
||||
"tripWeight": round(trip_weight, 2),
|
||||
"weightPerHead": round(weight_per_head, 2),
|
||||
"dryMatterPerHead": round(dry_matter_per_head, 4),
|
||||
}
|
||||
)
|
||||
|
||||
_calc_log(
|
||||
"calculate_ingredients_from_dry_matter:end",
|
||||
ingredient_count=len(calculated_ingredients),
|
||||
)
|
||||
return calculated_ingredients
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport, ZootechUnloadingReport
|
||||
|
||||
REPORT_TABLE_MAP = {
|
||||
"loading_report": ZootechLoadingReport,
|
||||
"unloading_report": ZootechUnloadingReport,
|
||||
"feed_alert": ZootechFeedAlert,
|
||||
}
|
||||
|
||||
|
||||
def _write_payload_json(row: Any, extra: dict[str, Any]) -> None:
|
||||
if not extra:
|
||||
return
|
||||
row.payload_json = json.dumps(extra, ensure_ascii=False)
|
||||
|
||||
|
||||
def repair_report_payloads_from_event_log(enterprise_id: str) -> int:
|
||||
"""Re-apply latest sync event payload per report row (one-time repair helper)."""
|
||||
from app.modules.sync.models import SyncEventLog
|
||||
|
||||
repaired = 0
|
||||
events: list[tuple[str, str, str, int, str, str | None]] = []
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(SyncEventLog)
|
||||
.where(
|
||||
SyncEventLog.enterprise_id == enterprise_id,
|
||||
SyncEventLog.table_name.in_(("loading_report", "unloading_report", "feed_alert")),
|
||||
)
|
||||
.order_by(SyncEventLog.record_id.asc(), SyncEventLog.received_at.desc())
|
||||
)
|
||||
)
|
||||
latest_by_record: dict[tuple[str, str], SyncEventLog] = {}
|
||||
for row in rows:
|
||||
key = (row.table_name, row.record_id)
|
||||
if key not in latest_by_record:
|
||||
latest_by_record[key] = row
|
||||
for (table_name, record_id), event in latest_by_record.items():
|
||||
events.append(
|
||||
(
|
||||
table_name,
|
||||
record_id,
|
||||
event.payload_json or "{}",
|
||||
int(event.version or 1),
|
||||
str(event.content_hash or ""),
|
||||
event.origin_site_id,
|
||||
)
|
||||
)
|
||||
|
||||
for table_name, record_id, payload_json, version, content_hash, origin_site_id in events:
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
apply_report_change(
|
||||
enterprise_id,
|
||||
table_name,
|
||||
record_id,
|
||||
"upsert",
|
||||
payload,
|
||||
version,
|
||||
content_hash,
|
||||
farm_hub_id=origin_site_id,
|
||||
)
|
||||
repaired += 1
|
||||
return repaired
|
||||
|
||||
|
||||
def apply_report_change(
|
||||
enterprise_id: str,
|
||||
table_name: str,
|
||||
record_id: str,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
version: int,
|
||||
content_hash: str,
|
||||
farm_hub_id: str | None = None,
|
||||
) -> None:
|
||||
model = REPORT_TABLE_MAP.get(table_name)
|
||||
if not model:
|
||||
return
|
||||
with session_scope() as db:
|
||||
row = db.scalar(
|
||||
select(model).where(model.enterprise_id == enterprise_id, model.id == record_id)
|
||||
)
|
||||
if action == "delete":
|
||||
if row:
|
||||
row.is_deleted = True
|
||||
row.version = version
|
||||
row.content_hash = content_hash
|
||||
row.updated_at = datetime.now(UTC)
|
||||
return
|
||||
data = dict(payload)
|
||||
data["id"] = record_id
|
||||
data["enterprise_id"] = enterprise_id
|
||||
data["farm_hub_id"] = farm_hub_id or data.get("farm_hub_id")
|
||||
data["version"] = version
|
||||
data["content_hash"] = content_hash
|
||||
data["is_deleted"] = False
|
||||
if table_name == "feed_alert":
|
||||
if data.get("event_type") and not data.get("alert_type"):
|
||||
data["alert_type"] = str(data["event_type"])
|
||||
if data.get("detail") and not data.get("message"):
|
||||
data["message"] = str(data["detail"])
|
||||
if row:
|
||||
allowed = {c.key for c in model.__table__.columns}
|
||||
extra = {k: v for k, v in data.items() if k not in allowed}
|
||||
for key, value in data.items():
|
||||
if key in allowed and key not in ("enterprise_id", "created_at"):
|
||||
setattr(row, key, value)
|
||||
if extra and "payload_json" in allowed:
|
||||
_write_payload_json(row, extra)
|
||||
# #region agent log
|
||||
try:
|
||||
import pathlib
|
||||
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"D","location":"report_apply.py:update","message":"report payload update","data":{"table":table_name,"record_id":record_id,"extra_keys":sorted(extra.keys()),"components_len":len(extra.get("components") or []) if isinstance(extra.get("components"), list) else None,"groups_len":len(extra.get("unloading_groups") or []) if isinstance(extra.get("unloading_groups"), list) else None},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
row.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
allowed = {c.key for c in model.__table__.columns}
|
||||
filtered = {k: v for k, v in data.items() if k in allowed}
|
||||
extra = {k: v for k, v in data.items() if k not in allowed}
|
||||
if extra and "payload_json" in allowed:
|
||||
filtered["payload_json"] = json.dumps(extra, ensure_ascii=False)
|
||||
db.add(model(**filtered))
|
||||
# #region agent log
|
||||
try:
|
||||
import pathlib
|
||||
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"D","location":"report_apply.py:insert","message":"report payload insert","data":{"table":table_name,"record_id":record_id,"components_len":len(extra.get("components") or []) if isinstance(extra.get("components"), list) else None},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ZootechLoadingReport(Base):
|
||||
__tablename__ = "zootech_loading_report"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_loading_report_ent_id"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
trip_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class ZootechUnloadingReport(Base):
|
||||
__tablename__ = "zootech_unloading_report"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_report_ent_id"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
trip_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class ZootechFeedAlert(Base):
|
||||
__tablename__ = "zootech_feed_alert"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_alert_ent_id"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
alert_type: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info")
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.tenant import TenantContext, get_tenant_context, require_enterprise_zootech
|
||||
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||
from app.modules.zootech import service as zootech_service
|
||||
from app.modules.zootech.service import ZootechServiceError
|
||||
from app.modules.zootech.models import ZootechComponent, ZootechRecipe
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_components(enterprise_id: str) -> list[dict]:
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ZootechComponent).where(
|
||||
ZootechComponent.enterprise_id == enterprise_id,
|
||||
ZootechComponent.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"type": row.type,
|
||||
"is_active": row.is_active,
|
||||
"dry_matter": row.dry_matter,
|
||||
"protein": row.protein,
|
||||
"energy": row.energy,
|
||||
"price": row.price,
|
||||
"external_no": row.external_no,
|
||||
"version": row.version,
|
||||
"content_hash": row.content_hash,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _list_recipes(enterprise_id: str) -> list[dict]:
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ZootechRecipe).where(
|
||||
ZootechRecipe.enterprise_id == enterprise_id,
|
||||
ZootechRecipe.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"heads_per_trip": row.heads_per_trip,
|
||||
"mixing_time": row.mixing_time,
|
||||
"trip_percent": row.trip_percent,
|
||||
"dry_matter_locked": row.dry_matter_locked,
|
||||
"version": row.version,
|
||||
"content_hash": row.content_hash,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/components")
|
||||
def list_components(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
return {"components": _list_components(enterprise_id)}
|
||||
|
||||
|
||||
@router.get("/components/{component_id}")
|
||||
def get_component(
|
||||
component_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
row = load_catalog_row(enterprise_id, "component", component_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/components")
|
||||
def create_component(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
return zootech_service.upsert_component(enterprise_id, None, body)
|
||||
|
||||
|
||||
@router.patch("/components/{component_id}")
|
||||
def update_component(
|
||||
component_id: str,
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
try:
|
||||
return zootech_service.patch_component(enterprise_id, component_id, body)
|
||||
except ZootechServiceError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
|
||||
|
||||
|
||||
@router.post("/recipes")
|
||||
def create_recipe(
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
return zootech_service.upsert_recipe(enterprise_id, None, body)
|
||||
|
||||
|
||||
@router.get("/recipes")
|
||||
def list_recipes(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
return {"recipes": _list_recipes(enterprise_id)}
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}")
|
||||
def get_recipe(
|
||||
recipe_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
row = load_catalog_row(enterprise_id, "recipe", recipe_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||
return row
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.exceptions import DomainError
|
||||
from app.modules.sync.engine import SyncEngine
|
||||
from app.modules.sync.schemas import ChangeEventIn
|
||||
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||
from app.modules.zootech.sync_content_hash import compute_content_hash
|
||||
|
||||
|
||||
class ZootechServiceError(DomainError):
|
||||
pass
|
||||
|
||||
|
||||
def _emit_change(enterprise_id: str, table: str, record_id: str, payload: dict, version: int, content_hash: str) -> None:
|
||||
event = ChangeEventIn(
|
||||
event_id=str(uuid4()),
|
||||
seq=None,
|
||||
domain="global",
|
||||
table=table,
|
||||
record_id=record_id,
|
||||
action="upsert",
|
||||
version=version,
|
||||
content_hash=content_hash,
|
||||
payload=payload,
|
||||
emitted_at=datetime.now(UTC),
|
||||
origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID,
|
||||
)
|
||||
SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator")
|
||||
|
||||
|
||||
def upsert_component(enterprise_id: str, record_id: str | None, body: dict) -> dict:
|
||||
rid = record_id or str(uuid4())
|
||||
existing = load_catalog_row(enterprise_id, "component", rid)
|
||||
version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1
|
||||
payload = {
|
||||
"id": rid,
|
||||
"name": body.get("name", (existing or {}).get("name", "")),
|
||||
"type": body.get("type", (existing or {}).get("type", "")),
|
||||
"is_active": body.get("is_active", (existing or {}).get("is_active", True)),
|
||||
"dry_matter": float(body.get("dry_matter", (existing or {}).get("dry_matter", 0.0))),
|
||||
"protein": float(body.get("protein", (existing or {}).get("protein", 0.0))),
|
||||
"energy": float(body.get("energy", (existing or {}).get("energy", 0.0))),
|
||||
"price": float(body.get("price", (existing or {}).get("price", 0.0))),
|
||||
"external_no": body.get("external_no", (existing or {}).get("external_no")),
|
||||
"version": version,
|
||||
}
|
||||
content_hash = compute_content_hash(payload)
|
||||
payload["content_hash"] = content_hash
|
||||
_emit_change(enterprise_id, "component", rid, payload, version, content_hash)
|
||||
return load_catalog_row(enterprise_id, "component", rid) or payload
|
||||
|
||||
|
||||
def patch_component(enterprise_id: str, record_id: str, body: dict) -> dict:
|
||||
existing = load_catalog_row(enterprise_id, "component", record_id)
|
||||
if not existing:
|
||||
raise ZootechServiceError("NOT_FOUND")
|
||||
merged = {**existing, **body}
|
||||
return upsert_component(enterprise_id, record_id, merged)
|
||||
|
||||
|
||||
def upsert_recipe(enterprise_id: str, record_id: str | None, body: dict) -> dict:
|
||||
rid = record_id or str(uuid4())
|
||||
existing = load_catalog_row(enterprise_id, "recipe", rid)
|
||||
version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1
|
||||
payload = {
|
||||
"id": rid,
|
||||
"name": body.get("name", (existing or {}).get("name", "")),
|
||||
"heads_per_trip": int(body.get("heads_per_trip", (existing or {}).get("heads_per_trip", 1))),
|
||||
"mixing_time": int(body.get("mixing_time", (existing or {}).get("mixing_time", 0))),
|
||||
"trip_percent": float(body.get("trip_percent", (existing or {}).get("trip_percent", 100.0))),
|
||||
"dry_matter_locked": body.get("dry_matter_locked", (existing or {}).get("dry_matter_locked", False)),
|
||||
"version": version,
|
||||
}
|
||||
if "ingredients" in body:
|
||||
payload["ingredients"] = body["ingredients"]
|
||||
elif existing and "ingredients" in existing:
|
||||
payload["ingredients"] = existing["ingredients"]
|
||||
content_hash = compute_content_hash(payload)
|
||||
payload["content_hash"] = content_hash
|
||||
_emit_change(enterprise_id, "recipe", rid, payload, version, content_hash)
|
||||
return load_catalog_row(enterprise_id, "recipe", rid) or payload
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ZootechOrgSettings(Base):
|
||||
__tablename__ = "zootech_org_settings"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", name="uq_zootech_org_settings_enterprise"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
class ZootechFeedQualitySettings(Base):
|
||||
__tablename__ = "zootech_feed_quality_settings"
|
||||
__table_args__ = (UniqueConstraint("enterprise_id", name="uq_zootech_feed_quality_settings_enterprise"),)
|
||||
|
||||
enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
_EXCLUDED_FROM_HASH = frozenset(
|
||||
{
|
||||
"version",
|
||||
"content_hash",
|
||||
"sync_timestamp",
|
||||
"sync_status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"client_id",
|
||||
"server_synced",
|
||||
"is_deleted",
|
||||
"deleted_at",
|
||||
"deleted_by",
|
||||
"enterprise_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def stable_payload_for_hash(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in data.items() if k not in _EXCLUDED_FROM_HASH}
|
||||
|
||||
|
||||
def compute_content_hash_hex(payload: dict[str, Any]) -> str:
|
||||
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def compute_content_hash(data: dict[str, Any]) -> str:
|
||||
return compute_content_hash_hex(stable_payload_for_hash(data))
|
||||
@@ -0,0 +1,435 @@
|
||||
"""WESP-shaped HTTP API for copied static UI (orchestrator zootech catalog)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||
from app.modules.zootech.catalog_models import (
|
||||
ZootechFeedDispenser,
|
||||
ZootechFeedingPeriod,
|
||||
ZootechPeriodRecipe,
|
||||
ZootechUnloadingGroup,
|
||||
)
|
||||
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
|
||||
|
||||
def _component_wesp(row: ZootechComponent) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"type": row.type,
|
||||
"is_active": row.is_active,
|
||||
"dryMatter": row.dry_matter,
|
||||
"protein": row.protein,
|
||||
"energy": row.energy,
|
||||
"price": row.price,
|
||||
"externalNo": row.external_no,
|
||||
"version": row.version,
|
||||
"content_hash": row.content_hash,
|
||||
}
|
||||
|
||||
|
||||
def _recipe_short(row: ZootechRecipe) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"heads_count": row.heads_per_trip,
|
||||
"mixing_time": row.mixing_time,
|
||||
"trip_percent": row.trip_percent,
|
||||
}
|
||||
|
||||
|
||||
def _ingredient_wesp(row: ZootechIngredient, comp_name: str | None = None) -> dict[str, Any]:
|
||||
wph = float(row.weight_per_head or 0)
|
||||
dm_pct = float(row.dry_matter or 0)
|
||||
dm_ph = float(row.dry_matter_per_head or 0)
|
||||
if not dm_ph and wph > 0 and dm_pct > 0:
|
||||
dm_ph = wph * (dm_pct / 100.0)
|
||||
name = (row.name or "").strip() or (comp_name or "") or "—"
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": name,
|
||||
"weightPerHead": wph,
|
||||
"weight_per_head": wph,
|
||||
"amount": float(row.amount or 0),
|
||||
"dry_matter": dm_pct,
|
||||
"dry_matter_per_head": dm_ph,
|
||||
"order": int(row.order or 0),
|
||||
"component_id": row.component_id,
|
||||
"version": row.version,
|
||||
}
|
||||
|
||||
|
||||
def _unloading_group_wesp(row: ZootechUnloadingGroup) -> dict[str, Any]:
|
||||
extra: dict[str, Any] = {}
|
||||
if row.payload_json:
|
||||
try:
|
||||
extra = json.loads(row.payload_json)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
distribution_type = str(extra.get("distribution_type") or extra.get("distributionType") or "percent")
|
||||
value = float(extra.get("value") or 0)
|
||||
weight = float(extra.get("weight") or 0)
|
||||
order = int(extra.get("order") or 0)
|
||||
base = {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"distributionType": distribution_type,
|
||||
"distribution_type": distribution_type,
|
||||
"value": value,
|
||||
"weight": weight,
|
||||
"order": order,
|
||||
"version": row.version,
|
||||
}
|
||||
for key in ("created_at", "updated_at", "created_by", "updated_by"):
|
||||
if key in extra:
|
||||
base[key] = extra[key]
|
||||
return base
|
||||
|
||||
|
||||
def _serialize_recipe_wesp(db, enterprise_id: str, recipe: ZootechRecipe) -> dict[str, Any]:
|
||||
ingredients = list(
|
||||
db.scalars(
|
||||
select(ZootechIngredient)
|
||||
.where(
|
||||
ZootechIngredient.enterprise_id == enterprise_id,
|
||||
ZootechIngredient.recipe_id == recipe.id,
|
||||
ZootechIngredient.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ZootechIngredient.order.asc())
|
||||
)
|
||||
)
|
||||
comp_ids = [i.component_id for i in ingredients if i.component_id]
|
||||
comp_names: dict[str, str] = {}
|
||||
if comp_ids:
|
||||
for comp in db.scalars(
|
||||
select(ZootechComponent).where(
|
||||
ZootechComponent.enterprise_id == enterprise_id,
|
||||
ZootechComponent.id.in_(comp_ids),
|
||||
)
|
||||
):
|
||||
comp_names[comp.id] = comp.name
|
||||
|
||||
groups = list(
|
||||
db.scalars(
|
||||
select(ZootechUnloadingGroup)
|
||||
.where(
|
||||
ZootechUnloadingGroup.enterprise_id == enterprise_id,
|
||||
ZootechUnloadingGroup.recipe_id == recipe.id,
|
||||
ZootechUnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
groups.sort(key=lambda g: int((_unloading_group_wesp(g).get("order") or 0)))
|
||||
|
||||
unloading_groups = [_unloading_group_wesp(g) for g in groups]
|
||||
return {
|
||||
"id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"headsPerTrip": recipe.heads_per_trip,
|
||||
"mixingTime": recipe.mixing_time,
|
||||
"tripPercent": recipe.trip_percent,
|
||||
"heads_count": recipe.heads_per_trip,
|
||||
"mixing_time": recipe.mixing_time,
|
||||
"trip_percent": recipe.trip_percent,
|
||||
"dryMatterLocked": recipe.dry_matter_locked,
|
||||
"dry_matter_locked": recipe.dry_matter_locked,
|
||||
"unloading_link_broken": recipe.unloading_link_broken,
|
||||
"unloadingLinkBroken": recipe.unloading_link_broken,
|
||||
"target_component_id": recipe.target_component_id,
|
||||
"version": recipe.version,
|
||||
"ingredients": [_ingredient_wesp(i, comp_names.get(i.component_id or "")) for i in ingredients],
|
||||
"unloadingGroups": unloading_groups,
|
||||
"unloading_groups": unloading_groups,
|
||||
}
|
||||
|
||||
|
||||
def _dispenser_wesp(row: ZootechFeedDispenser, periods: list[dict] | None = None) -> dict[str, Any]:
|
||||
extra: dict[str, Any] = {}
|
||||
if row.payload_json:
|
||||
try:
|
||||
extra = json.loads(row.payload_json)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"version": row.version,
|
||||
"content_hash": row.content_hash,
|
||||
"periods": periods or [],
|
||||
"hasSkipToday": False,
|
||||
**{k: v for k, v in extra.items() if k not in ("id", "name")},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/components/ping")
|
||||
def components_ping():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/components")
|
||||
def wesp_list_components(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
limit: int = Query(1000),
|
||||
offset: int = Query(0),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ZootechComponent)
|
||||
.where(
|
||||
ZootechComponent.enterprise_id == enterprise_id,
|
||||
ZootechComponent.is_deleted.is_(False),
|
||||
ZootechComponent.is_active.is_(True),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
return [_component_wesp(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/components/{component_id}")
|
||||
def wesp_get_component(
|
||||
component_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
row = load_catalog_row(enterprise_id, "component", component_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"type": row.get("type", ""),
|
||||
"is_active": row.get("is_active", True),
|
||||
"dryMatter": row.get("dry_matter", 0),
|
||||
"protein": row.get("protein", 0),
|
||||
"energy": row.get("energy", 0),
|
||||
"price": row.get("price", 0),
|
||||
"externalNo": row.get("external_no"),
|
||||
"version": row.get("version", 1),
|
||||
"content_hash": row.get("content_hash", ""),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/feed_dispensers/ping")
|
||||
def feed_dispensers_ping():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/feed_dispensers/names")
|
||||
def feed_dispenser_names(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ZootechFeedDispenser)
|
||||
.where(
|
||||
ZootechFeedDispenser.enterprise_id == enterprise_id,
|
||||
ZootechFeedDispenser.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ZootechFeedDispenser.name.asc())
|
||||
)
|
||||
)
|
||||
seen: set[str] = set()
|
||||
names: list[str] = []
|
||||
for r in rows:
|
||||
if r.name and r.name not in seen:
|
||||
seen.add(r.name)
|
||||
names.append(r.name)
|
||||
return names
|
||||
|
||||
|
||||
@router.get("/feed_dispensers")
|
||||
def list_feed_dispensers(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
limit: int = Query(100),
|
||||
offset: int = Query(0),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
with session_scope() as db:
|
||||
dispensers = list(
|
||||
db.scalars(
|
||||
select(ZootechFeedDispenser)
|
||||
.where(
|
||||
ZootechFeedDispenser.enterprise_id == enterprise_id,
|
||||
ZootechFeedDispenser.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ZootechFeedDispenser.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
result = []
|
||||
for d in dispensers:
|
||||
periods = list(
|
||||
db.scalars(
|
||||
select(ZootechFeedingPeriod).where(
|
||||
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||
ZootechFeedingPeriod.dispenser_id == d.id,
|
||||
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
period_payload = [{"id": p.id, "name": p.name} for p in periods]
|
||||
result.append(_dispenser_wesp(d, period_payload))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/feed_dispensers/{dispenser_id}/periods")
|
||||
def dispenser_periods(
|
||||
dispenser_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
with session_scope() as db:
|
||||
periods = list(
|
||||
db.scalars(
|
||||
select(ZootechFeedingPeriod).where(
|
||||
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||
ZootechFeedingPeriod.dispenser_id == dispenser_id,
|
||||
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
return [{"id": p.id, "name": p.name, "dispenser_id": p.dispenser_id} for p in periods]
|
||||
|
||||
|
||||
def _recipes_for_period(db, enterprise_id: str, period_id: str) -> list[dict]:
|
||||
links = list(
|
||||
db.scalars(
|
||||
select(ZootechPeriodRecipe)
|
||||
.where(
|
||||
ZootechPeriodRecipe.enterprise_id == enterprise_id,
|
||||
ZootechPeriodRecipe.period_id == period_id,
|
||||
ZootechPeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ZootechPeriodRecipe.order.asc())
|
||||
)
|
||||
)
|
||||
out: list[dict] = []
|
||||
for link in links:
|
||||
recipe = db.scalar(
|
||||
select(ZootechRecipe).where(
|
||||
ZootechRecipe.enterprise_id == enterprise_id,
|
||||
ZootechRecipe.id == link.recipe_id,
|
||||
ZootechRecipe.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if recipe:
|
||||
out.append(_recipe_short(recipe))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/feed_dispensers/{dispenser_id}/recipes")
|
||||
def dispenser_recipes(
|
||||
dispenser_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
with session_scope() as db:
|
||||
periods = list(
|
||||
db.scalars(
|
||||
select(ZootechFeedingPeriod).where(
|
||||
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||
ZootechFeedingPeriod.dispenser_id == dispenser_id,
|
||||
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
merged: dict[str, dict] = {}
|
||||
for p in periods:
|
||||
for r in _recipes_for_period(db, enterprise_id, p.id):
|
||||
merged[r["id"]] = r
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
@router.get("/periods/{period_id}/recipes")
|
||||
def period_recipes(
|
||||
period_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
with session_scope() as db:
|
||||
period = db.scalar(
|
||||
select(ZootechFeedingPeriod).where(
|
||||
ZootechFeedingPeriod.enterprise_id == enterprise_id,
|
||||
ZootechFeedingPeriod.id == period_id,
|
||||
ZootechFeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if not period:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||
return _recipes_for_period(db, enterprise_id, period_id)
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}")
|
||||
def wesp_get_recipe(
|
||||
recipe_id: str,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
date: str | None = Query(None),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
with session_scope() as db:
|
||||
recipe = db.scalar(
|
||||
select(ZootechRecipe).where(
|
||||
ZootechRecipe.enterprise_id == enterprise_id,
|
||||
ZootechRecipe.id == recipe_id,
|
||||
ZootechRecipe.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
|
||||
return _serialize_recipe_wesp(db, enterprise_id, recipe)
|
||||
|
||||
|
||||
@router.post("/recipes/calculate")
|
||||
def wesp_calculate_recipe(body: dict):
|
||||
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, calculate_recipe_wesp, recipe_write_http_error
|
||||
|
||||
try:
|
||||
return calculate_recipe_wesp(body)
|
||||
except RecipeWriteError as exc:
|
||||
raise recipe_write_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.put("/recipes/{recipe_id}")
|
||||
def wesp_update_recipe(
|
||||
recipe_id: str,
|
||||
body: dict,
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, recipe_write_http_error, update_recipe_wesp
|
||||
|
||||
_require_ent(enterprise_id, tenant)
|
||||
try:
|
||||
return update_recipe_wesp(enterprise_id, recipe_id, body)
|
||||
except RecipeWriteError as exc:
|
||||
raise recipe_write_http_error(exc) from exc
|
||||
@@ -0,0 +1,729 @@
|
||||
"""WESP-shaped admin endpoints for copied static UI (/api/admin/*)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.server_logging import ensure_server_log_file
|
||||
from app.core.database import session_scope
|
||||
from app.core.dependencies import require_superuser
|
||||
from app.core.audit_log import read_audit_events, write_audit_event
|
||||
from app.modules.admin.service import (
|
||||
create_admin_user,
|
||||
delete_admin_user,
|
||||
get_diagnostics_report,
|
||||
get_server_log_tail,
|
||||
patch_user,
|
||||
reset_user_password,
|
||||
)
|
||||
from app.modules.zootech.orchestrator_system_metrics import collect_orchestrator_system_metrics
|
||||
from app.modules.sync.models import Enterprise, FarmHub, SyncConflict, SyncOutbox
|
||||
from app.modules.sync.service import get_sync_metrics
|
||||
from app.modules.users import repository as users_repository
|
||||
from app.modules.users.models import User
|
||||
from app.modules.zootech.catalog_models import ZootechFeedDispenser, ZootechFeedingPeriod
|
||||
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["zootech-wesp-admin"])
|
||||
|
||||
|
||||
def _wesp_error(message: str, code: int = 400) -> None:
|
||||
raise HTTPException(status_code=code, detail={"status": "error", "message": message})
|
||||
|
||||
|
||||
def _count_zootech(model) -> int:
|
||||
with session_scope() as db:
|
||||
return (
|
||||
db.scalar(select(func.count()).select_from(model).where(model.is_deleted.is_(False))) or 0
|
||||
)
|
||||
|
||||
|
||||
def _sync_queue_stats() -> dict[str, int]:
|
||||
with session_scope() as db:
|
||||
total = db.scalar(select(func.count()).select_from(SyncOutbox)) or 0
|
||||
pending = (
|
||||
db.scalar(
|
||||
select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "pending")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
processing = (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(SyncOutbox)
|
||||
.where(SyncOutbox.status == "processing")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
failed = (
|
||||
db.scalar(
|
||||
select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "failed")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {
|
||||
"total": int(total),
|
||||
"pending": int(pending),
|
||||
"processing": int(processing),
|
||||
"failed": int(failed),
|
||||
}
|
||||
|
||||
|
||||
def _user_to_wesp(user: User) -> dict[str, Any]:
|
||||
return {
|
||||
"id": user.id,
|
||||
"login": user.email,
|
||||
"is_superuser": bool(user.is_superuser),
|
||||
"lab_access": False,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class WespUiActivityIn(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
level: str = "ok"
|
||||
|
||||
|
||||
class WespUserCreateIn(BaseModel):
|
||||
login: str = Field(min_length=1)
|
||||
password: str = Field(min_length=8)
|
||||
is_superuser: bool = False
|
||||
|
||||
|
||||
class WespUserPatchIn(BaseModel):
|
||||
password: str | None = None
|
||||
is_superuser: bool | None = None
|
||||
lab_access: bool | None = None
|
||||
|
||||
|
||||
class WespNetworkPatchIn(BaseModel):
|
||||
local_hostname: str | None = None
|
||||
public_base_url: str | None = None
|
||||
mdns_enabled: bool | None = None
|
||||
|
||||
|
||||
class WespOrchestratorSyncPatchIn(BaseModel):
|
||||
upstream_url: str | None = None
|
||||
hub_site_id: str | None = None
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def wesp_list_users(_admin: User = Depends(require_superuser)):
|
||||
users, _total = users_repository.list_users(page=1, limit=500)
|
||||
payload = {"status": "success", "users": [_user_to_wesp(u) for u in users]}
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/users", status_code=201)
|
||||
def wesp_create_user(payload: WespUserCreateIn, admin: User = Depends(require_superuser)):
|
||||
email = payload.login.strip()
|
||||
if "@" not in email:
|
||||
_wesp_error("Используйте email в поле логина (admin@compton.example)")
|
||||
try:
|
||||
create_admin_user(
|
||||
admin,
|
||||
email=email,
|
||||
password=payload.password,
|
||||
role="admin" if payload.is_superuser else "user",
|
||||
is_superuser=payload.is_superuser,
|
||||
status="active",
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "USER_EXISTS":
|
||||
_wesp_error("Пользователь с таким логином уже существует", 409)
|
||||
_wesp_error(str(exc))
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
def wesp_patch_user(user_id: str, payload: WespUserPatchIn, admin: User = Depends(require_superuser)):
|
||||
if payload.password:
|
||||
try:
|
||||
reset_user_password(admin, user_id, payload.password)
|
||||
except ValueError as exc:
|
||||
_wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400)
|
||||
return {"status": "success"}
|
||||
if payload.is_superuser is not None:
|
||||
try:
|
||||
patch_user(admin, user_id, None, None, payload.is_superuser)
|
||||
except ValueError as exc:
|
||||
_wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400)
|
||||
return {"status": "success"}
|
||||
if payload.lab_access is not None:
|
||||
return {"status": "success"}
|
||||
_wesp_error("Нет полей для обновления")
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def wesp_delete_user(user_id: str, admin: User = Depends(require_superuser)):
|
||||
try:
|
||||
delete_admin_user(admin, user_id)
|
||||
except ValueError as exc:
|
||||
_wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def wesp_admin_summary(_admin: User = Depends(require_superuser)):
|
||||
queue = _sync_queue_stats()
|
||||
with session_scope() as db:
|
||||
enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||
hub_count = db.scalar(select(func.count()).select_from(FarmHub)) or 0
|
||||
enterprise_ids = [e.id for e in enterprises]
|
||||
|
||||
conflicts_pending = 0
|
||||
for enterprise_id in enterprise_ids:
|
||||
conflicts_pending += int(get_sync_metrics(enterprise_id).get("conflicts_pending") or 0)
|
||||
|
||||
payload = {
|
||||
"status": "success",
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"app": {
|
||||
"config_mode": settings.app_env,
|
||||
"debug": settings.app_env != "production",
|
||||
"version": "1.0.0-orchestrator",
|
||||
"role": "server",
|
||||
"first_launch_at": None,
|
||||
"warranty_until": None,
|
||||
"warranty_days_remaining": None,
|
||||
},
|
||||
"databases": {
|
||||
"recipes": {"engine": "postgresql", "path": "orchestrator"},
|
||||
"reports": {"engine": "postgresql", "path": "orchestrator"},
|
||||
},
|
||||
"machine": {"platform": "orchestrator"},
|
||||
"counts": {
|
||||
"users": users_repository.count_users(),
|
||||
"recipes": _count_zootech(ZootechRecipe),
|
||||
"ingredients": _count_zootech(ZootechIngredient),
|
||||
"components": _count_zootech(ZootechComponent),
|
||||
"feed_dispensers": _count_zootech(ZootechFeedDispenser),
|
||||
"feeding_periods": _count_zootech(ZootechFeedingPeriod),
|
||||
},
|
||||
"sync": {
|
||||
"client_role": "server",
|
||||
"connection": {
|
||||
"role": "server",
|
||||
"server_url": settings.public_base_url.rstrip("/"),
|
||||
"configured": True,
|
||||
},
|
||||
"max_concurrent": 4,
|
||||
"queue": queue,
|
||||
"client_poll_interval_sec": 14,
|
||||
"first_sync_banner": False,
|
||||
"initial_sync_progress": {"active": False},
|
||||
"guarantees": {
|
||||
"master_data": "Orchestrator — источник правды для предприятий и хабов.",
|
||||
"reports": "Отчёты принимаются через sync API.",
|
||||
},
|
||||
"state": {"hub_count": int(hub_count), "enterprise_count": len(enterprise_ids)},
|
||||
"queue_errors": [],
|
||||
},
|
||||
"security": {
|
||||
"mac_lock_enabled": False,
|
||||
"calibration_public": False,
|
||||
"device_token_configured": False,
|
||||
"kiosk_enforce_paired_only": False,
|
||||
},
|
||||
"auto_update": {"enabled": False, "note": "Обновления управляются деплоем orchestrator."},
|
||||
"hardware": {"simulation_mode": True, "note": "Железо недоступно на orchestrator."},
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/system-metrics")
|
||||
def wesp_system_metrics(_admin: User = Depends(require_superuser)):
|
||||
metrics = collect_orchestrator_system_metrics()
|
||||
return {"status": "success", "metrics": metrics}
|
||||
|
||||
|
||||
@router.get("/factory-reset/preview")
|
||||
def wesp_factory_reset_preview(_admin: User = Depends(require_superuser)):
|
||||
return {
|
||||
"status": "success",
|
||||
"confirm_phrase": "СБРОС ДАННЫХ",
|
||||
"preserved_note": "Сброс data/ недоступен на orchestrator — данные в PostgreSQL и S3.",
|
||||
"files": [],
|
||||
"directories": [],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/network-settings")
|
||||
def wesp_network_settings_get(_admin: User = Depends(require_superuser)):
|
||||
public_url = settings.public_base_url.rstrip("/")
|
||||
return {
|
||||
"status": "success",
|
||||
"network": {
|
||||
"local_hostname": "orchestrator.local",
|
||||
"default_local_hostname": "orchestrator.local",
|
||||
"public_base_url": public_url,
|
||||
"mdns_enabled": False,
|
||||
"local_hostname_locked_by_env": True,
|
||||
"public_base_url_locked_by_env": False,
|
||||
"mdns_enabled_locked_by_env": True,
|
||||
"detected": {
|
||||
"listen_port": 5173,
|
||||
"listen_host": "0.0.0.0",
|
||||
"os_hostname": "orchestrator",
|
||||
"mdns_available": False,
|
||||
"mdns_active": False,
|
||||
},
|
||||
"suggested_public_url": public_url,
|
||||
"sync_url_by_ip": public_url,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/network-settings")
|
||||
def wesp_network_settings_patch(_payload: WespNetworkPatchIn, _admin: User = Depends(require_superuser)):
|
||||
return {"status": "success", "message": "Сетевые настройки orchestrator задаются через PUBLIC_BASE_URL / nginx."}
|
||||
|
||||
|
||||
@router.get("/peripheral-events")
|
||||
def wesp_peripheral_events(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
component: str | None = None,
|
||||
exclude_component: str | None = None,
|
||||
_admin: User = Depends(require_superuser),
|
||||
):
|
||||
_ = (limit, component, exclude_component)
|
||||
return {"status": "success", "events": []}
|
||||
|
||||
|
||||
@router.get("/sync-diagnostics")
|
||||
def wesp_sync_diagnostics(_admin: User = Depends(require_superuser)):
|
||||
queue = _sync_queue_stats()
|
||||
with session_scope() as db:
|
||||
enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||
hubs = list(db.scalars(select(FarmHub).order_by(FarmHub.created_at.desc())))
|
||||
conflicts_total = db.scalar(select(func.count()).select_from(SyncConflict)) or 0
|
||||
conflicts_pending = (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(SyncConflict)
|
||||
.where(SyncConflict.status == "pending")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
hub_rows = [
|
||||
{
|
||||
"farm_hub_id": h.id,
|
||||
"name": h.name,
|
||||
"hub_site_id": h.hub_site_id,
|
||||
"status": h.status,
|
||||
"last_seen": h.last_seen.isoformat() if h.last_seen else None,
|
||||
"enterprise_id": h.enterprise_id,
|
||||
}
|
||||
for h in hubs
|
||||
]
|
||||
enterprise_rows = [{"id": e.id, "name": e.name, "slug": e.slug} for e in enterprises]
|
||||
|
||||
for row in hub_rows:
|
||||
row.update(get_sync_metrics(row.pop("enterprise_id")))
|
||||
|
||||
payload = {
|
||||
"status": "success",
|
||||
"role": "server",
|
||||
"queues": {
|
||||
"summary": queue,
|
||||
"by_table": {},
|
||||
"stuck_processing": [],
|
||||
"processing_missing_processed_at": [],
|
||||
"failed_recent": [],
|
||||
"high_retry_pending": [],
|
||||
},
|
||||
"clients": {"recent_deliveries": []},
|
||||
"conflicts": {
|
||||
"summary": {"total": int(conflicts_total), "pending": int(conflicts_pending)},
|
||||
"recent": [],
|
||||
},
|
||||
"report_push": {
|
||||
"scope": "orchestrator",
|
||||
"note": "Push отчётов обрабатывается через sync engine.",
|
||||
"total": 0,
|
||||
},
|
||||
"engine_state": {
|
||||
"enterprises": enterprise_rows,
|
||||
"hubs": hub_rows,
|
||||
},
|
||||
"initial_sync_progress": {"active": False},
|
||||
"actions_capabilities": {
|
||||
"requeue_stuck": False,
|
||||
"restart_local_sync": False,
|
||||
"refresh_diagnostics": True,
|
||||
"retry_report_push": False,
|
||||
},
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/orchestrator-sync")
|
||||
def wesp_orchestrator_sync_get(_admin: User = Depends(require_superuser)):
|
||||
with session_scope() as db:
|
||||
hub = db.scalar(select(FarmHub).order_by(FarmHub.created_at.desc()))
|
||||
hub_site_id = hub.hub_site_id if hub else ""
|
||||
return {
|
||||
"status": "success",
|
||||
"orchestrator_sync": {
|
||||
"upstream_url": settings.public_base_url.rstrip("/"),
|
||||
"hub_site_id": hub_site_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/orchestrator-sync")
|
||||
def wesp_orchestrator_sync_patch(_payload: WespOrchestratorSyncPatchIn, _admin: User = Depends(require_superuser)):
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Параметры hub↔orchestrator настраиваются через pairing, не из этой формы.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/activity-feed")
|
||||
def wesp_activity_feed(limit: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)):
|
||||
entries: list[dict[str, Any]] = []
|
||||
for event in read_audit_events(limit=limit):
|
||||
ts_raw = event.get("timestamp")
|
||||
ts_ms = 0
|
||||
if ts_raw:
|
||||
try:
|
||||
ts_ms = int(datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).timestamp() * 1000)
|
||||
except ValueError:
|
||||
ts_ms = int(time.time() * 1000)
|
||||
details = event.get("details") or {}
|
||||
text = str(details.get("text") or event.get("action") or "")
|
||||
level = str(details.get("level") or "ok")
|
||||
if level not in {"ok", "err", "warn"}:
|
||||
level = "ok"
|
||||
entries.append({"ts": ts_ms, "level": level, "text": text})
|
||||
return {"status": "success", "entries": entries, "meta": "orchestrator-audit"}
|
||||
|
||||
|
||||
@router.post("/ui-activity")
|
||||
def wesp_ui_activity(payload: WespUiActivityIn, admin: User = Depends(require_superuser)):
|
||||
level = payload.level if payload.level in {"ok", "err", "warn"} else "ok"
|
||||
write_audit_event(
|
||||
action="ui.activity",
|
||||
actor_user_id=admin.id,
|
||||
actor_email=admin.email,
|
||||
details={"text": payload.text, "level": level},
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/diagnostics/report")
|
||||
def wesp_diagnostics_report(_admin: User = Depends(require_superuser)):
|
||||
report = get_diagnostics_report()
|
||||
return {"status": "success", **report}
|
||||
|
||||
|
||||
def _security_settings_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"mac_lock_enabled": False,
|
||||
"mac_lock_enabled_locked_by_env": True,
|
||||
"allowed_mac_addresses": "",
|
||||
"allowed_mac_addresses_locked_by_env": True,
|
||||
"kiosk_enforce_paired_only": False,
|
||||
"kiosk_enforce_paired_only_locked_by_env": True,
|
||||
"calibration_public": False,
|
||||
"calibration_public_locked_by_env": True,
|
||||
"device_token_configured": False,
|
||||
"device_token_locked_by_env": True,
|
||||
"device_token_header": "X-Device-Token",
|
||||
"device_token_header_locked_by_env": True,
|
||||
"kiosk_pair_token_ttl_seconds": 300,
|
||||
"kiosk_pair_token_ttl_seconds_locked_by_env": True,
|
||||
"kiosk_auth_cookie_days": 18250,
|
||||
"kiosk_auth_cookie_days_locked_by_env": True,
|
||||
"session_remember_days": 7,
|
||||
"session_remember_days_locked_by_env": True,
|
||||
"llm_autostart": False,
|
||||
"llm_autostart_locked_by_env": True,
|
||||
"admin_llm_enabled": False,
|
||||
"admin_llm_enabled_locked_by_env": True,
|
||||
"llm_chat_db_context": False,
|
||||
"llm_chat_db_context_locked_by_env": True,
|
||||
"llm_tools_enabled": False,
|
||||
"llm_tools_enabled_locked_by_env": True,
|
||||
}
|
||||
|
||||
|
||||
def _auto_update_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": False,
|
||||
"auto_install": False,
|
||||
"gitea_url": "",
|
||||
"gitea_owner": "",
|
||||
"gitea_repo": "",
|
||||
"repository_url": "",
|
||||
"check_interval_sec": 3600,
|
||||
"note": "Автообновление недоступно на orchestrator.",
|
||||
}
|
||||
|
||||
|
||||
def _hub_only_stub(message: str, **extra: Any) -> dict[str, Any]:
|
||||
return {"status": "success", "available": False, "orchestrator": True, "message": message, **extra}
|
||||
|
||||
|
||||
def _patch_ok(message: str, **extra: Any) -> dict[str, Any]:
|
||||
return {"status": "success", "message": message, **extra}
|
||||
|
||||
|
||||
@router.get("/hardware-status")
|
||||
def wesp_hardware_status(_admin: User = Depends(require_superuser)):
|
||||
return {
|
||||
"status": "success",
|
||||
"config": {"simulation_mode": True, "read_interval": 0.05, "samples_per_read": 3},
|
||||
"peripherals": {},
|
||||
"scales": {"available": False, "error": "Железо недоступно на orchestrator (Docker)."},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hardware-metrics-history")
|
||||
def wesp_hardware_metrics_history(_admin: User = Depends(require_superuser)):
|
||||
return {"status": "success", "points": [], "path": None, "retention_days": 0}
|
||||
|
||||
|
||||
@router.patch("/hardware/simulation")
|
||||
def wesp_hardware_simulation_patch(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Симуляция железа недоступна на orchestrator.")
|
||||
|
||||
|
||||
@router.put("/hardware/simulation/weight")
|
||||
@router.patch("/hardware/simulation/weight")
|
||||
def wesp_hardware_simulation_weight(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Симуляция весов недоступна на orchestrator.")
|
||||
|
||||
|
||||
@router.get("/llm/status")
|
||||
def wesp_llm_status(_admin: User = Depends(require_superuser)):
|
||||
return {
|
||||
"status": "success",
|
||||
"enabled": False,
|
||||
"llm_base_url": "",
|
||||
"model": "",
|
||||
"assistant_dir": "",
|
||||
"llm_autostart": False,
|
||||
"llm_autostart_locked_by_env": True,
|
||||
"admin_llm_enabled_locked_by_env": True,
|
||||
"llm_chat_db_context": False,
|
||||
"llm_chat_db_context_locked_by_env": True,
|
||||
"llm_tools_enabled": False,
|
||||
"llm_tools_enabled_locked_by_env": True,
|
||||
"llm_reachable": False,
|
||||
"model_present": False,
|
||||
"llm_error": "LLM недоступен на orchestrator.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/llm/activity")
|
||||
def wesp_llm_activity(_admin: User = Depends(require_superuser)):
|
||||
return {"status": "success", "entries": []}
|
||||
|
||||
|
||||
@router.post("/llm/chat")
|
||||
@router.post("/llm/ping")
|
||||
@router.post("/llm/diagnostics")
|
||||
@router.post("/llm/summary")
|
||||
def wesp_llm_actions(_admin: User = Depends(require_superuser)):
|
||||
return {"status": "error", "message": "LLM недоступен на orchestrator."}
|
||||
|
||||
|
||||
@router.patch("/security-settings")
|
||||
def wesp_security_settings_patch(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok(
|
||||
"Настройки безопасности hub недоступны на orchestrator.",
|
||||
security=_security_settings_payload(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/auto-update-settings")
|
||||
def wesp_auto_update_settings_patch(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok(
|
||||
"Автообновление недоступно на orchestrator.",
|
||||
auto_update=_auto_update_payload(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/gitea-secrets")
|
||||
def wesp_gitea_secrets_patch(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Gitea secrets недоступны на orchestrator.", auto_update=_auto_update_payload())
|
||||
|
||||
|
||||
@router.get("/sync-settings")
|
||||
def wesp_sync_settings_get(_admin: User = Depends(require_superuser)):
|
||||
return {
|
||||
"status": "success",
|
||||
"connection": {"role": "server", "server_url": settings.public_base_url.rstrip("/"), "configured": True},
|
||||
"state": {},
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/sync-settings")
|
||||
def wesp_sync_settings_patch(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Sync settings hub-client недоступны на orchestrator (роль server).")
|
||||
|
||||
|
||||
@router.post("/sync-actions/requeue-stuck")
|
||||
@router.post("/sync-actions/restart-local-sync")
|
||||
@router.post("/sync-actions/refresh-runtime")
|
||||
@router.post("/sync-actions/retry-report-push")
|
||||
def wesp_sync_actions(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Действие sync hub-client недоступно на orchestrator.")
|
||||
|
||||
|
||||
@router.get("/kiosk-full-setup/status")
|
||||
def wesp_kiosk_full_setup_status(_admin: User = Depends(require_superuser)):
|
||||
return _hub_only_stub(
|
||||
"Kiosk setup только на Raspberry Pi hub.",
|
||||
running_as_root=False,
|
||||
full_setup_ready=False,
|
||||
platform_ready=False,
|
||||
kiosk_enabled=False,
|
||||
chromium_found=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kiosk-full-setup")
|
||||
def wesp_kiosk_full_setup_post(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("Kiosk setup только на Raspberry Pi hub.", 400)
|
||||
|
||||
|
||||
@router.get("/pi-platform/status")
|
||||
def wesp_pi_platform_status(_admin: User = Depends(require_superuser)):
|
||||
return _hub_only_stub("Pi platform setup только на hub.", running_as_root=False, platform_ready=False)
|
||||
|
||||
|
||||
@router.post("/pi-platform/apply")
|
||||
def wesp_pi_platform_apply(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("Pi platform setup только на hub.", 400)
|
||||
|
||||
|
||||
@router.get("/pi-boot/rainbow-splash/status")
|
||||
def wesp_pi_rainbow_status(_admin: User = Depends(require_superuser)):
|
||||
return _hub_only_stub("Pi boot config только на hub.", config_found=False, disable_splash=True)
|
||||
|
||||
|
||||
@router.post("/pi-boot/rainbow-splash")
|
||||
def wesp_pi_rainbow_post(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("Pi boot config только на hub.", 400)
|
||||
|
||||
|
||||
@router.get("/plymouth/status")
|
||||
def wesp_plymouth_status(_admin: User = Depends(require_superuser)):
|
||||
return _hub_only_stub("Plymouth только на hub.", installed=False, running=False)
|
||||
|
||||
|
||||
@router.post("/plymouth/install")
|
||||
def wesp_plymouth_install(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("Plymouth только на hub.", 400)
|
||||
|
||||
|
||||
@router.get("/kiosk-boot/status")
|
||||
def wesp_kiosk_boot_status(_admin: User = Depends(require_superuser)):
|
||||
return _hub_only_stub("Kiosk boot только на hub.", configured=False)
|
||||
|
||||
|
||||
@router.post("/kiosk-boot")
|
||||
def wesp_kiosk_boot_post(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("Kiosk boot только на hub.", 400)
|
||||
|
||||
|
||||
@router.post("/factory-reset")
|
||||
def wesp_factory_reset_post(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("Factory reset data/ недоступен на orchestrator.", 400)
|
||||
|
||||
|
||||
@router.post("/service-control")
|
||||
def wesp_service_control(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("Управление systemd недоступно на orchestrator.", 400)
|
||||
|
||||
|
||||
@router.get("/backup.sqlite")
|
||||
@router.post("/restore.sqlite")
|
||||
def wesp_sqlite_backup(_admin: User = Depends(require_superuser)):
|
||||
_wesp_error("SQLite backup/restore только на hub.", 400)
|
||||
|
||||
|
||||
@router.get("/client-uploaded-logs")
|
||||
def wesp_client_uploaded_logs(_admin: User = Depends(require_superuser)):
|
||||
return {"status": "success", "root": "", "clients": []}
|
||||
|
||||
|
||||
@router.get("/client-uploaded-logs/tail")
|
||||
def wesp_client_uploaded_logs_tail(
|
||||
client_id: str | None = None,
|
||||
file: str | None = None,
|
||||
lines: int = Query(default=300, ge=1, le=2000),
|
||||
_admin: User = Depends(require_superuser),
|
||||
):
|
||||
_ = (client_id, file, lines)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Логи клиентов (POST /api/sync/client-log) на orchestrator пока не настроены.",
|
||||
"lines": [],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/client-uploaded-logs/download")
|
||||
def wesp_client_uploaded_logs_download(
|
||||
client_id: str | None = None,
|
||||
file: str | None = None,
|
||||
_admin: User = Depends(require_superuser),
|
||||
):
|
||||
_ = (client_id, file)
|
||||
_wesp_error("Логи клиентов на orchestrator пока не настроены.", 404)
|
||||
|
||||
|
||||
@router.get("/server-log")
|
||||
def wesp_server_log(lines: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)):
|
||||
log_path = ensure_server_log_file()
|
||||
tail = get_server_log_tail(lines=lines)
|
||||
payload = {"status": "success", "path": str(log_path), "lines": tail.get("lines") or []}
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/server-log/download")
|
||||
def wesp_server_log_download(_admin: User = Depends(require_superuser)):
|
||||
log_path = ensure_server_log_file()
|
||||
return FileResponse(
|
||||
log_path,
|
||||
media_type="text/plain; charset=utf-8",
|
||||
filename=log_path.name,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/diagnostics/settings")
|
||||
def wesp_diagnostics_settings_get(_admin: User = Depends(require_superuser)):
|
||||
return {"status": "success", "targets": [], "extra_targets": []}
|
||||
|
||||
|
||||
@router.patch("/diagnostics/settings")
|
||||
def wesp_diagnostics_settings_patch(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Diagnostics settings сохранены локально недоступны на orchestrator.")
|
||||
|
||||
|
||||
@router.post("/diagnostics/network-run")
|
||||
@router.post("/diagnostics/hx711-sample")
|
||||
@router.post("/diagnostics/gpio-blink")
|
||||
@router.post("/diagnostics/traceroute-by-hosts")
|
||||
def wesp_diagnostics_actions(_admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Диагностика железа/сети hub недоступна на orchestrator.")
|
||||
|
||||
|
||||
@router.patch("/sync-clients/{node_id}/ip")
|
||||
def wesp_sync_client_ip(_node_id: str, _admin: User = Depends(require_superuser)):
|
||||
return _patch_ok("Sync client IP управляется через hub sync API.")
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""WESP-shaped analytics and feed-quality endpoints for /reports tabs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||
from app.modules.zootech.models import ZootechComponent
|
||||
from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport
|
||||
from app.modules.zootech.wesp_compat_reports import _parse_payload, _parse_report_time
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
|
||||
|
||||
def _parse_date_window(date_from: str | None, date_to: str | None) -> tuple[datetime, datetime] | None:
|
||||
if not date_from or not date_to:
|
||||
return None
|
||||
try:
|
||||
start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1)
|
||||
return start, end
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "Некорректный формат date_from/date_to (YYYY-MM-DD)"},
|
||||
) from exc
|
||||
|
||||
|
||||
def _loading_reports_in_range(
|
||||
enterprise_id: str, window: tuple[datetime, datetime] | None
|
||||
) -> list[tuple[str, dict[str, Any]]]:
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ZootechLoadingReport).where(
|
||||
ZootechLoadingReport.enterprise_id == enterprise_id,
|
||||
ZootechLoadingReport.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
out: list[tuple[str, dict[str, Any]]] = []
|
||||
for row in rows:
|
||||
payload = _parse_payload(row)
|
||||
start_time = _parse_report_time(payload.get("start_time"))
|
||||
if window and start_time and not (window[0] <= start_time < window[1]):
|
||||
continue
|
||||
if window and start_time is None:
|
||||
continue
|
||||
out.append((row.id, payload))
|
||||
return out
|
||||
|
||||
|
||||
def _component_prices(enterprise_id: str) -> dict[str, dict[str, float]]:
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ZootechComponent).where(
|
||||
ZootechComponent.enterprise_id == enterprise_id,
|
||||
ZootechComponent.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
by_id = {row.id: float(row.price or 0) for row in rows}
|
||||
by_name = {(row.name or "").strip().lower(): float(row.price or 0) for row in rows if row.name}
|
||||
return {"id": by_id, "name": by_name}
|
||||
|
||||
|
||||
def _price_for(prices: dict[str, dict[str, float]], component_id: str | None, name: str) -> float:
|
||||
if component_id and component_id in prices["id"]:
|
||||
return prices["id"][component_id]
|
||||
key = (name or "").strip().lower()
|
||||
if key and key in prices["name"]:
|
||||
return prices["name"][key]
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.get("/analytics/finance")
|
||||
def analytics_finance_wesp(
|
||||
enterprise_id: str = Query(...),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
window = _parse_date_window(date_from, date_to)
|
||||
reports = _loading_reports_in_range(enterprise_id, window)
|
||||
if not reports:
|
||||
return {
|
||||
"overloadRub": 0.0,
|
||||
"underloadRub": 0.0,
|
||||
"netRub": 0.0,
|
||||
"dominantIssue": "balanced",
|
||||
"topComponents": [],
|
||||
"reportCount": 0,
|
||||
}
|
||||
|
||||
prices = _component_prices(enterprise_id)
|
||||
by_component: dict[str, dict[str, Any]] = defaultdict(
|
||||
lambda: {"name": "", "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0}
|
||||
)
|
||||
overload_total = 0.0
|
||||
underload_total = 0.0
|
||||
|
||||
for _report_id, payload in reports:
|
||||
components = payload.get("components")
|
||||
if not isinstance(components, list):
|
||||
continue
|
||||
for comp in components:
|
||||
if not isinstance(comp, dict):
|
||||
continue
|
||||
target = float(comp.get("target_weight") or 0)
|
||||
actual = float(comp.get("actual_weight") or 0)
|
||||
if target <= 0 and actual <= 0:
|
||||
continue
|
||||
name = str(comp.get("component_name") or "—")
|
||||
price = _price_for(prices, comp.get("component_id"), name)
|
||||
dev_kg = actual - target
|
||||
dev_rub = dev_kg * price
|
||||
key = str(comp.get("component_id") or name)
|
||||
row = by_component[key]
|
||||
row["name"] = name
|
||||
row["componentId"] = comp.get("component_id")
|
||||
if dev_rub > 0:
|
||||
row["overloadRub"] += dev_rub
|
||||
overload_total += dev_rub
|
||||
elif dev_rub < 0:
|
||||
row["underloadRub"] += abs(dev_rub)
|
||||
underload_total += abs(dev_rub)
|
||||
row["netRub"] += dev_rub
|
||||
|
||||
top = sorted(
|
||||
by_component.values(),
|
||||
key=lambda item: max(item["overloadRub"], item["underloadRub"]),
|
||||
reverse=True,
|
||||
)[:3]
|
||||
top_out = [
|
||||
{
|
||||
"name": item["name"],
|
||||
"componentId": item.get("componentId"),
|
||||
"overloadRub": round(item["overloadRub"], 2),
|
||||
"underloadRub": round(item["underloadRub"], 2),
|
||||
"netRub": round(item["netRub"], 2),
|
||||
}
|
||||
for item in top
|
||||
if max(item["overloadRub"], item["underloadRub"]) > 0
|
||||
]
|
||||
net = overload_total - underload_total
|
||||
if overload_total > underload_total:
|
||||
dominant = "overload"
|
||||
elif underload_total > overload_total:
|
||||
dominant = "underload"
|
||||
else:
|
||||
dominant = "balanced"
|
||||
result = {
|
||||
"overloadRub": round(overload_total, 2),
|
||||
"underloadRub": round(underload_total, 2),
|
||||
"netRub": round(net, 2),
|
||||
"dominantIssue": dominant,
|
||||
"topComponents": top_out,
|
||||
"reportCount": len(reports),
|
||||
}
|
||||
# #region agent log
|
||||
try:
|
||||
import pathlib
|
||||
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"E","location":"wesp_compat_analytics.py:finance","message":"finance summary","data":{"date_from":date_from,"date_to":date_to,"report_count":len(reports),"underloadRub":result["underloadRub"],"top_count":len(top_out)},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/analytics/plan-fact")
|
||||
def analytics_plan_fact_wesp(
|
||||
enterprise_id: str = Query(...),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
return {"items": []}
|
||||
|
||||
|
||||
def _parse_alert_payload_json(raw: str | None) -> dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _alert_payload(row: ZootechFeedAlert) -> dict[str, Any]:
|
||||
return _parse_alert_payload_json(row.payload_json)
|
||||
|
||||
|
||||
def _serialize_feed_alert(
|
||||
alert_id: str,
|
||||
alert_type: str,
|
||||
severity: str,
|
||||
message: str,
|
||||
created_at: datetime | None,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
event_type = payload.get("event_type") or alert_type or ""
|
||||
created = payload.get("created_at") or (created_at.isoformat() if created_at else None)
|
||||
return {
|
||||
"id": alert_id,
|
||||
"eventType": event_type,
|
||||
"severity": payload.get("severity") or severity or "warning",
|
||||
"loadingReportId": payload.get("loading_report_id") or "",
|
||||
"unloadingReportId": payload.get("unloading_report_id"),
|
||||
"recipeId": payload.get("recipe_id") or "",
|
||||
"recipeName": payload.get("recipe_name") or "",
|
||||
"componentName": payload.get("component_name"),
|
||||
"groupName": payload.get("group_name"),
|
||||
"detail": payload.get("detail") or message or "",
|
||||
"deviationKg": payload.get("deviation_kg"),
|
||||
"deviationPct": payload.get("deviation_pct"),
|
||||
"costDeviationRub": payload.get("cost_deviation_rub"),
|
||||
"clientId": payload.get("client_id"),
|
||||
"createdAt": created,
|
||||
"linkKind": "report_loading",
|
||||
"linkId": payload.get("loading_report_id") or "",
|
||||
"targetKg": None,
|
||||
"actualKg": None,
|
||||
"durationSec": None,
|
||||
}
|
||||
|
||||
|
||||
def _feed_alerts_for_range(
|
||||
enterprise_id: str,
|
||||
window: tuple[datetime, datetime] | None,
|
||||
*,
|
||||
severity: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict[str, Any]]:
|
||||
loading_by_id = {
|
||||
report_id: payload for report_id, payload in _loading_reports_in_range(enterprise_id, window)
|
||||
}
|
||||
alert_rows: list[tuple[str, str, str, str, datetime | None, dict[str, Any]]] = []
|
||||
with session_scope() as db:
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ZootechFeedAlert).where(
|
||||
ZootechFeedAlert.enterprise_id == enterprise_id,
|
||||
ZootechFeedAlert.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
payload = _parse_alert_payload_json(row.payload_json)
|
||||
alert_rows.append(
|
||||
(row.id, row.alert_type, row.severity, row.message, row.created_at, payload)
|
||||
)
|
||||
items: list[dict[str, Any]] = []
|
||||
for alert_id, alert_type, sev, message, created_at, payload in alert_rows:
|
||||
loading_id = str(payload.get("loading_report_id") or "").strip()
|
||||
if window and loading_id and loading_id not in loading_by_id:
|
||||
continue
|
||||
item = _serialize_feed_alert(alert_id, alert_type, sev, message, created_at, payload)
|
||||
if severity and (item.get("severity") or "").lower() != severity.strip().lower():
|
||||
continue
|
||||
items.append(item)
|
||||
items.sort(key=lambda item: str(item.get("createdAt") or ""), reverse=True)
|
||||
return items[: max(1, min(limit, 500))]
|
||||
|
||||
|
||||
@router.get("/feed-quality/alerts/summary")
|
||||
def feed_quality_alerts_summary_wesp(
|
||||
enterprise_id: str = Query(...),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
window = _parse_date_window(date_from, date_to)
|
||||
items = _feed_alerts_for_range(enterprise_id, window, limit=500)
|
||||
by_severity = {"warning": 0, "error": 0, "info": 0}
|
||||
for item in items:
|
||||
sev = (item.get("severity") or "warning").lower()
|
||||
if sev in by_severity:
|
||||
by_severity[sev] += 1
|
||||
return {
|
||||
"total": len(items),
|
||||
"warning": by_severity["warning"],
|
||||
"error": by_severity["error"],
|
||||
"info": by_severity["info"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/feed-quality/alerts")
|
||||
def feed_quality_alerts_wesp(
|
||||
enterprise_id: str = Query(...),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
severity: str | None = Query(None),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
_require_ent(enterprise_id, tenant)
|
||||
window = _parse_date_window(date_from, date_to)
|
||||
items = _feed_alerts_for_range(enterprise_id, window, severity=severity, limit=limit)
|
||||
by_severity = {"warning": 0, "error": 0, "info": 0}
|
||||
for item in items:
|
||||
sev = (item.get("severity") or "warning").lower()
|
||||
if sev in by_severity:
|
||||
by_severity[sev] += 1
|
||||
return {"items": items, "total": len(items), "bySeverity": by_severity}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""WESP-shaped auth endpoints for copied static UI (/api/auth/*)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.modules.auth.router import _set_refresh_cookie
|
||||
from app.modules.auth.service import login as auth_login
|
||||
from app.modules.users.models import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["zootech-wesp-auth"])
|
||||
|
||||
_USER_RULES = {
|
||||
"password_min_len": 8,
|
||||
"login_min_len": 3,
|
||||
}
|
||||
|
||||
|
||||
class WespLoginIn(BaseModel):
|
||||
login: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
remember: bool = False
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def wesp_auth_login(payload: WespLoginIn, response: Response):
|
||||
email = payload.login.strip()
|
||||
if "@" not in email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"status": "error", "message": "Используйте email (admin@compton.example)"},
|
||||
)
|
||||
try:
|
||||
access_token, refresh_token, user = auth_login(email, payload.password)
|
||||
except ValueError:
|
||||
return {"status": "error", "message": "Неверный логин или пароль"}
|
||||
except PermissionError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "EMAIL_NOT_VERIFIED":
|
||||
return {"status": "error", "message": "Email не подтверждён"}
|
||||
if detail == "ACCOUNT_BLOCKED":
|
||||
return {"status": "error", "message": "Учётная запись заблокирована"}
|
||||
return {"status": "error", "message": "Вход временно недоступен"}
|
||||
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Авторизация успешна",
|
||||
"authenticated": True,
|
||||
"user_login": user.email,
|
||||
"access_token": access_token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check")
|
||||
def wesp_auth_check(user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"status": "success",
|
||||
"authenticated": True,
|
||||
"user_login": user.email,
|
||||
"remember_login": False,
|
||||
"is_superuser": bool(user.is_superuser),
|
||||
"can_lab": bool(user.is_superuser),
|
||||
"user_rules": _USER_RULES,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/get_current_credentials")
|
||||
def wesp_get_current_credentials(user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"status": "success",
|
||||
"login": user.email,
|
||||
"password": "",
|
||||
"user_rules": _USER_RULES,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def wesp_logout():
|
||||
return {"status": "success", "message": "Выход выполнен успешно"}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""WESP-shaped misc endpoints: notifications, updates (/api/notifications, /api/updates/*)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.modules.users.models import User
|
||||
|
||||
router = APIRouter(tags=["zootech-wesp-misc"])
|
||||
|
||||
|
||||
def _empty_notifications(*, summary: bool = False) -> dict:
|
||||
today = date.today().isoformat()
|
||||
if summary:
|
||||
return {"items": [], "unreadCount": 0}
|
||||
return {
|
||||
"items": [],
|
||||
"unreadCount": 0,
|
||||
"date": today,
|
||||
"prevDate": None,
|
||||
"hasMore": False,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
def wesp_notifications_list(
|
||||
summary: str | None = None,
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
if summary in ("1", "true", "yes"):
|
||||
return _empty_notifications(summary=True)
|
||||
return _empty_notifications()
|
||||
|
||||
|
||||
@router.post("/notifications")
|
||||
def wesp_notifications_create(_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": "orchestrator-stub",
|
||||
"title": "",
|
||||
"detail": "",
|
||||
"kind": "info",
|
||||
"category": "general",
|
||||
"read": False,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/notifications/read-all")
|
||||
def wesp_notifications_read_all(_user: User = Depends(get_current_user)):
|
||||
return {"success": True, "marked": 0}
|
||||
|
||||
|
||||
@router.patch("/notifications/{notification_id}/read")
|
||||
def wesp_notifications_mark_read(notification_id: str, _user: User = Depends(get_current_user)):
|
||||
return {"id": notification_id, "read": True}
|
||||
|
||||
|
||||
@router.get("/updates/status")
|
||||
def wesp_updates_status(_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"initialized": False,
|
||||
"check_enabled": False,
|
||||
"current_version": "1.0.0-orchestrator",
|
||||
"update_available": False,
|
||||
"pending_version": None,
|
||||
"pending_name": None,
|
||||
"pending_body": None,
|
||||
"published_at": None,
|
||||
"last_check_at": None,
|
||||
"is_running": False,
|
||||
"is_updating": False,
|
||||
"update_progress": None,
|
||||
"last_update_state": {"status": "idle", "note": "orchestrator deploy"},
|
||||
"gitea_configured": False,
|
||||
"restart_configured": False,
|
||||
"gitea_url": None,
|
||||
"gitea_repo": None,
|
||||
}
|
||||
|
||||
|
||||
class UpdatesInstallIn(BaseModel):
|
||||
version: str | None = None
|
||||
|
||||
|
||||
@router.post("/updates/install")
|
||||
def wesp_updates_install(_payload: UpdatesInstallIn, _user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Автообновление недоступно на orchestrator — используйте деплой Docker.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/updates/check")
|
||||
def wesp_updates_check(_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"update_available": False,
|
||||
"message": "Проверка обновлений недоступна на orchestrator.",
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"""WESP-shaped reports API for copied static UI (/api/reports)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||
from app.modules.zootech.report_models import ZootechLoadingReport, ZootechUnloadingReport
|
||||
from sqlalchemy import select
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
|
||||
|
||||
def _parse_payload(row: ZootechLoadingReport | ZootechUnloadingReport) -> dict[str, Any]:
|
||||
if not row.payload_json:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(row.payload_json)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _parse_date_range(
|
||||
date_from: str | None, date_to: str | None
|
||||
) -> tuple[datetime, datetime] | None:
|
||||
if not date_from or not date_to:
|
||||
end = datetime.now(UTC)
|
||||
return end - timedelta(hours=24), end + timedelta(seconds=1)
|
||||
try:
|
||||
start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1)
|
||||
return start, end
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": True, "message": "Некорректный формат date_from/date_to (YYYY-MM-DD)"},
|
||||
) from exc
|
||||
|
||||
|
||||
def _parse_report_time(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _in_range(start_time: datetime | None, window: tuple[datetime, datetime]) -> bool:
|
||||
if start_time is None:
|
||||
return False
|
||||
start, end = window
|
||||
return start <= start_time < end
|
||||
|
||||
|
||||
def _serialize_unloading_id(unloading_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
groups = payload.get("unloading_groups")
|
||||
if not isinstance(groups, list):
|
||||
groups = payload.get("groups") if isinstance(payload.get("groups"), list) else []
|
||||
return {
|
||||
"id": unloading_id,
|
||||
"start_time": payload.get("start_time"),
|
||||
"end_time": payload.get("end_time"),
|
||||
"total_weight": payload.get("total_weight"),
|
||||
"total_unloaded_weight": payload.get("total_unloaded_weight"),
|
||||
"remaining_weight": payload.get("remaining_weight"),
|
||||
"unloading_groups": groups,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_unloading(row: ZootechUnloadingReport, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return _serialize_unloading_id(row.id, payload)
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports_wesp(
|
||||
enterprise_id: str = Query(...),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
limit: int = Query(500, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
"""Legacy-compatible aggregated loading reports for reports.html."""
|
||||
_require_ent(enterprise_id, tenant)
|
||||
window = _parse_date_range(date_from, date_to)
|
||||
|
||||
with session_scope() as db:
|
||||
loading_rows = list(
|
||||
db.scalars(
|
||||
select(ZootechLoadingReport).where(
|
||||
ZootechLoadingReport.enterprise_id == enterprise_id,
|
||||
ZootechLoadingReport.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
unloading_rows = list(
|
||||
db.scalars(
|
||||
select(ZootechUnloadingReport).where(
|
||||
ZootechUnloadingReport.enterprise_id == enterprise_id,
|
||||
ZootechUnloadingReport.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
loading_data = [(row.id, _parse_payload(row)) for row in loading_rows]
|
||||
unloading_data: list[tuple[str, str, dict[str, Any]]] = []
|
||||
for row in unloading_rows:
|
||||
payload = _parse_payload(row)
|
||||
loading_id = str(payload.get("loading_report_id") or "").strip()
|
||||
if loading_id:
|
||||
unloading_data.append((loading_id, row.id, payload))
|
||||
|
||||
unloading_by_loading: dict[str, tuple[str, dict[str, Any]]] = {
|
||||
loading_id: (unloading_id, payload) for loading_id, unloading_id, payload in unloading_data
|
||||
}
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
for row_id, data in loading_data:
|
||||
start_time = _parse_report_time(data.get("start_time"))
|
||||
if window and not _in_range(start_time, window):
|
||||
continue
|
||||
unloading = unloading_by_loading.get(row_id)
|
||||
unloading_payload = (
|
||||
_serialize_unloading_id(unloading[0], unloading[1]) if unloading else None
|
||||
)
|
||||
payload.append(
|
||||
{
|
||||
"id": row_id,
|
||||
"recipe_id": data.get("recipe_id"),
|
||||
"recipe_name": data.get("recipe_name"),
|
||||
"start_time": data.get("start_time"),
|
||||
"end_time": data.get("end_time"),
|
||||
"target_mixing_time": data.get("target_mixing_time"),
|
||||
"actual_mixing_time": data.get("actual_mixing_time"),
|
||||
"total_weight": data.get("total_weight"),
|
||||
"dispenser_type": data.get("dispenser_type") or "dispenser",
|
||||
"components": data.get("components") if isinstance(data.get("components"), list) else [],
|
||||
"component_loading_times": (
|
||||
data.get("component_loading_times")
|
||||
if isinstance(data.get("component_loading_times"), list)
|
||||
else []
|
||||
),
|
||||
"unloading_data": unloading_payload,
|
||||
}
|
||||
)
|
||||
|
||||
payload.sort(key=lambda item: str(item.get("start_time") or ""), reverse=True)
|
||||
result = payload[offset : offset + limit]
|
||||
|
||||
# #region agent log
|
||||
try:
|
||||
import pathlib
|
||||
_log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log")
|
||||
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _log_path.open("a", encoding="utf-8") as _lf:
|
||||
_lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"A,B","location":"wesp_compat_reports.py:list","message":"reports api response","data":{"date_from":date_from,"date_to":date_to,"total_before_filter":len(payload),"returned":len(result),"component_counts":[len(r.get("components") or []) for r in result[:5]]},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/reports/ping")
|
||||
def reports_ping_wesp():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""WESP /api/sync/clients compat — maps to orchestrator farm hubs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.modules.sync import repository as sync_repo
|
||||
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/sync/clients")
|
||||
def list_sync_clients(
|
||||
enterprise_id: str = Query(...),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
if tenant.enterprise_id != enterprise_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
|
||||
hubs = sync_repo.list_farm_hubs(enterprise_id)
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"node_id": h.hub_site_id,
|
||||
"client_name": h.name,
|
||||
"status": h.status,
|
||||
"last_seen": h.last_seen.isoformat() if h.last_seen else None,
|
||||
}
|
||||
for h in hubs
|
||||
]
|
||||
@@ -0,0 +1,352 @@
|
||||
"""WESP-shaped recipe write/calculate for orchestrator static UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.engine import SyncEngine
|
||||
from app.modules.sync.schemas import ChangeEventIn
|
||||
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||
from app.modules.zootech.catalog_models import ZootechUnloadingGroup
|
||||
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||
from app.modules.zootech.recipe_calculator import calculate_recipe
|
||||
from app.modules.zootech.sync_content_hash import compute_content_hash
|
||||
|
||||
|
||||
class RecipeWriteError(Exception):
|
||||
def __init__(self, message: str, status_code: int = 400):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _normalize_groups(raw: list[Any]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for group in raw:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
g = dict(group)
|
||||
if "distributionType" not in g and "distribution_type" in g:
|
||||
g["distributionType"] = g.get("distribution_type")
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
def calculate_recipe_wesp(body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise RecipeWriteError("Данные не предоставлены", 400)
|
||||
try:
|
||||
heads_count = int(body.get("headsCount") or body.get("heads_count") or body.get("headsPerTrip") or 0)
|
||||
trip_percent = float(body.get("tripPercent") or body.get("trip_percent") or 100)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RecipeWriteError("Некорректные числовые параметры", 400) from exc
|
||||
|
||||
ingredients = body.get("ingredients") or []
|
||||
unloading_groups = _normalize_groups(body.get("unloadingGroups") or body.get("unloading_groups") or [])
|
||||
calculate_from_dry_matter = bool(
|
||||
body.get("calculateFromDryMatter")
|
||||
if body.get("calculateFromDryMatter") is not None
|
||||
else body.get("calculate_from_dry_matter", False)
|
||||
)
|
||||
|
||||
component_ids = [i.get("component_id") for i in ingredients if isinstance(i, dict) and i.get("component_id")]
|
||||
component_dry_matter_map: dict[str, float] = {}
|
||||
if component_ids:
|
||||
with session_scope() as db:
|
||||
for comp in db.scalars(select(ZootechComponent).where(ZootechComponent.id.in_(component_ids))):
|
||||
component_dry_matter_map[comp.id] = float(comp.dry_matter or 0)
|
||||
|
||||
result = calculate_recipe(
|
||||
ingredients=[i for i in ingredients if isinstance(i, dict)],
|
||||
heads_count=heads_count,
|
||||
trip_percent=trip_percent,
|
||||
unloading_groups=unloading_groups,
|
||||
component_dry_matter_map=component_dry_matter_map or None,
|
||||
calculate_from_dry_matter=calculate_from_dry_matter,
|
||||
)
|
||||
|
||||
def _to_float2(x: Any) -> float:
|
||||
try:
|
||||
return round(float(x), 2)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def _truncate2(x: Any) -> float:
|
||||
try:
|
||||
v = float(x)
|
||||
return float(int(v * 100)) / 100.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
for ing in result.get("ingredients") or []:
|
||||
for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"):
|
||||
if key in ing and ing[key] is not None:
|
||||
raw_val = ing[key]
|
||||
v = _truncate2(raw_val) if key == "weightPerHead" else _to_float2(raw_val)
|
||||
ing[key] = f"{v:.2f}" if key == "weightPerHead" else v
|
||||
|
||||
totals = result.get("totals") or {}
|
||||
for key in ("totalWeight", "totalTripWeight", "totalDryMatterPerHead", "totalWeightPerHead"):
|
||||
if key in totals and totals[key] is not None:
|
||||
totals[key] = _to_float2(totals[key])
|
||||
result["totals"] = totals
|
||||
return result
|
||||
|
||||
|
||||
def _push_change(
|
||||
enterprise_id: str,
|
||||
table: str,
|
||||
record_id: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
action: str = "upsert",
|
||||
) -> None:
|
||||
version = int(payload.get("version") or 1)
|
||||
content_hash = compute_content_hash(payload)
|
||||
payload = {**payload, "version": version, "content_hash": content_hash}
|
||||
event = ChangeEventIn(
|
||||
event_id=str(uuid4()),
|
||||
seq=0,
|
||||
domain="global",
|
||||
table=table,
|
||||
record_id=record_id,
|
||||
action=action,
|
||||
version=version,
|
||||
content_hash=content_hash,
|
||||
payload=payload,
|
||||
emitted_at=datetime.now(UTC),
|
||||
origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID,
|
||||
)
|
||||
SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator")
|
||||
|
||||
|
||||
def _resolve_component(enterprise_id: str, ing: dict[str, Any]) -> ZootechComponent | None:
|
||||
comp_id = ing.get("component_id")
|
||||
with session_scope() as db:
|
||||
if comp_id:
|
||||
row = db.scalar(
|
||||
select(ZootechComponent).where(
|
||||
ZootechComponent.enterprise_id == enterprise_id,
|
||||
ZootechComponent.id == str(comp_id),
|
||||
ZootechComponent.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if row:
|
||||
return row
|
||||
name = (ing.get("name") or "").strip()
|
||||
if name:
|
||||
return db.scalar(
|
||||
select(ZootechComponent).where(
|
||||
ZootechComponent.enterprise_id == enterprise_id,
|
||||
ZootechComponent.name == name,
|
||||
ZootechComponent.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def update_recipe_wesp(enterprise_id: str, recipe_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
if not data:
|
||||
raise RecipeWriteError("Данные не предоставлены", 400)
|
||||
for key in ("name", "heads_count", "mixing_time"):
|
||||
if key not in data:
|
||||
raise RecipeWriteError(f'Отсутствует обязательное поле "{key}"', 400)
|
||||
|
||||
with session_scope() as db:
|
||||
recipe = db.scalar(
|
||||
select(ZootechRecipe).where(
|
||||
ZootechRecipe.enterprise_id == enterprise_id,
|
||||
ZootechRecipe.id == recipe_id,
|
||||
ZootechRecipe.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if not recipe:
|
||||
raise RecipeWriteError("Рецепт не найден", 404)
|
||||
|
||||
existing_ings = list(
|
||||
db.scalars(
|
||||
select(ZootechIngredient).where(
|
||||
ZootechIngredient.enterprise_id == enterprise_id,
|
||||
ZootechIngredient.recipe_id == recipe_id,
|
||||
ZootechIngredient.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_groups = list(
|
||||
db.scalars(
|
||||
select(ZootechUnloadingGroup).where(
|
||||
ZootechUnloadingGroup.enterprise_id == enterprise_id,
|
||||
ZootechUnloadingGroup.recipe_id == recipe_id,
|
||||
ZootechUnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
recipe_trip_percent = float(recipe.trip_percent or 100)
|
||||
recipe_dry_matter_locked = bool(recipe.dry_matter_locked)
|
||||
recipe_unloading_link_broken = bool(recipe.unloading_link_broken)
|
||||
recipe_target_component_id = recipe.target_component_id
|
||||
recipe_version = int(recipe.version or 1) + 1
|
||||
ing_by_id = {
|
||||
str(i.id): {"id": str(i.id), "name": i.name, "version": int(i.version or 1)}
|
||||
for i in existing_ings
|
||||
}
|
||||
grp_by_id = {
|
||||
str(g.id): {"id": str(g.id), "name": g.name, "version": int(g.version or 1)}
|
||||
for g in existing_groups
|
||||
}
|
||||
|
||||
recipe_payload = {
|
||||
"id": recipe_id,
|
||||
"name": str(data["name"]),
|
||||
"heads_per_trip": int(data["heads_count"]),
|
||||
"mixing_time": int(data["mixing_time"]),
|
||||
"trip_percent": float(data.get("trip_percent") or recipe_trip_percent),
|
||||
"dry_matter_locked": bool(data.get("dry_matter_locked", recipe_dry_matter_locked)),
|
||||
"unloading_link_broken": bool(data.get("unloading_link_broken", recipe_unloading_link_broken)),
|
||||
"target_component_id": data.get("target_component_id", recipe_target_component_id),
|
||||
"version": recipe_version,
|
||||
}
|
||||
_push_change(enterprise_id, "recipe", recipe_id, recipe_payload)
|
||||
|
||||
seen_ing_ids: set[str] = set()
|
||||
for idx, ing in enumerate([x for x in (data.get("ingredients") or []) if isinstance(x, dict)], start=1):
|
||||
component = _resolve_component(enterprise_id, ing)
|
||||
if not component:
|
||||
raise RecipeWriteError(
|
||||
f'Компонент не найден (id="{ing.get("component_id", "")}", name="{ing.get("name", "")}")',
|
||||
400,
|
||||
)
|
||||
ing_id = str(ing.get("id") or "").strip() or str(uuid4())
|
||||
existing = ing_by_id.get(ing_id)
|
||||
order_value = int(ing.get("order") or idx)
|
||||
wph = float(ing.get("weight_per_head") or ing.get("weightPerHead") or 0)
|
||||
amount = float(ing.get("amount") or 0)
|
||||
dm = float(ing.get("dry_matter") or component.dry_matter or 0)
|
||||
dm_ph = ing.get("dry_matter_per_head")
|
||||
if dm_ph is None:
|
||||
dm_ph = ing.get("dryMatterPerHead")
|
||||
dm_ph_f = float(dm_ph) if dm_ph not in (None, "") else (wph * (dm / 100.0) if wph and dm else 0.0)
|
||||
version = int(existing.get("version") or 1) + 1 if existing else 1
|
||||
payload = {
|
||||
"id": ing_id,
|
||||
"recipe_id": recipe_id,
|
||||
"component_id": component.id,
|
||||
"name": component.name,
|
||||
"amount": amount,
|
||||
"weight_per_head": wph,
|
||||
"dry_matter": dm,
|
||||
"dry_matter_per_head": dm_ph_f,
|
||||
"order": order_value,
|
||||
"version": version,
|
||||
}
|
||||
_push_change(enterprise_id, "ingredient", ing_id, payload)
|
||||
seen_ing_ids.add(ing_id)
|
||||
|
||||
for raw_id in data.get("deleted_ingredient_ids") or []:
|
||||
ing_id = str(raw_id or "").strip()
|
||||
if not ing_id or ing_id not in ing_by_id:
|
||||
continue
|
||||
existing = ing_by_id[ing_id]
|
||||
version = int(existing["version"] or 1) + 1
|
||||
payload = {
|
||||
"id": ing_id,
|
||||
"recipe_id": recipe_id,
|
||||
"name": existing["name"],
|
||||
"version": version,
|
||||
}
|
||||
_push_change(enterprise_id, "ingredient", ing_id, payload, action="delete")
|
||||
|
||||
for ing_id, existing in ing_by_id.items():
|
||||
if ing_id not in seen_ing_ids and ing_id not in {str(x) for x in (data.get("deleted_ingredient_ids") or [])}:
|
||||
version = int(existing["version"] or 1) + 1
|
||||
_push_change(
|
||||
enterprise_id,
|
||||
"ingredient",
|
||||
ing_id,
|
||||
{"id": ing_id, "recipe_id": recipe_id, "name": existing["name"], "version": version},
|
||||
action="delete",
|
||||
)
|
||||
|
||||
seen_grp_ids: set[str] = set()
|
||||
groups_in = [x for x in (data.get("unloading_groups") or data.get("unloadingGroups") or []) if isinstance(x, dict)]
|
||||
for idx, group in enumerate(groups_in, start=1):
|
||||
try:
|
||||
gname = str(group["name"])
|
||||
gdist = str(group.get("distribution_type") or group.get("distributionType") or "percent")
|
||||
gval = float(group.get("value") or 0)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise RecipeWriteError(f"Некорректная группа выгрузки (order={idx}): {exc}", 400) from exc
|
||||
grp_id = str(group.get("id") or "").strip() or str(uuid4())
|
||||
existing = grp_by_id.get(grp_id)
|
||||
order_value = int(group.get("order") or idx)
|
||||
weight_raw = group.get("weight")
|
||||
weight = float(weight_raw) if weight_raw not in (None, "") else None
|
||||
version = int(existing.get("version") or 1) + 1 if existing else 1
|
||||
payload = {
|
||||
"id": grp_id,
|
||||
"recipe_id": recipe_id,
|
||||
"name": gname,
|
||||
"distribution_type": gdist,
|
||||
"value": gval,
|
||||
"weight": weight,
|
||||
"order": order_value,
|
||||
"version": version,
|
||||
}
|
||||
_push_change(enterprise_id, "unloading_group", grp_id, payload)
|
||||
seen_grp_ids.add(grp_id)
|
||||
|
||||
for raw_id in data.get("deleted_unloading_group_ids") or []:
|
||||
grp_id = str(raw_id or "").strip()
|
||||
if not grp_id or grp_id not in grp_by_id:
|
||||
continue
|
||||
existing = grp_by_id[grp_id]
|
||||
version = int(existing["version"] or 1) + 1
|
||||
_push_change(
|
||||
enterprise_id,
|
||||
"unloading_group",
|
||||
grp_id,
|
||||
{"id": grp_id, "recipe_id": recipe_id, "name": existing["name"], "version": version},
|
||||
action="delete",
|
||||
)
|
||||
|
||||
for grp_id, existing in grp_by_id.items():
|
||||
if grp_id not in seen_grp_ids and grp_id not in {str(x) for x in (data.get("deleted_unloading_group_ids") or [])}:
|
||||
version = int(existing["version"] or 1) + 1
|
||||
_push_change(
|
||||
enterprise_id,
|
||||
"unloading_group",
|
||||
grp_id,
|
||||
{"id": grp_id, "recipe_id": recipe_id, "name": existing["name"], "version": version},
|
||||
action="delete",
|
||||
)
|
||||
|
||||
with session_scope() as db:
|
||||
saved = db.scalar(
|
||||
select(ZootechRecipe).where(
|
||||
ZootechRecipe.enterprise_id == enterprise_id,
|
||||
ZootechRecipe.id == recipe_id,
|
||||
ZootechRecipe.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if not saved:
|
||||
raise RecipeWriteError("Рецепт не найден после сохранения", 404)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Рецепт обновлен",
|
||||
"id": recipe_id,
|
||||
"stats": {
|
||||
"ingredients": len(seen_ing_ids),
|
||||
"unloading_groups": len(seen_grp_ids),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def recipe_write_http_error(exc: RecipeWriteError) -> HTTPException:
|
||||
return HTTPException(status_code=exc.status_code, detail={"message": exc.message, "error": True})
|
||||
Reference in New Issue
Block a user