"""Аудит данных lab-модуля (профили, нормы, рационы) — без компонентов.""" from __future__ import annotations import logging from dataclasses import dataclass, field from app import db from app.lab.constants import RATION_QUALITY_INDICATORS from app.lab.models import ( LabAnimalProfile, LabProfileNorm, LabRationCalcIndicator, LabRationLine, LabRecipeRation, ) from app.lab.calc.feed_groups import classify_feed_group from app.lab.nutrient_schema import read_from_mapping from app.lab.services.component_nutrients import nutrients_full_dict from app.models import Component, Recipe _OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ") _log = logging.getLogger(__name__) CALC_NORM_KEYS = tuple(d["key"] for d in RATION_QUALITY_INDICATORS) @dataclass class AuditIssue: level: str # error | warn | info area: str message: str @dataclass class LabDataAuditReport: issues: list[AuditIssue] = field(default_factory=list) profiles: int = 0 profiles_with_norms: int = 0 profile_norms_beef: int = 0 profile_norms_dairy: int = 0 recipe_rations: int = 0 ration_lines: int = 0 calc_indicators: int = 0 components: int = 0 components_omd_missing: int = 0 def add(self, level: str, area: str, message: str) -> None: self.issues.append(AuditIssue(level, area, message)) def ok(self) -> bool: return not any(i.level == "error" for i in self.issues) def _audit_components(report: LabDataAuditReport) -> None: components = Component.query.filter_by(is_deleted=False).all() report.components = len(components) for comp in components: full = nutrients_full_dict(comp.id) if not full: continue feed_group = classify_feed_group(comp) omd = read_from_mapping(full, _OMD_KEYS) if feed_group in ("rough", "succulent") and omd is None: report.components_omd_missing += 1 report.add( "warn", "component", f"Компонент {comp.name!r} ({feed_group}): нет ВРХ Орг Вещ", ) oe = read_from_mapping(full, ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм")) nel = read_from_mapping(full, ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм")) if (oe is not None and oe < 0) or (nel is not None and nel < 0): report.add( "warn", "component", f"Компонент {comp.name!r}: отрицательная энергия (ОЭ={oe}, ЧЭЛ={nel})", ) def audit_lab_data(*, include_components: bool = False) -> LabDataAuditReport: report = LabDataAuditReport() profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all() report.profiles = len(profiles) for profile in profiles: norms = LabProfileNorm.query.filter_by(profile_id=profile.id).all() norm_keys = {n.indicator_key for n in norms} if norms: report.profiles_with_norms += 1 else: report.add("warn", "profile", f"Профиль {profile.profile_key!r} без норм (lab_profile_norm пуст)") if profile.mass_kg is None: report.add("warn", "profile", f"Профиль {profile.profile_key!r}: mass_kg не задан") missing = [k for k in CALC_NORM_KEYS if k not in norm_keys] if norms and missing: report.add( "info", "profile", f"Профиль {profile.profile_key!r}: нет ключей {', '.join(missing[:5])}" + ("…" if len(missing) > 5 else ""), ) for norm in norms: if norm.min_value is None and norm.max_value is None: report.add( "warn", "profile", f"Профиль {profile.profile_key!r}: {norm.indicator_key} без min и max", ) for ration_type in ("BEEF", "DAIRY"): count = ( db.session.query(LabProfileNorm) .join(LabAnimalProfile, LabAnimalProfile.id == LabProfileNorm.profile_id) .filter( LabAnimalProfile.ration_type == ration_type, LabAnimalProfile.is_deleted.is_(False), ) .count() ) if ration_type == "BEEF": report.profile_norms_beef = count else: report.profile_norms_dairy = count if report.profile_norms_beef == 0 and report.profile_norms_dairy == 0: report.add( "warn", "profile_norm", "lab_profile_norm пуст — нормы не импортированы (scripts/import_seed.py --norms)", ) headers = ( LabRecipeRation.query.filter_by(is_deleted=False) .join(Recipe, Recipe.id == LabRecipeRation.recipe_id) .filter(Recipe.is_deleted.is_(False)) .all() ) report.recipe_rations = len(headers) for header in headers: recipe = Recipe.query.get(header.recipe_id) name = recipe.name if recipe else header.recipe_id if not header.animal_profile_id: report.add("warn", "ration", f"Рецепт {name!r}: нет animal_profile_id") elif LabAnimalProfile.query.get(header.animal_profile_id) is None: report.add("error", "ration", f"Рецепт {name!r}: профиль {header.animal_profile_id} не найден") lines = LabRationLine.query.filter_by( recipe_id=header.recipe_id, is_deleted=False ).all() active = [ln for ln in lines if ln.in_ration and (ln.daily_kg or 0) > 0] if not active: report.add("info", "ration", f"Рецепт {name!r}: нет активных строк рациона") for line in active: if not line.component_id: report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: нет component_id") if line.daily_kg is None: report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: daily_kg пуст") if header.calculated_at and not LabRationCalcIndicator.query.filter_by( recipe_id=header.recipe_id ).first(): report.add("warn", "calc", f"Рецепт {name!r}: calculated_at есть, lab_ration_calc_indicator пуст") report.ration_lines = LabRationLine.query.filter_by(is_deleted=False).count() report.calc_indicators = LabRationCalcIndicator.query.count() if report.profiles == 0: report.add("warn", "profile", "Нет профилей стада (lab_animal_profile)") if include_components: _audit_components(report) _log.info( "lab audit: profiles=%s with_norms=%s rations=%s issues=%s", report.profiles, report.profiles_with_norms, report.recipe_rations, len(report.issues), ) return report def format_audit_report(report: LabDataAuditReport) -> str: lines = [ "=== Lab data audit ===", f"Профили: {report.profiles} (с нормами: {report.profiles_with_norms})", f"lab_profile_norm: BEEF={report.profile_norms_beef}, DAIRY={report.profile_norms_dairy}", f"lab_recipe_ration: {report.recipe_rations}, строк: {report.ration_lines}", f"lab_ration_calc_indicator: {report.calc_indicators}", f"components: {report.components} (без ВРХ rough/succulent: {report.components_omd_missing})", f"Замечаний: {len(report.issues)}", ] for issue in report.issues: lines.append(f" [{issue.level}] {issue.area}: {issue.message}") return "\n".join(lines)