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