Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+538 -24
View File
@@ -3,12 +3,14 @@
from __future__ import annotations
import json
from datetime import date as date_cls
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.db.base import Base
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 (
@@ -17,18 +19,87 @@ from app.modules.zootech.catalog_models import (
ZootechPeriodRecipe,
ZootechUnloadingGroup,
)
from app.modules.zootech import service as zootech_service
from app.modules.zootech.service import ZootechServiceError
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
router = APIRouter()
_LAB_NUTRIENT_MODEL = None
def _lab_nutrient_model():
global _LAB_NUTRIENT_MODEL
if _LAB_NUTRIENT_MODEL is not False and _LAB_NUTRIENT_MODEL is None:
try:
from sqlalchemy import Float, String, inspect
from sqlalchemy.orm import Mapped, mapped_column
class ZootechLabComponentNutrientValue(Base):
__tablename__ = "lab_component_nutrient_value"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
component_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
nutrient_key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
value: Mapped[float | None] = mapped_column(Float, nullable=True)
with session_scope() as db:
if "lab_component_nutrient_value" not in inspect(db.bind).get_table_names():
_LAB_NUTRIENT_MODEL = False
else:
_LAB_NUTRIENT_MODEL = ZootechLabComponentNutrientValue
except Exception:
_LAB_NUTRIENT_MODEL = False
return _LAB_NUTRIENT_MODEL if _LAB_NUTRIENT_MODEL not in (None, False) else None
def _component_nutrients_dict(db, component_id: str) -> dict[str, float]:
model = _lab_nutrient_model()
if model is None:
return {}
rows = list(
db.scalars(select(model).where(model.component_id == component_id))
)
return {
row.nutrient_key: float(row.value)
for row in rows
if row.value is not None and row.nutrient_key != "СВ"
}
def _save_component_nutrients(db, component_id: str, nutrients: dict) -> None:
from uuid import uuid4
model = _lab_nutrient_model()
if model is None or not isinstance(nutrients, dict):
return
existing = {
row.nutrient_key: row
for row in db.scalars(select(model).where(model.component_id == component_id))
}
for key, value in nutrients.items():
if value is None or key == "СВ":
continue
row = existing.get(key)
if row is None:
db.add(
model(
id=str(uuid4()),
component_id=component_id,
nutrient_key=str(key),
value=float(value),
)
)
else:
row.value = float(value)
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 {
def _component_wesp(row: ZootechComponent, db=None) -> dict[str, Any]:
payload = {
"id": row.id,
"name": row.name,
"type": row.type,
@@ -41,6 +112,20 @@ def _component_wesp(row: ZootechComponent) -> dict[str, Any]:
"version": row.version,
"content_hash": row.content_hash,
}
if db is not None:
nutrients = _component_nutrients_dict(db, row.id)
if nutrients:
payload["nutrients"] = nutrients
return payload
def _component_body_from_wesp(body: dict[str, Any]) -> dict[str, Any]:
normalized = dict(body)
if "externalNo" in body and "external_no" not in normalized:
normalized["external_no"] = body["externalNo"]
if "dryMatter" in body and "dry_matter" not in normalized:
normalized["dry_matter"] = body["dryMatter"]
return normalized
def _recipe_short(row: ZootechRecipe) -> dict[str, Any]:
@@ -53,6 +138,50 @@ def _recipe_short(row: ZootechRecipe) -> dict[str, Any]:
}
def _payload_meta(row) -> dict[str, Any]:
if not row.payload_json:
return {}
try:
parsed = json.loads(row.payload_json)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _mill_recipes_for_dispenser(
db,
enterprise_id: str,
dispenser_id: str,
exclude_ids: set[str],
) -> list[dict]:
recipes = list(
db.scalars(
select(ZootechRecipe).where(
ZootechRecipe.enterprise_id == enterprise_id,
ZootechRecipe.is_deleted.is_(False),
)
)
)
out: list[dict] = []
for recipe in recipes:
if recipe.id in exclude_ids:
continue
meta = _payload_meta(recipe)
if str(meta.get("dispenser_id") or "") == str(dispenser_id):
out.append(_recipe_short(recipe))
return out
def _period_wesp(db, enterprise_id: str, period: ZootechFeedingPeriod, *, has_skip_today: bool = False) -> dict[str, Any]:
return {
"id": period.id,
"name": period.name,
"dispenser_id": period.dispenser_id,
"recipes": _recipes_for_period(db, enterprise_id, period.id),
"hasSkipToday": has_skip_today,
}
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)
@@ -158,7 +287,7 @@ def _serialize_recipe_wesp(db, enterprise_id: str, recipe: ZootechRecipe) -> dic
}
def _dispenser_wesp(row: ZootechFeedDispenser, periods: list[dict] | None = None) -> dict[str, Any]:
def _dispenser_wesp(row: ZootechFeedDispenser, periods: list[dict] | None = None, *, has_skip_today: bool = False) -> dict[str, Any]:
extra: dict[str, Any] = {}
if row.payload_json:
try:
@@ -171,7 +300,7 @@ def _dispenser_wesp(row: ZootechFeedDispenser, periods: list[dict] | None = None
"version": row.version,
"content_hash": row.content_hash,
"periods": periods or [],
"hasSkipToday": False,
"hasSkipToday": has_skip_today,
**{k: v for k, v in extra.items() if k not in ("id", "name")},
}
@@ -202,7 +331,46 @@ def wesp_list_components(
.limit(limit)
)
)
return [_component_wesp(r) for r in rows]
return [_component_wesp(r, db) for r in rows]
@router.post("/components")
def wesp_create_component(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
row = zootech_service.upsert_component(enterprise_id, None, _component_body_from_wesp(body))
with session_scope() as db:
component = db.scalar(
select(ZootechComponent).where(
ZootechComponent.enterprise_id == enterprise_id,
ZootechComponent.id == row["id"],
)
)
if not component:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="CREATE_FAILED")
payload = _component_wesp(component, db)
payload["success"] = True
return payload
_FEED_TYPE_OPTIONS = [
{"value": "Грубые корма", "groupLabel": "Корма"},
{"value": "Сочные корма", "groupLabel": "Корма"},
{"value": "Концентрированные", "groupLabel": "Корма"},
{"value": "Добавки", "groupLabel": "Корма"},
]
@router.get("/components/feed-types")
def wesp_component_feed_types(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
return {"types": _FEED_TYPE_OPTIONS}
@router.get("/components/{component_id}")
@@ -212,22 +380,50 @@ def wesp_get_component(
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", ""),
}
with session_scope() as db:
component = db.scalar(
select(ZootechComponent).where(
ZootechComponent.enterprise_id == enterprise_id,
ZootechComponent.id == component_id,
ZootechComponent.is_deleted.is_(False),
)
)
if not component:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
return _component_wesp(component, db)
@router.put("/components/{component_id}")
def wesp_update_component(
component_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
nutrients = body.get("nutrients")
try:
zootech_service.patch_component(enterprise_id, component_id, _component_body_from_wesp(body))
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
if isinstance(nutrients, dict):
with session_scope() as db:
_save_component_nutrients(db, component_id, nutrients)
return {"success": True, "message": "Компонент обновлён"}
@router.delete("/components/{component_id}")
def wesp_delete_component(
component_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.delete_component(enterprise_id, component_id)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"success": True, "message": "Компонент удалён"}
@router.get("/feed_dispensers/ping")
@@ -269,6 +465,10 @@ def list_feed_dispensers(
offset: int = Query(0),
):
_require_ent(enterprise_id, tenant)
from app.modules.zootech import wesp_daily_plan_service as dp_service
today_iso = date_cls.today().isoformat()
skip_today_ids = dp_service.get_recipe_ids_with_any_skip(enterprise_id, today_iso)
with session_scope() as db:
dispensers = list(
db.scalars(
@@ -294,10 +494,111 @@ def list_feed_dispensers(
)
)
period_payload = [{"id": p.id, "name": p.name} for p in periods]
result.append(_dispenser_wesp(d, period_payload))
dispenser_has_skip = False
for period in periods:
for recipe in _recipes_for_period(db, enterprise_id, period.id):
if recipe["id"] in skip_today_ids:
dispenser_has_skip = True
break
if dispenser_has_skip:
break
result.append(_dispenser_wesp(d, period_payload, has_skip_today=dispenser_has_skip))
return result
@router.post("/feed_dispensers")
def create_feed_dispenser(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
row = zootech_service.upsert_feed_dispenser(enterprise_id, None, body)
with session_scope() as db:
dispenser = db.scalar(
select(ZootechFeedDispenser).where(
ZootechFeedDispenser.enterprise_id == enterprise_id,
ZootechFeedDispenser.id == row["id"],
)
)
if not dispenser:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="CREATE_FAILED")
return _dispenser_wesp(dispenser, [])
@router.put("/feed_dispensers/{dispenser_id}")
def update_feed_dispenser(
dispenser_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.patch_feed_dispenser(enterprise_id, dispenser_id, body)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"message": "Кормораздатчик обновлён"}
@router.delete("/feed_dispensers/{dispenser_id}")
def delete_feed_dispenser_route(
dispenser_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.delete_feed_dispenser(enterprise_id, dispenser_id)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"message": "Кормораздатчик удалён"}
@router.post("/feed_dispensers/{dispenser_id}/periods")
def create_dispenser_period(
dispenser_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
payload = {"name": body.get("name", ""), "dispenser_id": dispenser_id}
row = zootech_service.upsert_feeding_period(enterprise_id, None, payload)
return {"id": row["id"], "name": row.get("name", ""), "dispenser_id": dispenser_id}
@router.put("/feed_dispensers/{dispenser_id}/periods/{period_id}")
def update_dispenser_period(
dispenser_id: str,
period_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
row = zootech_service.patch_feeding_period(enterprise_id, period_id, {"name": body.get("name", ""), "dispenser_id": dispenser_id})
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"id": row["id"], "name": row.get("name", ""), "dispenser_id": dispenser_id, "success": True}
@router.delete("/feed_dispensers/{dispenser_id}/periods/{period_id}")
def delete_dispenser_period(
dispenser_id: str,
period_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.delete_feeding_period(enterprise_id, period_id)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"message": "Период удалён"}
@router.get("/feed_dispensers/{dispenser_id}/periods")
def dispenser_periods(
dispenser_id: str,
@@ -305,6 +606,10 @@ def dispenser_periods(
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
from app.modules.zootech import wesp_daily_plan_service as dp_service
today_iso = date_cls.today().isoformat()
skip_today_ids = dp_service.get_recipe_ids_with_any_skip(enterprise_id, today_iso)
with session_scope() as db:
periods = list(
db.scalars(
@@ -315,7 +620,12 @@ def dispenser_periods(
)
)
)
return [{"id": p.id, "name": p.name, "dispenser_id": p.dispenser_id} for p in periods]
result = []
for p in periods:
recipes = _recipes_for_period(db, enterprise_id, p.id)
period_has_skip = any(r["id"] in skip_today_ids for r in recipes)
result.append(_period_wesp(db, enterprise_id, p, has_skip_today=period_has_skip))
return result
def _recipes_for_period(db, enterprise_id: str, period_id: str) -> list[dict]:
@@ -352,6 +662,16 @@ def dispenser_recipes(
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
dispenser = db.scalar(
select(ZootechFeedDispenser).where(
ZootechFeedDispenser.enterprise_id == enterprise_id,
ZootechFeedDispenser.id == dispenser_id,
ZootechFeedDispenser.is_deleted.is_(False),
)
)
if not dispenser:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
periods = list(
db.scalars(
select(ZootechFeedingPeriod).where(
@@ -365,6 +685,14 @@ def dispenser_recipes(
for p in periods:
for r in _recipes_for_period(db, enterprise_id, p.id):
merged[r["id"]] = r
is_mill = _payload_meta(dispenser).get("type") == "mill"
mill_recipes: list[dict] = []
if is_mill:
mill_recipes = _mill_recipes_for_dispenser(db, enterprise_id, dispenser_id, set(merged.keys()))
for r in mill_recipes:
merged[r["id"]] = r
return list(merged.values())
@@ -388,6 +716,80 @@ def period_recipes(
return _recipes_for_period(db, enterprise_id, period_id)
@router.post("/periods/{period_id}/recipes")
def wesp_create_period_recipe(
period_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, create_recipe_wesp, recipe_write_http_error
_require_ent(enterprise_id, tenant)
try:
return create_recipe_wesp(enterprise_id, body, period_id=period_id)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.post("/recipes")
def wesp_create_recipe(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, create_recipe_wesp, recipe_write_http_error
_require_ent(enterprise_id, tenant)
try:
return create_recipe_wesp(enterprise_id, body)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
def _apply_trip_overlay(payload: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
result = dict(payload)
overlay_ings = {row["id"]: row for row in overlay.get("ingredients") or []}
overlay_groups = {row["id"]: row for row in overlay.get("unloadingGroups") or []}
ingredients = []
for ing in payload.get("ingredients") or []:
row = dict(ing)
trip_row = overlay_ings.get(row.get("id"))
if trip_row:
row["name"] = trip_row.get("name") or row.get("name")
row["weightPerHead"] = trip_row.get("weightPerHead", row.get("weightPerHead"))
row["weight_per_head"] = row["weightPerHead"]
row["amount"] = trip_row.get("totalKg", row.get("amount"))
if trip_row.get("componentId"):
row["component_id"] = trip_row["componentId"]
if trip_row.get("skippedToday"):
row["skippedToday"] = True
if trip_row.get("replacedToday"):
row["replacedToday"] = True
if trip_row.get("adjustedToday"):
row["adjustedToday"] = True
ingredients.append(row)
groups = []
for grp in payload.get("unloadingGroups") or []:
row = dict(grp)
trip_grp = overlay_groups.get(row.get("id"))
if trip_grp:
if trip_grp.get("weightKg") is not None:
row["weight"] = trip_grp["weightKg"]
if trip_grp.get("skippedToday"):
row["skippedToday"] = True
groups.append(row)
result["ingredients"] = ingredients
result["unloadingGroups"] = groups
result["unloading_groups"] = groups
if overlay.get("totalWeightKg") is not None:
result["totalWeightKg"] = overlay["totalWeightKg"]
result["total_weight"] = overlay["totalWeightKg"]
if overlay.get("unloadingTotalKg") is not None:
result["unloadingTotalKg"] = overlay["unloadingTotalKg"]
return result
@router.get("/recipes/{recipe_id}")
def wesp_get_recipe(
recipe_id: str,
@@ -406,7 +808,31 @@ def wesp_get_recipe(
)
if not recipe:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
return _serialize_recipe_wesp(db, enterprise_id, recipe)
payload = _serialize_recipe_wesp(db, enterprise_id, recipe)
if date:
from app.modules.zootech import wesp_daily_plan_service as dp_service
overlay = dp_service.trip_overlay_for_recipe(enterprise_id, recipe, date)
payload = _apply_trip_overlay(payload, overlay)
return payload
@router.get("/recipes/{recipe_id}/open-context")
def wesp_recipe_open_context(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
from app.modules.zootech import wesp_daily_plan_service as dp_service
try:
return dp_service.recipe_open_context(enterprise_id, recipe_id)
except LookupError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": str(exc)},
) from exc
@router.post("/recipes/calculate")
@@ -419,6 +845,32 @@ def wesp_calculate_recipe(body: dict):
raise recipe_write_http_error(exc) from exc
@router.put("/recipes/{recipe_id}/move")
def wesp_move_recipe_in_period(
recipe_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import (
RecipeWriteError,
move_recipe_in_period_wesp,
recipe_write_http_error,
)
_require_ent(enterprise_id, tenant)
try:
return move_recipe_in_period_wesp(
enterprise_id,
recipe_id,
period_id=str(body.get("period_id") or ""),
from_index=body.get("from_index", 0),
to_index=body.get("to_index", 0),
)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.put("/recipes/{recipe_id}")
def wesp_update_recipe(
recipe_id: str,
@@ -433,3 +885,65 @@ def wesp_update_recipe(
return update_recipe_wesp(enterprise_id, recipe_id, body)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.post("/feed_dispensers/{dispenser_id}/periods/{period_id}/recipes/{recipe_id}/transfer")
def wesp_transfer_period_recipe(
dispenser_id: str,
period_id: str,
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,
transfer_recipe_between_periods_wesp,
)
_require_ent(enterprise_id, tenant)
try:
return transfer_recipe_between_periods_wesp(
enterprise_id,
dispenser_id,
period_id,
recipe_id,
from_dispenser_id=str(body.get("from_dispenser_id") or ""),
from_period_id=str(body.get("from_period_id") or ""),
to_index=body.get("to_index", 0),
)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.delete("/feed_dispensers/{dispenser_id}/periods/{period_id}/recipes/{recipe_id}")
def wesp_unlink_period_recipe(
dispenser_id: str,
period_id: str,
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, recipe_write_http_error, unlink_recipe_from_period_wesp
_require_ent(enterprise_id, tenant)
try:
return unlink_recipe_from_period_wesp(enterprise_id, dispenser_id, period_id, recipe_id)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.delete("/recipes/{recipe_id}")
def wesp_delete_recipe(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, delete_recipe_wesp, recipe_write_http_error
_require_ent(enterprise_id, tenant)
try:
return delete_recipe_wesp(enterprise_id, recipe_id)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc