Files
site/apps/api/app/modules/zootech/wesp_compat_lab.py
T
2026-07-17 12:57:18 +03:00

695 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""WESP-shaped lab API (/api/lab) for zootech static UI."""
from __future__ import annotations
from contextlib import contextmanager
from html import escape
from typing import Any, Iterator
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import HTMLResponse
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.lab.calc.diff import compare_master_execution
from app.modules.zootech.lab.calc.feed_groups import list_feed_groups_api, parse_group_selections
from app.modules.zootech.lab.calc.formulate import FormulateRequest, formulate, list_formulate_components
from app.modules.zootech.lab.calc.formulate_validate import validate_components
from app.modules.zootech.lab.calc.gfe_norms import preview_dynamic_norms
from app.modules.zootech.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.commands import (
apply_from_master,
delete_animal_profile,
ensure_empty_master,
recalculate_ration,
seed_from_execution,
sync_from_execution,
upsert_animal_profile,
upsert_ration,
)
from app.modules.zootech.lab.etl.agrostar_xml_import import apply_agrostar_import
from app.modules.zootech.lab.etl.lab_import_router import parse_lab_import
from app.modules.zootech.lab.loaders.execution_loader import load_execution
from app.modules.zootech.lab.loaders.profile_loader import list_animal_profiles, load_animal_profile
from app.modules.zootech.lab.loaders.ration_loader import load_ration
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.norm_catalog import list_norm_indicators
from app.modules.zootech.lab.services.norms_params import load_norms_params
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
from app.modules.zootech.lab.services.seed_norms_catalog import (
ReferenceNormsCatalogEmptyError,
get_seed_norm_entry,
list_seed_norm_catalog,
)
from app.modules.zootech.lab.serde.api import diff_to_api, ration_to_api
from app.modules.zootech.models import ZootechRecipe
from app.modules.zootech.wesp_bridge_db import wesp_db_session
from app.modules.zootech.wesp_bridge_models import wesp_enterprise
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 _user(tenant: TenantContext) -> str:
return str(tenant.user_id or "system")
def _error(message: str, code: int = status.HTTP_400_BAD_REQUEST, **extra: Any) -> HTTPException:
body: dict[str, Any] = {"error": True, "message": message}
body.update(extra)
return HTTPException(status_code=code, detail=body)
@contextmanager
def _lab_ctx(enterprise_id: str) -> Iterator[None]:
with wesp_db_session(), wesp_enterprise(enterprise_id):
yield
def _parse_formulate_num(value) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _parse_int(value) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _render_print_html(
*,
title: str,
recipe_name: str,
ration_type: str,
heads: int,
mode: str,
lines: list[dict[str, Any]],
indicators: list[dict[str, Any]],
) -> str:
rows = []
for line in lines:
rows.append(
"<tr>"
f"<td>{escape(str(line.get('name') or '—'))}</td>"
f"<td class=\"num\">{escape(str(line.get('daily_kg') or ''))}</td>"
f"<td>{'✓' if line.get('included') else ''}</td>"
"</tr>"
)
badges = []
for ind in indicators[:12]:
label = escape(str(ind.get("label") or ind.get("key") or ""))
content = ind.get("content")
unit = escape(str(ind.get("unit") or ""))
badges.append(f"<span class=\"badge\">{label}: {content} {unit}</span>")
col_title = "В комб." if mode == "compound" else "В рац."
return f"""<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"><title>{escape(title)}</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 24px; color: #111; }}
h1 {{ font-size: 1.25rem; margin: 0 0 8px; }}
.meta {{ color: #555; margin-bottom: 16px; font-size: 0.9rem; }}
table {{ width: 100%; border-collapse: collapse; margin-bottom: 20px; }}
th, td {{ border: 1px solid #ccc; padding: 6px 8px; text-align: left; }}
th {{ background: #f5f5f5; }}
.num {{ text-align: right; }}
.indicators {{ display: flex; flex-wrap: wrap; gap: 8px; }}
.badge {{ border: 1px solid #ddd; border-radius: 4px; padding: 4px 8px; font-size: 0.85rem; }}
</style></head><body>
<h1>{escape(title)}</h1>
<div class="meta">Рецепт: {escape(recipe_name)} · Тип: {escape(ration_type)} · Голов/рейс: {heads}</div>
<table><thead><tr><th>Ингредиент</th><th class="num">кг/день</th><th>{col_title}</th></tr></thead>
<tbody>{''.join(rows)}</tbody></table>
<div class="indicators">{''.join(badges)}</div>
</body></html>"""
@router.get("/lab/health")
def lab_health():
return {"ok": True, "module": "lab"}
@router.get("/lab/recipes")
def list_lab_recipes(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
rows = db.execute(
select(ZootechRecipe.id, ZootechRecipe.name)
.where(
ZootechRecipe.enterprise_id == enterprise_id,
ZootechRecipe.is_deleted.is_(False),
)
.order_by(ZootechRecipe.name)
.limit(500)
).all()
return {"recipes": [{"id": rid, "name": name} for rid, name in rows]}
@router.post("/lab/import/parse")
async def post_lab_import_parse(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
file: UploadFile = File(...),
):
_require_ent(enterprise_id, tenant)
data = await file.read()
body = parse_lab_import(file.filename or "upload.bin", data)
if body.get("error"):
raise _error(body.get("message") or "parse failed", status.HTTP_400_BAD_REQUEST, **body)
return body
@router.post("/lab/import/agrostar-xml/apply")
def post_agrostar_apply(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
assignments = body.get("assignments") or []
if not assignments:
raise _error("assignments required")
dry_run = bool(body.get("dryRun") or body.get("dry_run"))
with _lab_ctx(enterprise_id):
result = apply_agrostar_import(assignments, user_id=_user(tenant), dry_run=dry_run)
return result.to_api_dict()
@router.get("/lab/rations/diff")
def get_diff_batch(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
recipe_ids: str = Query(""),
):
_require_ent(enterprise_id, tenant)
raw = recipe_ids.strip()
if not raw:
raise _error("recipe_ids required")
ids = [x.strip() for x in raw.split(",") if x.strip()]
results = []
with _lab_ctx(enterprise_id):
for recipe_id in ids:
try:
master = load_ration(recipe_id)
execution = load_execution(recipe_id)
except LookupError:
results.append({"recipeId": recipe_id, "error": "not_found"})
continue
master_lines = [
{"component_id": l.component_id, "daily_kg": l.daily_kg, "in_ration": l.in_ration}
for l in master.lines
]
exec_lines = [
{"component_id": l.component_id, "daily_kg_total": l.daily_kg_total}
for l in execution.lines
]
results.append({"recipeId": recipe_id, **diff_to_api(compare_master_execution(master_lines, exec_lines))})
return {"results": results}
@router.get("/lab/rations/{recipe_id}")
def get_ration(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
snapshot = load_ration(recipe_id)
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return ration_to_api(snapshot)
@router.put("/lab/rations/{recipe_id}")
def put_ration(
recipe_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
upsert_ration(recipe_id, body, _user(tenant))
snapshot = load_ration(recipe_id)
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
except Exception as exc:
raise _error(str(exc)) from exc
return ration_to_api(snapshot)
@router.post("/lab/rations/{recipe_id}/recalculate")
def post_recalculate(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = recalculate_ration(recipe_id, _user(tenant))
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
except ValueError as exc:
raise _error(str(exc)) from exc
return result
@router.get("/lab/rations/{recipe_id}/diff")
def get_diff(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
master = load_ration(recipe_id)
execution = load_execution(recipe_id)
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
master_lines = [
{"component_id": l.component_id, "daily_kg": l.daily_kg, "in_ration": l.in_ration}
for l in master.lines
]
exec_lines = [
{"component_id": l.component_id, "daily_kg_total": l.daily_kg_total}
for l in execution.lines
]
return diff_to_api(compare_master_execution(master_lines, exec_lines))
@router.post("/lab/rations/{recipe_id}/seed-from-execution")
def post_seed(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = seed_from_execution(recipe_id, _user(tenant))
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return result
@router.post("/lab/rations/{recipe_id}/ensure-empty")
def post_ensure_empty(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = ensure_empty_master(recipe_id, _user(tenant))
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return result
@router.post("/lab/rations/{recipe_id}/sync-from-execution")
def post_sync_from_execution(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = sync_from_execution(recipe_id, _user(tenant))
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return result
@router.post("/lab/rations/{recipe_id}/apply-from-master")
def post_apply(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = apply_from_master(recipe_id, _user(tenant))
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
except ValueError as exc:
raise _error(str(exc)) from exc
return result
@router.get("/lab/rations/{recipe_id}/compound")
def get_compound(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
snapshot = load_ration(recipe_id)
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return snapshot.compound_results or {}
@router.get("/lab/norm-indicators")
def get_norm_indicators(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
return {"indicators": list_norm_indicators()}
@router.get("/lab/norms-preview")
def get_norms_preview(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
method: str | None = Query(None),
mass_kg: str | None = Query(None),
milk_yield_kg: str | None = Query(None),
profile_id: str | None = Query(None),
milk_fat_pct: str | None = Query(None),
lactation_no: str | None = Query(None),
body_condition: str | None = Query(None),
housing_system: str | None = Query(None),
konc_oe_sv: str | None = Query(None),
):
_require_ent(enterprise_id, tenant)
norms_method = normalize_norms_method(method)
mass = parse_num(mass_kg)
milk = parse_num(milk_yield_kg)
stored: dict = {}
params = NormsParams()
pid = (profile_id or "").strip()
with _lab_ctx(enterprise_id):
if pid:
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
if profile is not None:
stored = load_norms_dict(profile.id)
params = load_norms_params(profile)
if mass is None:
mass = profile.mass_kg
if milk is None:
milk = profile.milk_yield_kg
if not method:
norms_method = normalize_norms_method(profile.norms_method)
override = NormsParams.from_dict(
{
"milkFatPct": parse_num(milk_fat_pct),
"lactationNo": lactation_no,
"bodyCondition": body_condition,
"housingSystem": housing_system,
"koncOeSv": parse_num(konc_oe_sv),
}
)
params = NormsParams(
milk_fat_pct=override.milk_fat_pct if override.milk_fat_pct is not None else params.milk_fat_pct,
lactation_no=override.lactation_no if override.lactation_no is not None else params.lactation_no,
lactation_stage=override.lactation_stage if override.lactation_stage is not None else params.lactation_stage,
body_condition=override.body_condition if override.body_condition is not None else params.body_condition,
housing_system=override.housing_system if override.housing_system is not None else params.housing_system,
konc_oe_sv=override.konc_oe_sv if override.konc_oe_sv is not None else params.konc_oe_sv,
)
try:
resolved, meta = resolve_norms(
NormsResolveRequest(
method=norms_method,
stored=stored,
mass_kg=mass,
milk_yield_kg=milk,
force_dynamic=norms_method == "wesp",
params=params,
)
)
except ValueError as exc:
raise _error(str(exc)) from exc
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
if norms_method == "wesp" and not dynamic:
dynamic = preview_dynamic_norms(mass, milk)
body: dict[str, Any] = {
"normsMethod": norms_method,
"resolvedIndicators": resolved,
"dynamicNorms": dynamic,
"normsMeta": meta.get("meta"),
}
if meta.get("coverage"):
body["coverage"] = meta["coverage"]
return body
@router.get("/lab/seed-norms-catalog")
def get_seed_norms_catalog(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
ration_type: str = Query("DAIRY"),
):
_require_ent(enterprise_id, tenant)
rt = ration_type.strip().upper()
with _lab_ctx(enterprise_id):
try:
entries = list_seed_norm_catalog(rt)
except ValueError as exc:
raise _error(str(exc)) from exc
except ReferenceNormsCatalogEmptyError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return {"rationType": rt, "entries": entries}
@router.get("/lab/formulate/components")
def get_formulate_components(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
return {"components": list_formulate_components(), "groups": list_feed_groups_api()}
@router.post("/lab/formulate")
def post_formulate(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
group_selections = parse_group_selections(body.get("groupSelections"))
main_feed_ids = body.get("mainFeedIds") or []
additional_ids = body.get("additionalIds") or []
candidate_ids = body.get("candidateIds") or body.get("componentIds") or []
if group_selections:
candidate_ids = []
elif main_feed_ids or additional_ids:
if not isinstance(main_feed_ids, list) or not isinstance(additional_ids, list):
raise _error("mainFeedIds/additionalIds must be lists")
group_selections = {
"rough": [str(x) for x in main_feed_ids],
"succulent": [],
"concentrate": [str(x) for x in additional_ids if str(x) not in main_feed_ids],
"other": [],
}
for x in additional_ids:
sx = str(x)
if sx not in group_selections["rough"] and sx not in group_selections["concentrate"]:
group_selections["concentrate"].append(sx)
candidate_ids = []
elif not isinstance(candidate_ids, list):
raise _error("candidateIds must be a list")
profile_id = (body.get("profileId") or "").strip()
if not profile_id:
raise _error("profileId required")
with _lab_ctx(enterprise_id):
try:
result = formulate(
FormulateRequest(
profile_id=profile_id,
candidate_ids=[str(x) for x in candidate_ids],
group_selections=group_selections,
main_feed_ids=[str(x) for x in main_feed_ids],
mass_kg=_parse_formulate_num(body.get("massKg")),
milk_yield_kg=_parse_formulate_num(body.get("milkYieldKg")),
heads_per_trip=_parse_int(body.get("headsPerTrip")),
total_kg_per_head=float(body.get("totalKgPerHead") or 7.3),
optimize_keys=list(body.get("optimizeKeys") or []),
objective=str(body.get("objective") or "min_cost"),
cost_weight=float(body.get("costWeight") or 100),
grid_step=float(body.get("gridStep") or 0.1),
prefilter_k=int(body.get("prefilterK") or 18),
min_share=float(body.get("minShare") or 0.05),
norms_method=str(body.get("normsMethod") or "wesp"),
norms_params=dict(body.get("normsParams") or {}),
)
)
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
except ValueError as exc:
args = exc.args
if args and args[0] == "ineligible_components":
raise _error(
"Некоторые компоненты не подходят для авторациона",
components=args[1],
) from exc
if args and args[0] == "candidate_ids_min_3":
raise _error("Нужно минимум 3 кандидата") from exc
if args and args[0] == "candidate_ids_duplicate":
raise _error("Дубликаты в candidateIds") from exc
if args and args[0] == "no_feasible_solution":
raise _error("Не удалось подобрать рацион") from exc
if args and args[0] == "group_selection_invalid":
raise _error("Некорректный выбор по группам кормов", errors=args[1]) from exc
if args and args[0] == "no_feasible_solution_groups":
raise _error("Не удалось подобрать рацион с учётом обязательных групп кормов") from exc
raise _error(str(exc)) from exc
return result
@router.get("/lab/animal-profiles")
def get_profiles(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
ration_type: str | None = Query(None),
):
_require_ent(enterprise_id, tenant)
rt = ration_type.strip() if ration_type else None
with _lab_ctx(enterprise_id):
profiles = list_animal_profiles(rt)
return {"profiles": profiles}
@router.post("/lab/animal-profiles", status_code=status.HTTP_201_CREATED)
def post_profile(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = upsert_animal_profile(None, body, _user(tenant))
except ValueError as exc:
raise _error(str(exc)) from exc
return result
@router.get("/lab/animal-profiles/{profile_id}")
def get_profile(
profile_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
payload = load_animal_profile(profile_id)
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return payload
@router.put("/lab/animal-profiles/{profile_id}")
def put_profile(
profile_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = upsert_animal_profile(profile_id, body, _user(tenant))
except ValueError as exc:
raise _error(str(exc)) from exc
return result
@router.delete("/lab/animal-profiles/{profile_id}")
def delete_profile_route(
profile_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with _lab_ctx(enterprise_id):
try:
result = delete_animal_profile(profile_id, _user(tenant))
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
return result
@router.get("/lab/rations/{recipe_id}/print", response_class=HTMLResponse)
def get_print(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
mode: str = Query("ration"),
):
_require_ent(enterprise_id, tenant)
mode_norm = mode.strip().lower()
if mode_norm not in ("ration", "compound"):
raise _error("mode must be ration or compound")
with _lab_ctx(enterprise_id):
try:
snapshot = load_ration(recipe_id)
except LookupError as exc:
raise _error(str(exc), status.HTTP_404_NOT_FOUND) from exc
if not snapshot.exists:
raise _error("Мастер рациона не найден", status.HTTP_404_NOT_FOUND)
lines = []
for line in snapshot.lines:
included = line.in_ration if mode_norm == "ration" else line.in_compound
if not included:
continue
lines.append(
{
"name": line.ingredient_name or line.component_id or "—",
"daily_kg": line.daily_kg,
"included": True,
}
)
results = snapshot.ration_results if mode_norm == "ration" else snapshot.compound_results
indicators = (results or {}).get("indicators") or []
title = "Рацион" if mode_norm == "ration" else "Комбикорм"
html = _render_print_html(
title=title,
recipe_name=snapshot.recipe_name,
ration_type=snapshot.ration_type,
heads=snapshot.heads_per_trip,
mode=mode_norm,
lines=lines,
indicators=indicators,
)
return HTMLResponse(content=html)