Files
2026-07-17 12:57:18 +03:00

203 lines
7.7 KiB
Python
Raw Permalink 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.
"""Аудит данных 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)