"""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( "" f"{escape(str(line.get('name') or '—'))}" f"{escape(str(line.get('daily_kg') or ''))}" f"{'✓' if line.get('included') else ''}" "" ) 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"{label}: {content} {unit}") col_title = "В комб." if mode == "compound" else "В рац." return f""" {escape(title)}

{escape(title)}

Рецепт: {escape(recipe_name)} · Тип: {escape(ration_type)} · Голов/рейс: {heads}
{''.join(rows)}
Ингредиенткг/день{col_title}
{''.join(badges)}
""" @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)