695 lines
25 KiB
Python
695 lines
25 KiB
Python
"""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)
|