Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+1
View File
@@ -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",
]
+94
View File
@@ -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),
)
+151
View File
@@ -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
+84
View File
@@ -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))
+435
View File
@@ -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})