@@ -0,0 +1,202 @@
|
||||
"""Аудит данных 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)
|
||||
Reference in New Issue
Block a user