@@ -0,0 +1,21 @@
|
||||
from .apply_from_master import apply_from_master
|
||||
from .ensure_empty_master import ensure_empty_master
|
||||
from .recalculate import recalculate_ration
|
||||
from .seed_demo_component_nutrients import seed_demo_component_nutrients_once
|
||||
from .seed_from_execution import seed_from_execution
|
||||
from .sync_from_execution import sync_from_execution
|
||||
from .delete_profile import delete_animal_profile
|
||||
from .upsert_profile import upsert_animal_profile
|
||||
from .upsert_ration import upsert_ration
|
||||
|
||||
__all__ = [
|
||||
"upsert_ration",
|
||||
"recalculate_ration",
|
||||
"apply_from_master",
|
||||
"seed_demo_component_nutrients_once",
|
||||
"seed_from_execution",
|
||||
"ensure_empty_master",
|
||||
"sync_from_execution",
|
||||
"delete_animal_profile",
|
||||
"upsert_animal_profile",
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app import db
|
||||
from app.lab.commands.write_audit import write_audit
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.loaders.execution_loader import load_execution
|
||||
from app.lab.loaders.ration_loader import load_ration
|
||||
from app.models import Component, Ingredient, Recipe
|
||||
from app.models.base import default_uuid
|
||||
from app.services.recipe_update_service import _enqueue_recipe_children_sync
|
||||
|
||||
|
||||
@retry_locked
|
||||
def apply_from_master(recipe_id: str, user_id: str = "system") -> dict:
|
||||
snapshot = load_ration(recipe_id)
|
||||
if not snapshot.exists:
|
||||
raise LookupError("Мастер рациона не найден")
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
heads = int(recipe.heads_per_trip or 0)
|
||||
if heads <= 0:
|
||||
raise ValueError("Количество голов должно быть > 0")
|
||||
|
||||
execution = load_execution(recipe_id)
|
||||
by_component = {str(l.component_id): l for l in execution.lines if l.component_id}
|
||||
master_by_component = {
|
||||
str(l.component_id): l
|
||||
for l in snapshot.lines
|
||||
if l.component_id and l.in_ration
|
||||
}
|
||||
|
||||
touched_ingredient_ids: list[str] = []
|
||||
for comp_id, master_line in master_by_component.items():
|
||||
daily_kg = float(master_line.daily_kg or 0)
|
||||
weight_per_head = daily_kg / heads
|
||||
ing = by_component.get(comp_id)
|
||||
comp = Component.query.get(comp_id)
|
||||
dm_pct = float(comp.dry_matter or 0) if comp else float(master_line.dry_matter or 0)
|
||||
if ing:
|
||||
row = Ingredient.query.get(ing.ingredient_id)
|
||||
if row is None:
|
||||
continue
|
||||
row.weight_per_head = weight_per_head
|
||||
row.amount = weight_per_head
|
||||
row.dry_matter = dm_pct
|
||||
row.updated_by = user_id
|
||||
touched_ingredient_ids.append(row.id)
|
||||
else:
|
||||
comp_name = master_line.ingredient_name or comp_id
|
||||
new_ing = Ingredient(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
component_id=comp_id,
|
||||
name=comp_name[:100],
|
||||
amount=weight_per_head,
|
||||
weight_per_head=weight_per_head,
|
||||
dry_matter=dm_pct,
|
||||
order=len(touched_ingredient_ids),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.session.add(new_ing)
|
||||
touched_ingredient_ids.append(new_ing.id)
|
||||
|
||||
master_ids = set(master_by_component)
|
||||
for line in execution.lines:
|
||||
if line.component_id and str(line.component_id) not in master_ids:
|
||||
row = Ingredient.query.get(line.ingredient_id)
|
||||
if row and not row.is_deleted:
|
||||
row.soft_delete(user_id)
|
||||
|
||||
_enqueue_recipe_children_sync(recipe_id)
|
||||
write_audit("APPLY_FROM_MASTER", "lab_recipe_ration", recipe_id, user_id)
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "ingredientsUpdated": len(touched_ingredient_ids)}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Удаление legacy/fp_* профилей стада (одноразовая ops-команда)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app import db
|
||||
from app.lab.models import LabAnimalProfile, LabProfileNorm, LabRecipeRation
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_LEGACY_KEY = re.compile(r"^(dairy|beef)-\d+$", re.IGNORECASE)
|
||||
_FP_KEY = re.compile(r"^fp_", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CleanupReport:
|
||||
dry_run: bool = True
|
||||
profiles_to_delete: list[str] = field(default_factory=list)
|
||||
recipe_ids_cleared: list[str] = field(default_factory=list)
|
||||
norms_deleted: int = 0
|
||||
profiles_deleted: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _should_delete(profile_key: str | None) -> bool:
|
||||
key = (profile_key or "").strip()
|
||||
if not key:
|
||||
return False
|
||||
if key.startswith("lab_math_"):
|
||||
return False
|
||||
if _LEGACY_KEY.match(key):
|
||||
return True
|
||||
if _FP_KEY.match(key):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_legacy_profiles(*, dry_run: bool = True, user_id: str = "system") -> CleanupReport:
|
||||
report = CleanupReport(dry_run=dry_run)
|
||||
profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all()
|
||||
to_delete = [p for p in profiles if _should_delete(p.profile_key)]
|
||||
|
||||
for profile in to_delete:
|
||||
report.profiles_to_delete.append(profile.profile_key or profile.id)
|
||||
|
||||
norms_count = LabProfileNorm.query.filter_by(profile_id=profile.id).count()
|
||||
report.norms_deleted += norms_count
|
||||
|
||||
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
|
||||
for header in rations:
|
||||
if header.recipe_id not in report.recipe_ids_cleared:
|
||||
report.recipe_ids_cleared.append(header.recipe_id)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
|
||||
for header in rations:
|
||||
header.animal_profile_id = None
|
||||
header.updated_by = user_id
|
||||
if hasattr(profile, "soft_delete"):
|
||||
profile.soft_delete(user_id)
|
||||
else:
|
||||
db.session.delete(profile)
|
||||
report.profiles_deleted += 1
|
||||
|
||||
if not dry_run:
|
||||
db.session.commit()
|
||||
_log.info(
|
||||
"cleanup_legacy_profiles user=%s deleted=%s rations_cleared=%s",
|
||||
user_id,
|
||||
report.profiles_deleted,
|
||||
len(report.recipe_ids_cleared),
|
||||
)
|
||||
else:
|
||||
_log.info(
|
||||
"cleanup_legacy_profiles dry_run profiles=%s rations=%s",
|
||||
len(report.profiles_to_delete),
|
||||
len(report.recipe_ids_cleared),
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app import db
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.models import (
|
||||
LabAnimalProfile,
|
||||
LabProfileNorm,
|
||||
LabRecipeRation,
|
||||
)
|
||||
@retry_locked
|
||||
def delete_animal_profile(profile_id: str, user_id: str = "system") -> dict:
|
||||
pid = (profile_id or "").strip()
|
||||
if not pid:
|
||||
raise LookupError("Профиль не найден")
|
||||
|
||||
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
|
||||
if profile is None:
|
||||
raise LookupError("Профиль не найден")
|
||||
|
||||
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
|
||||
|
||||
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
|
||||
for header in rations:
|
||||
header.animal_profile_id = None
|
||||
header.updated_by = user_id
|
||||
|
||||
profile.updated_by = user_id
|
||||
profile.soft_delete(user_id)
|
||||
db.session.commit()
|
||||
return {"id": profile.id, "deleted": True}
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app import db
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.models import LabRecipeRation
|
||||
from app.models import Recipe
|
||||
|
||||
|
||||
@retry_locked
|
||||
def ensure_empty_master(recipe_id: str, user_id: str = "system") -> dict:
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
|
||||
if existing is not None:
|
||||
return {"recipeId": recipe_id, "created": False}
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "created": True}
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Generic ETL: CSV из data/seed/ → lab profiles и component nutrients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from app import db
|
||||
from app.lab.calc.ingredient_catalog import INGREDIENT_HEADERS
|
||||
from app.lab.calc.ingredient_derive import derive_ingredient_nutrients
|
||||
from app.lab.models import LabAnimalProfile
|
||||
from app.lab.norm_catalog import norm_header, norm_title_to_key
|
||||
from app.lab.seed_paths import norms_dir, nutrients_dir
|
||||
from app.lab.services.component_nutrients import save_component_nutrients
|
||||
from app.lab.services.profile_norms import save_norms_from_payload
|
||||
from app.models.base import default_uuid
|
||||
from app.models.component import Component
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_SKIP_NUTRIENT_HEADERS = frozenset({"№", "Наименование", "Цена 1 кг"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormsImportStats:
|
||||
profiles_created: int = 0
|
||||
profiles_updated: int = 0
|
||||
norm_rows: int = 0
|
||||
skipped: int = 0
|
||||
unmapped_headers: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutrientsImportStats:
|
||||
imported: int = 0
|
||||
skipped: int = 0
|
||||
unmatched: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _parse_min_max_groups(h1: list[str], h2: list[str]) -> list[tuple[str, int, int | None]]:
|
||||
groups: list[tuple[str, int, int | None]] = []
|
||||
current_name: str | None = None
|
||||
limit = min(len(h1), len(h2))
|
||||
for i in range(limit):
|
||||
b = (h2[i] or "").strip().lower().replace("і", "и")
|
||||
n = (h1[i] or "").strip()
|
||||
if n and n not in ("№", "Наименование", "Масса", "КРС"):
|
||||
current_name = norm_header(n)
|
||||
if b.startswith("мин") and current_name:
|
||||
max_i = i + 1 if i + 1 < limit and "мах" in (h2[i + 1] or "").lower() else None
|
||||
groups.append((current_name, i, max_i))
|
||||
return groups
|
||||
|
||||
|
||||
def norm_profile_key(ration_type: str, external_no: int) -> str:
|
||||
return f"norm_{ration_type.lower()}_{external_no:04d}"
|
||||
|
||||
|
||||
def parse_norm_profile_row(
|
||||
row: list[str],
|
||||
groups: list[tuple[str, int, int | None]],
|
||||
ration_type: str,
|
||||
*,
|
||||
unmapped: set[str],
|
||||
profile_key_fn=norm_profile_key,
|
||||
) -> dict | None:
|
||||
if len(row) < 2 or not (row[1] or "").strip():
|
||||
return None
|
||||
try:
|
||||
external_no = int(float((row[0] or "").strip()))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if external_no <= 0:
|
||||
return None
|
||||
|
||||
indicators: dict[str, dict[str, float | None]] = {}
|
||||
for header, min_i, max_i in groups:
|
||||
key = norm_title_to_key(header)
|
||||
if not key:
|
||||
unmapped.add(header)
|
||||
continue
|
||||
|
||||
def _num(idx: int | None) -> float | None:
|
||||
if idx is None or idx >= len(row):
|
||||
return None
|
||||
raw = (row[idx] or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
min_v, max_v = _num(min_i), _num(max_i)
|
||||
if min_v is None and max_v is None:
|
||||
continue
|
||||
indicators[key] = {"min": min_v, "max": max_v}
|
||||
|
||||
mass_kg = None
|
||||
if len(row) > 2 and (row[2] or "").strip():
|
||||
try:
|
||||
mass_kg = float(row[2])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"external_no": external_no,
|
||||
"profile_key": profile_key_fn(ration_type, external_no),
|
||||
"label": row[1].strip(),
|
||||
"ration_type": ration_type,
|
||||
"mass_kg": mass_kg,
|
||||
"indicators": indicators,
|
||||
}
|
||||
|
||||
|
||||
_IMPORT_BATCH_SIZE = 50
|
||||
|
||||
|
||||
def _lookup_reference_profile(profile_key: str) -> LabAnimalProfile | None:
|
||||
"""Поиск по profile_key включая soft-deleted (UNIQUE на всю таблицу)."""
|
||||
return LabAnimalProfile.query.filter_by(profile_key=profile_key).first()
|
||||
|
||||
|
||||
def _restore_reference_profile(profile: LabAnimalProfile) -> None:
|
||||
if not profile.is_deleted:
|
||||
return
|
||||
profile.is_deleted = False
|
||||
profile.deleted_at = None
|
||||
profile.deleted_by = None
|
||||
|
||||
|
||||
def _import_norm_sheet(
|
||||
csv_path: Path,
|
||||
ration_type: str,
|
||||
stats: NormsImportStats,
|
||||
unmapped: set[str],
|
||||
*,
|
||||
replace_reference: bool = True,
|
||||
) -> None:
|
||||
if not csv_path.is_file():
|
||||
stats.errors.append(f"Файл не найден: {csv_path}")
|
||||
return
|
||||
|
||||
with csv_path.open(encoding="utf-8") as f:
|
||||
rows = list(csv.reader(f))
|
||||
if len(rows) < 8:
|
||||
stats.errors.append(f"Слишком мало строк: {csv_path}")
|
||||
return
|
||||
|
||||
groups = _parse_min_max_groups(rows[2], rows[3])
|
||||
batch_count = 0
|
||||
for row in rows[6:]:
|
||||
parsed = parse_norm_profile_row(row, groups, ration_type, unmapped=unmapped)
|
||||
if parsed is None:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
|
||||
profile = _lookup_reference_profile(parsed["profile_key"])
|
||||
if profile is not None:
|
||||
if not replace_reference and not profile.is_deleted:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
if not replace_reference and profile.is_deleted:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
_restore_reference_profile(profile)
|
||||
stats.profiles_updated += 1
|
||||
else:
|
||||
profile = LabAnimalProfile(
|
||||
id=default_uuid(),
|
||||
profile_key=parsed["profile_key"],
|
||||
created_by="seed-import",
|
||||
)
|
||||
db.session.add(profile)
|
||||
stats.profiles_created += 1
|
||||
|
||||
profile.label = parsed["label"]
|
||||
profile.ration_type = ration_type
|
||||
profile.external_no = parsed["external_no"]
|
||||
save_norms_from_payload(
|
||||
profile,
|
||||
{
|
||||
"massKg": parsed["mass_kg"],
|
||||
"externalNo": parsed["external_no"],
|
||||
"indicators": parsed["indicators"],
|
||||
},
|
||||
)
|
||||
stats.norm_rows += len(parsed["indicators"])
|
||||
batch_count += 1
|
||||
if batch_count >= _IMPORT_BATCH_SIZE:
|
||||
db.session.commit()
|
||||
batch_count = 0
|
||||
|
||||
|
||||
def import_norm_profiles(
|
||||
csv_dir: Path | None = None,
|
||||
*,
|
||||
replace_reference: bool = True,
|
||||
) -> NormsImportStats:
|
||||
stats = NormsImportStats()
|
||||
base = csv_dir or norms_dir()
|
||||
unmapped: set[str] = set()
|
||||
|
||||
_import_norm_sheet(
|
||||
base / "Нормы КРС.csv", "BEEF", stats, unmapped, replace_reference=replace_reference
|
||||
)
|
||||
_import_norm_sheet(
|
||||
base / "Нормы Дойн.csv", "DAIRY", stats, unmapped, replace_reference=replace_reference
|
||||
)
|
||||
stats.unmapped_headers = sorted(unmapped)
|
||||
db.session.commit()
|
||||
|
||||
_log.info(
|
||||
"seed norms import: created=%s updated=%s norm_rows=%s",
|
||||
stats.profiles_created,
|
||||
stats.profiles_updated,
|
||||
stats.norm_rows,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def _parse_nutrient_row(row: list[str]) -> dict[str, float]:
|
||||
nutrients: dict[str, float] = {}
|
||||
for i, header in enumerate(INGREDIENT_HEADERS):
|
||||
if header in _SKIP_NUTRIENT_HEADERS or i >= len(row):
|
||||
continue
|
||||
raw = (row[i] or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
nutrients[header] = float(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
return nutrients
|
||||
|
||||
|
||||
def _find_component(name: str, external_no: int | None) -> Component | None:
|
||||
if external_no is not None:
|
||||
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
|
||||
if hit:
|
||||
return hit
|
||||
if name:
|
||||
hit = Component.query.filter(Component.name == name, Component.is_deleted.is_(False)).first()
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def import_component_nutrients(
|
||||
csv_path: Path | None = None,
|
||||
*,
|
||||
derive: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> NutrientsImportStats:
|
||||
path = csv_path or (nutrients_dir() / "База сырья.csv")
|
||||
stats = NutrientsImportStats()
|
||||
if not path.is_file():
|
||||
stats.errors.append(f"CSV not found: {path}")
|
||||
return stats
|
||||
|
||||
with path.open(encoding="utf-8") as f:
|
||||
rows = list(csv.reader(f))
|
||||
|
||||
for row in rows[5:]:
|
||||
name = row[1].strip() if len(row) > 1 else ""
|
||||
if not name:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
try:
|
||||
external_no = int(float(row[0])) if row[0].strip() else None
|
||||
except (ValueError, IndexError):
|
||||
external_no = None
|
||||
|
||||
comp = _find_component(name, external_no)
|
||||
if comp is None:
|
||||
stats.unmatched += 1
|
||||
continue
|
||||
|
||||
nutrients = _parse_nutrient_row(row)
|
||||
if derive:
|
||||
nutrients = derive_ingredient_nutrients(nutrients)
|
||||
|
||||
if not dry_run:
|
||||
save_component_nutrients(comp.id, nutrients, user_id="seed-import")
|
||||
stats.imported += 1
|
||||
|
||||
if not dry_run:
|
||||
db.session.commit()
|
||||
_log.info(
|
||||
"seed nutrients import: imported=%s skipped=%s unmatched=%s",
|
||||
stats.imported,
|
||||
stats.skipped,
|
||||
stats.unmatched,
|
||||
)
|
||||
return stats
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app import db
|
||||
from app.lab.calc.engine import calculate_ration
|
||||
from app.lab.commands.write_audit import write_audit, write_calculation_run
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.commands.seed_demo_component_nutrients import (
|
||||
ensure_component_nutrients_if_empty,
|
||||
supplement_component_nutrients_if_sparse,
|
||||
)
|
||||
from app.lab.loaders.ration_loader import load_ration, ration_to_calc_lines
|
||||
from app.lab.models import LabAnimalProfile, LabRecipeRation
|
||||
from app.lab.services.ration_calc_store import save_calc_result
|
||||
|
||||
|
||||
@retry_locked
|
||||
def recalculate_ration(recipe_id: str, user_id: str = "system") -> dict:
|
||||
snapshot = load_ration(recipe_id)
|
||||
if not snapshot.lines:
|
||||
raise ValueError("Пустой мастер — добавьте строки рациона")
|
||||
|
||||
seeded_any = False
|
||||
for line in snapshot.lines:
|
||||
if not line.component_id:
|
||||
continue
|
||||
if ensure_component_nutrients_if_empty(line.component_id, user_id=user_id):
|
||||
seeded_any = True
|
||||
elif supplement_component_nutrients_if_sparse(line.component_id, user_id=user_id):
|
||||
seeded_any = True
|
||||
if seeded_any:
|
||||
db.session.commit()
|
||||
snapshot = load_ration(recipe_id)
|
||||
|
||||
profile_mass_kg = None
|
||||
if snapshot.animal_profile_id:
|
||||
prof = LabAnimalProfile.query.get(snapshot.animal_profile_id)
|
||||
if prof is not None:
|
||||
profile_mass_kg = prof.mass_kg
|
||||
|
||||
started = time.perf_counter()
|
||||
result = calculate_ration(
|
||||
snapshot.ration_type,
|
||||
ration_to_calc_lines(snapshot),
|
||||
snapshot.norms,
|
||||
heads_per_trip=snapshot.heads_per_trip,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
save_calc_result(recipe_id, result, header)
|
||||
header.updated_by = user_id
|
||||
|
||||
status = "FAILED" if result.get("errors") else "COMPLETED"
|
||||
write_calculation_run(
|
||||
recipe_id,
|
||||
status,
|
||||
{"indicators": result.get("indicators", [])},
|
||||
duration_ms=duration_ms,
|
||||
error_message="; ".join(result.get("errors") or []) or None,
|
||||
)
|
||||
write_audit("RECALCULATE", "lab_recipe_ration", recipe_id, user_id)
|
||||
db.session.commit()
|
||||
return result
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Стартовые nutrients для компонентов без EAV (демо-шаблоны, не лабораторный анализ).
|
||||
|
||||
При старте и пересчёте рациона заполняет только пустые `lab_component_nutrient_value`.
|
||||
Маркер в data/ — журнал последнего прогона, не блокирует новые компоненты.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app import db
|
||||
from config import Config
|
||||
from app.lab.nutrient_schema import read_from_mapping
|
||||
from app.lab.services.component_nutrients import nutrients_full_dict, nutrients_is_empty, save_component_nutrients
|
||||
from app.models.component import Component
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_MARKER_NAME = ".lab_demo_nutrients_seeded"
|
||||
|
||||
# Доп. поля для полной матрицы рациона (демо; заменить лабораторными при наличии).
|
||||
_MINERAL_SUPPLEMENT: dict[str, float] = {
|
||||
"Ca": 8.0,
|
||||
"P": 5.5,
|
||||
"Mg": 3.2,
|
||||
"Na": 1.2,
|
||||
"K": 6.5,
|
||||
"DCAB Форм": 45.0,
|
||||
"Сырая зола": 55.0,
|
||||
"Каротин": 5.0,
|
||||
}
|
||||
|
||||
_CARB_SUPPLEMENT: dict[str, float] = {
|
||||
"Сахар": 80.0,
|
||||
"Крахмал": 280.0,
|
||||
"Сахар и Крохм": 420.0,
|
||||
"Нераств. Крохмал": 35.0,
|
||||
}
|
||||
|
||||
_ROUGHAGE_TYPES = frozenset({"объемные корма", "грубые корма", "сочные корма"})
|
||||
|
||||
_SUPPLEMENT_KEYS: tuple[str, ...] = tuple({**_MINERAL_SUPPLEMENT, **_CARB_SUPPLEMENT}.keys())
|
||||
|
||||
_NAMED_SEEDS: dict[str, dict[str, Any]] = {
|
||||
"комбикорм": {
|
||||
"dry_matter": 88.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 170,
|
||||
"уСП": 142,
|
||||
"БРА": 36,
|
||||
"ОЭ-КРС": 12.5,
|
||||
"ЧЭЛ- КРС": 7.8,
|
||||
"Сырая клетчатка": 95,
|
||||
"Структур. клетчатка": 28,
|
||||
"Сырой жир": 38,
|
||||
},
|
||||
},
|
||||
"жом свекловичный гранулы": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 105,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.8,
|
||||
"ЧЭЛ- КРС": 7.2,
|
||||
"Сырая клетчатка": 220,
|
||||
"Структур. клетчатка": 48,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"жом": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 105,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.8,
|
||||
"ЧЭЛ- КРС": 7.2,
|
||||
"Сырая клетчатка": 220,
|
||||
"Структур. клетчатка": 48,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"дробина": {
|
||||
"dry_matter": 91.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 100,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.5,
|
||||
"ЧЭЛ- КРС": 7.0,
|
||||
"Сырая клетчатка": 200,
|
||||
"Структур. клетчатка": 40,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"добавки": {
|
||||
"dry_matter": 99.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 8,
|
||||
"ОЭ-КРС": 2.0,
|
||||
"ЧЭЛ- КРС": 1.2,
|
||||
"Сырая клетчатка": 5,
|
||||
"Сырой жир": 3,
|
||||
},
|
||||
},
|
||||
"сено": {
|
||||
"dry_matter": 85.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 78,
|
||||
"уСП": 62,
|
||||
"ОЭ-КРС": 5.9,
|
||||
"ЧЭЛ- КРС": 3.4,
|
||||
"Сырая клетчатка": 328,
|
||||
"Структур. клетчатка": 265,
|
||||
"Сырой жир": 22,
|
||||
},
|
||||
},
|
||||
"анионовые соли": {
|
||||
"dry_matter": 95.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 12,
|
||||
"ОЭ-КРС": 1.2,
|
||||
"ЧЭЛ- КРС": 0.8,
|
||||
"Сырая клетчатка": 8,
|
||||
"Сырой жир": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TYPE_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"зерновые": {
|
||||
"dry_matter": 88.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 120,
|
||||
"уСП": 105,
|
||||
"БРА": 28,
|
||||
"ОЭ-КРС": 11.0,
|
||||
"ЧЭЛ- КРС": 6.8,
|
||||
"Сырая клетчатка": 85,
|
||||
"Структур. клетчатка": 22,
|
||||
"Сырой жир": 35,
|
||||
},
|
||||
},
|
||||
"белковые": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 380,
|
||||
"уСП": 300,
|
||||
"БРА": 80,
|
||||
"ОЭ-КРС": 11.5,
|
||||
"ЧЭЛ- КРС": 7.0,
|
||||
"Сырая клетчатка": 120,
|
||||
"Сырой жир": 45,
|
||||
},
|
||||
},
|
||||
"минеральные": {
|
||||
"dry_matter": 95.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 12,
|
||||
"ОЭ-КРС": 1.2,
|
||||
"ЧЭЛ- КРС": 0.8,
|
||||
"Сырая клетчатка": 8,
|
||||
"Сырой жир": 2,
|
||||
},
|
||||
},
|
||||
"витаминные": {
|
||||
"dry_matter": 99.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 8,
|
||||
"ОЭ-КРС": 2.0,
|
||||
"ЧЭЛ- КРС": 1.2,
|
||||
"Сырая клетчатка": 5,
|
||||
"Сырой жир": 3,
|
||||
},
|
||||
},
|
||||
"энергетические": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 100,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.5,
|
||||
"ЧЭЛ- КРС": 7.0,
|
||||
"Сырая клетчатка": 200,
|
||||
"Структур. клетчатка": 40,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"объемные корма": {
|
||||
"dry_matter": 85.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 78,
|
||||
"уСП": 62,
|
||||
"ОЭ-КРС": 5.9,
|
||||
"ЧЭЛ- КРС": 3.4,
|
||||
"Сырая клетчатка": 328,
|
||||
"Структур. клетчатка": 265,
|
||||
"Сырой жир": 22,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_FALLBACK = {
|
||||
"dry_matter": 88.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 100,
|
||||
"уСП": 85,
|
||||
"ОЭ-КРС": 10.0,
|
||||
"ЧЭЛ- КРС": 6.2,
|
||||
"Сырая клетчатка": 150,
|
||||
"Сырой жир": 25,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_name(name: str | None) -> str:
|
||||
return (name or "").strip().lower()
|
||||
|
||||
|
||||
def _merge_supplement(nutrients: dict[str, Any], component: Component) -> dict[str, float]:
|
||||
merged = dict(_MINERAL_SUPPLEMENT)
|
||||
type_key = _normalize_name(component.type)
|
||||
if type_key not in _ROUGHAGE_TYPES:
|
||||
merged.update(_CARB_SUPPLEMENT)
|
||||
merged.update(nutrients)
|
||||
return {k: float(v) for k, v in merged.items() if v is not None}
|
||||
|
||||
|
||||
def _payload_for_component(component: Component) -> dict[str, Any]:
|
||||
name_key = _normalize_name(component.name)
|
||||
if name_key in _NAMED_SEEDS:
|
||||
payload = _NAMED_SEEDS[name_key]
|
||||
else:
|
||||
payload = None
|
||||
for key in sorted(_NAMED_SEEDS, key=len, reverse=True):
|
||||
if key in name_key:
|
||||
payload = _NAMED_SEEDS[key]
|
||||
break
|
||||
if payload is None:
|
||||
type_key = _normalize_name(component.type)
|
||||
payload = _TYPE_DEFAULTS.get(type_key, _FALLBACK)
|
||||
return {
|
||||
"dry_matter": payload.get("dry_matter"),
|
||||
"nutrients": _merge_supplement(payload.get("nutrients") or {}, component),
|
||||
}
|
||||
|
||||
|
||||
def _apply_demo_payload(component: Component, payload: dict[str, Any], *, user_id: str) -> None:
|
||||
if payload.get("dry_matter") is not None:
|
||||
dm = float(payload["dry_matter"])
|
||||
current_dm = float(component.dry_matter or 0)
|
||||
if dm > 0 and (current_dm <= 0 or current_dm > 100):
|
||||
component.dry_matter = dm
|
||||
save_component_nutrients(
|
||||
component.id,
|
||||
payload["nutrients"],
|
||||
user_id=user_id,
|
||||
)
|
||||
component.protein = 0.0
|
||||
component.energy = 0.0
|
||||
component.updated_by = "demo-nutrients-seed"
|
||||
|
||||
|
||||
def supplement_component_nutrients_if_sparse(
|
||||
component_id: str | None,
|
||||
*,
|
||||
user_id: str = "system",
|
||||
) -> bool:
|
||||
"""Добавляет Ca/P/сахара и т.д., если EAV уже есть, но без минералов."""
|
||||
if not component_id or nutrients_is_empty(component_id):
|
||||
return False
|
||||
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if component is None:
|
||||
return False
|
||||
full = nutrients_full_dict(component_id)
|
||||
template = _payload_for_component(component)["nutrients"]
|
||||
allowed = set(_MINERAL_SUPPLEMENT)
|
||||
if _normalize_name(component.type) not in _ROUGHAGE_TYPES:
|
||||
allowed.update(_CARB_SUPPLEMENT)
|
||||
missing = {
|
||||
key: float(template[key])
|
||||
for key in allowed
|
||||
if key in template and read_from_mapping(full, (key,)) is None
|
||||
}
|
||||
if not missing:
|
||||
return False
|
||||
save_component_nutrients(component_id, missing, user_id=user_id, pin=True)
|
||||
return True
|
||||
|
||||
|
||||
def ensure_component_nutrients_if_empty(
|
||||
component_id: str | None,
|
||||
*,
|
||||
user_id: str = "system",
|
||||
) -> bool:
|
||||
"""Заполняет EAV демо-шаблоном, если у компонента нет nutrients. Возвращает True при записи."""
|
||||
if not component_id or not nutrients_is_empty(component_id):
|
||||
return False
|
||||
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if component is None:
|
||||
return False
|
||||
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
|
||||
return True
|
||||
|
||||
|
||||
def _marker_path() -> Path:
|
||||
return Path(Config.DATA_DIR) / _MARKER_NAME
|
||||
|
||||
|
||||
def _already_seeded() -> bool:
|
||||
return _marker_path().is_file()
|
||||
|
||||
|
||||
def _mark_seeded() -> None:
|
||||
path = _marker_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("1\n", encoding="utf-8")
|
||||
|
||||
|
||||
def seed_demo_component_nutrients_once(*, user_id: str = "system", force: bool = False) -> dict:
|
||||
"""Заполняет lab_component_nutrient_value у всех компонентов с пустой матрицей."""
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for component in Component.query.filter_by(is_deleted=False).all():
|
||||
if not force and not nutrients_is_empty(component.id):
|
||||
skipped += 1
|
||||
continue
|
||||
if force and not nutrients_is_empty(component.id):
|
||||
skipped += 1
|
||||
continue
|
||||
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
db.session.commit()
|
||||
_mark_seeded()
|
||||
_log.info(
|
||||
"demo component nutrients seed user=%s updated=%s skipped=%s",
|
||||
user_id,
|
||||
updated,
|
||||
skipped,
|
||||
)
|
||||
return {"seeded": True, "updated": updated, "skipped": skipped}
|
||||
|
||||
return {"seeded": False, "reason": "nothing_to_update", "updated": 0, "skipped": skipped}
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app import db
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.loaders.execution_loader import load_execution
|
||||
from app.lab.models import LabRationLine, LabRecipeRation
|
||||
from app.models import Component
|
||||
from app.models.base import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def seed_from_execution(recipe_id: str, user_id: str = "system") -> dict:
|
||||
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
|
||||
if existing is not None:
|
||||
return {"recipeId": recipe_id, "seeded": False, "reason": "already_exists"}
|
||||
|
||||
execution = load_execution(recipe_id)
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
|
||||
for idx, line in enumerate(execution.lines):
|
||||
comp = Component.query.get(line.component_id) if line.component_id else None
|
||||
daily_kg = line.daily_kg_total
|
||||
db.session.add(
|
||||
LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
component_id=line.component_id,
|
||||
ingredient_name=line.name,
|
||||
row_index=idx,
|
||||
daily_kg=daily_kg,
|
||||
in_ration=True,
|
||||
in_compound=False,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
)
|
||||
header.seed_source = "execution"
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "seeded": True, "lines": len(execution.lines)}
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Тестовые рецепты LAB: полная матрица показателей + рацион «в нормах»."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app import db
|
||||
from app.lab.commands.recalculate import recalculate_ration
|
||||
from app.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
|
||||
from app.lab.services.component_nutrients import save_component_nutrients
|
||||
from app.models import Component, Ingredient, Recipe
|
||||
from app.models.base import default_uuid
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_KEY = "lab_math_dairy_01"
|
||||
HEADS = 10
|
||||
_MARKER = ".lab_math_ration_seeded"
|
||||
|
||||
RECIPE_MATRIX = "LAB тест — полная матрица"
|
||||
RECIPE_IN_NORMS = "LAB тест — в нормах"
|
||||
|
||||
# Реалистичные г/кг (и МДж для энергии) — покрывают расширенный каталог indicators.py
|
||||
_COMPONENTS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"name": "LAB тест — сено",
|
||||
"type": "Грубые корма",
|
||||
"dry_matter": 85.0,
|
||||
"price": 12.0,
|
||||
"nutrients": {
|
||||
"СВ": 850,
|
||||
"Осн.Корм": 850,
|
||||
"Сыр. Протеин": 78,
|
||||
"уСП": 62,
|
||||
"ОЭ-КРС": 5.9,
|
||||
"ЧЭЛ- КРС": 3.4,
|
||||
"Сырая клетч": 328,
|
||||
"НДК": 328,
|
||||
"КДК": 210,
|
||||
"Структур клетч": 265,
|
||||
"Сырой жир": 22,
|
||||
"Ca": 3.5,
|
||||
"P": 2.1,
|
||||
"Mg": 1.8,
|
||||
"Na": 0.5,
|
||||
"K": 18,
|
||||
"DCAB Форм": 120,
|
||||
"Сахар и Крохм": 120,
|
||||
"Сахар": 40,
|
||||
"Крахмал": 15,
|
||||
"Нераств. Крохмал": 5,
|
||||
"Каротин": 25,
|
||||
"Сырая зола": 65,
|
||||
"БЕР": 95,
|
||||
"Переварим Протеин": 55,
|
||||
"КРС Протеин": 68,
|
||||
"Переварим сырая клетч": 195,
|
||||
"Fe": 40,
|
||||
"Zn": 22,
|
||||
"Cu": 6,
|
||||
"Mn": 35,
|
||||
"Se": 0.04,
|
||||
"J": 0.02,
|
||||
"Вит А": 12000,
|
||||
"Вит D": 800,
|
||||
"Вит Е": 30,
|
||||
"Вит В1": 2.5,
|
||||
"Вит В2": 4.0,
|
||||
"Вит В6": 3.0,
|
||||
"Вит В12": 0.0,
|
||||
"Лизин": 2.8,
|
||||
"Метаб лизин": 1.2,
|
||||
"% уСП/кг СВ": 7.3,
|
||||
"Нер. СП / кг СВ": 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "LAB тест — комбикорм",
|
||||
"type": "Концентрированные",
|
||||
"dry_matter": 88.0,
|
||||
"price": 22.0,
|
||||
"nutrients": {
|
||||
"СВ": 880,
|
||||
"Осн.Корм": 0,
|
||||
"Сыр. Протеин": 170,
|
||||
"уСП": 142,
|
||||
"ОЭ-КРС": 12.5,
|
||||
"ЧЭЛ- КРС": 7.8,
|
||||
"Сырая клетч": 95,
|
||||
"НДК": 95,
|
||||
"КДК": 42,
|
||||
"Структур клетч": 28,
|
||||
"Сырой жир": 38,
|
||||
"Ca": 8.0,
|
||||
"P": 5.5,
|
||||
"Mg": 3.2,
|
||||
"Na": 1.2,
|
||||
"K": 6.5,
|
||||
"DCAB Форм": 45,
|
||||
"Сахар и Крохм": 420,
|
||||
"Сахар": 80,
|
||||
"Крахмал": 280,
|
||||
"Нераств. Крохмал": 35,
|
||||
"Каротин": 5,
|
||||
"Сырая зола": 55,
|
||||
"БЕР": 520,
|
||||
"Переварим Протеин": 125,
|
||||
"КРС Протеин": 155,
|
||||
"Fe": 85,
|
||||
"Zn": 55,
|
||||
"Cu": 12,
|
||||
"Mn": 28,
|
||||
"Se": 0.12,
|
||||
"Вит А": 2500,
|
||||
"Вит D": 400,
|
||||
"Вит Е": 45,
|
||||
"Вит В1": 5.5,
|
||||
"Вит В2": 6.0,
|
||||
"Лизин": 8.5,
|
||||
"Метаб лизин": 4.2,
|
||||
"% уСП/кг СВ": 16.1,
|
||||
"Нер. СП / кг СВ": 28,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "LAB тест — силос",
|
||||
"type": "Сочные корма",
|
||||
"dry_matter": 34.0,
|
||||
"price": 4.0,
|
||||
"nutrients": {
|
||||
"СВ": 340,
|
||||
"Осн.Корм": 0,
|
||||
"Сыр. Протеин": 32,
|
||||
"уСП": 28,
|
||||
"ОЭ-КРС": 6.2,
|
||||
"ЧЭЛ- КРС": 3.8,
|
||||
"Сырая клетч": 220,
|
||||
"НДК": 220,
|
||||
"КДК": 125,
|
||||
"Структур клетч": 45,
|
||||
"Сырой жир": 28,
|
||||
"Ca": 4.5,
|
||||
"P": 2.8,
|
||||
"Mg": 1.5,
|
||||
"Na": 0.3,
|
||||
"K": 12,
|
||||
"DCAB Форм": 95,
|
||||
"Сахар и Крохм": 180,
|
||||
"Сахар": 60,
|
||||
"Крахмал": 45,
|
||||
"Нераств. Крохмал": 12,
|
||||
"Каротин": 18,
|
||||
"Сырая зола": 38,
|
||||
"БЕР": 145,
|
||||
"Переварим Протеин": 22,
|
||||
"КРС Протеин": 28,
|
||||
"Fe": 25,
|
||||
"Zn": 18,
|
||||
"Cu": 4,
|
||||
"Mn": 15,
|
||||
"Вит А": 3500,
|
||||
"Вит Е": 18,
|
||||
"Лизин": 1.1,
|
||||
"% уСП/кг СВ": 8.2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
_RECIPE_SPECS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"name": RECIPE_MATRIX,
|
||||
"profile_key": PROFILE_KEY,
|
||||
"description": "перегруз для проверки красных отклонений",
|
||||
"lines": (
|
||||
("LAB тест — сено", 5.0),
|
||||
("LAB тест — комбикорм", 12.0),
|
||||
("LAB тест — силос", 8.0),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": RECIPE_IN_NORMS,
|
||||
"profile_key": PROFILE_KEY,
|
||||
"description": "поддержание 300 кг — большинство норм в зелёной зоне",
|
||||
"lines": (
|
||||
("LAB тест — сено", 4.5),
|
||||
("LAB тест — комбикорм", 1.0),
|
||||
("LAB тест — силос", 1.8),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabMathRationStats:
|
||||
recipe_ids: list[str] = field(default_factory=list)
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
recalculated: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def recipe_id(self) -> str | None:
|
||||
return self.recipe_ids[0] if self.recipe_ids else None
|
||||
|
||||
|
||||
def _marker_path() -> Path:
|
||||
from config import Config
|
||||
|
||||
return Path(Config.DATA_DIR) / _MARKER
|
||||
|
||||
|
||||
def _get_or_create_component(spec: dict[str, Any]) -> Component:
|
||||
comp = Component.query.filter_by(name=spec["name"], is_deleted=False).first()
|
||||
if comp is None:
|
||||
comp = Component(
|
||||
id=default_uuid(),
|
||||
name=spec["name"],
|
||||
type=spec["type"],
|
||||
dry_matter=float(spec["dry_matter"]),
|
||||
protein=0.0,
|
||||
energy=0.0,
|
||||
price=float(spec.get("price", 0)),
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
else:
|
||||
comp.dry_matter = float(spec["dry_matter"])
|
||||
comp.price = float(spec.get("price", comp.price or 0))
|
||||
comp.updated_by = "lab-math-ration"
|
||||
nutrients = {k: float(v) for k, v in spec["nutrients"].items()}
|
||||
save_component_nutrients(comp.id, nutrients, user_id="lab-math-ration", pin=True)
|
||||
return comp
|
||||
|
||||
|
||||
def _seed_one_recipe(
|
||||
spec: dict[str, Any],
|
||||
*,
|
||||
by_name: dict[str, Component],
|
||||
profile: LabAnimalProfile,
|
||||
stats: LabMathRationStats,
|
||||
) -> str:
|
||||
recipe = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
|
||||
if recipe is None:
|
||||
recipe = Recipe(
|
||||
id=default_uuid(),
|
||||
name=spec["name"],
|
||||
heads_per_trip=HEADS,
|
||||
ration_type="DAIRY",
|
||||
mixing_time=0,
|
||||
trip_percent=100.0,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
db.session.add(recipe)
|
||||
stats.created += 1
|
||||
db.session.flush()
|
||||
else:
|
||||
recipe.heads_per_trip = HEADS
|
||||
recipe.ration_type = "DAIRY"
|
||||
stats.updated += 1
|
||||
|
||||
for ing in Ingredient.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
|
||||
ing.soft_delete("lab-math-ration")
|
||||
for order, (cname, wph) in enumerate(spec["lines"]):
|
||||
comp = by_name[cname]
|
||||
db.session.add(
|
||||
Ingredient(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe.id,
|
||||
component_id=comp.id,
|
||||
name=comp.name,
|
||||
amount=wph * HEADS,
|
||||
weight_per_head=wph,
|
||||
dry_matter=comp.dry_matter,
|
||||
order=order,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
)
|
||||
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe.id, is_deleted=False).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(
|
||||
recipe_id=recipe.id,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
db.session.add(header)
|
||||
header.animal_profile_id = profile.id
|
||||
header.seed_source = "lab_math_test"
|
||||
|
||||
for line in LabRationLine.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
|
||||
line.soft_delete("lab-math-ration")
|
||||
for idx, (cname, wph) in enumerate(spec["lines"]):
|
||||
comp = by_name[cname]
|
||||
db.session.add(
|
||||
LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe.id,
|
||||
component_id=comp.id,
|
||||
ingredient_name=comp.name,
|
||||
row_index=idx,
|
||||
daily_kg=wph * HEADS,
|
||||
in_ration=True,
|
||||
in_compound=False,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
)
|
||||
return recipe.id
|
||||
|
||||
|
||||
def seed_lab_math_ration(*, force: bool = False, run_calc: bool = True) -> LabMathRationStats:
|
||||
stats = LabMathRationStats()
|
||||
if not force and _marker_path().is_file():
|
||||
for spec in _RECIPE_SPECS:
|
||||
existing = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
|
||||
if existing:
|
||||
stats.recipe_ids.append(existing.id)
|
||||
if stats.recipe_ids:
|
||||
stats.updated = len(stats.recipe_ids)
|
||||
return stats
|
||||
|
||||
profile = LabAnimalProfile.query.filter_by(profile_key=PROFILE_KEY, is_deleted=False).first()
|
||||
if profile is None:
|
||||
stats.errors.append(
|
||||
f"Профиль {PROFILE_KEY} не найден — запустите scripts/seed_lab_math_profiles.py"
|
||||
)
|
||||
return stats
|
||||
|
||||
components = [_get_or_create_component(spec) for spec in _COMPONENTS]
|
||||
by_name = {c.name: c for c in components}
|
||||
|
||||
for spec in _RECIPE_SPECS:
|
||||
rid = _seed_one_recipe(spec, by_name=by_name, profile=profile, stats=stats)
|
||||
stats.recipe_ids.append(rid)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if run_calc:
|
||||
for rid in stats.recipe_ids:
|
||||
try:
|
||||
recalculate_ration(rid, "lab-math-ration")
|
||||
stats.recalculated += 1
|
||||
except Exception as exc:
|
||||
stats.errors.append(f"пересчёт {rid}: {exc}")
|
||||
|
||||
_marker_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
_marker_path().write_text("\n".join(stats.recipe_ids) + "\n", encoding="utf-8")
|
||||
_log.info(
|
||||
"lab math rations: ids=%s matrix+in_norms heads=%s",
|
||||
stats.recipe_ids,
|
||||
HEADS,
|
||||
)
|
||||
return stats
|
||||
@@ -0,0 +1,90 @@
|
||||
"""5 профилей норм для проверки математики рациона (строки 1–5 из data/seed/norms)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from app import db
|
||||
from app.lab.commands.import_seed import _parse_min_max_groups, parse_norm_profile_row
|
||||
from app.lab.seed_paths import norms_dir
|
||||
from app.lab.models import LabAnimalProfile
|
||||
from app.lab.services.profile_norms import save_norms_from_payload
|
||||
from app.models.base import default_uuid
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_MATH_SPECS: tuple[tuple[str, str, int, str], ...] = (
|
||||
("DAIRY", "Нормы Дойн.csv", 1, "lab_math_dairy_01"),
|
||||
("DAIRY", "Нормы Дойн.csv", 2, "lab_math_dairy_02"),
|
||||
("DAIRY", "Нормы Дойн.csv", 3, "lab_math_dairy_03"),
|
||||
("DAIRY", "Нормы Дойн.csv", 4, "lab_math_dairy_04"),
|
||||
("DAIRY", "Нормы Дойн.csv", 5, "lab_math_dairy_05"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MathTestSeedStats:
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _load_csv_row(csv_path: Path, external_no: int, ration_type: str) -> dict | None:
|
||||
with csv_path.open(encoding="utf-8") as f:
|
||||
rows = list(csv.reader(f))
|
||||
if len(rows) < 8:
|
||||
return None
|
||||
groups = _parse_min_max_groups(rows[2], rows[3])
|
||||
data_row = rows[5 + external_no]
|
||||
|
||||
eno = external_no
|
||||
return parse_norm_profile_row(
|
||||
data_row,
|
||||
groups,
|
||||
ration_type,
|
||||
unmapped=set(),
|
||||
profile_key_fn=lambda rt, _ext: f"lab_math_{rt.lower()}_{eno:02d}",
|
||||
)
|
||||
|
||||
|
||||
def seed_math_test_profiles(*, csv_dir: Path | None = None) -> MathTestSeedStats:
|
||||
stats = MathTestSeedStats()
|
||||
base = csv_dir or norms_dir()
|
||||
|
||||
for ration_type, filename, external_no, profile_key in _MATH_SPECS:
|
||||
parsed = _load_csv_row(base / filename, external_no, ration_type)
|
||||
if parsed is None:
|
||||
stats.errors.append(f"Строка {external_no} не найдена в {filename}")
|
||||
continue
|
||||
|
||||
profile = LabAnimalProfile.query.filter_by(profile_key=profile_key, is_deleted=False).first()
|
||||
if profile is None:
|
||||
profile = LabAnimalProfile(
|
||||
id=default_uuid(),
|
||||
profile_key=profile_key,
|
||||
created_by="lab-math-test-seed",
|
||||
)
|
||||
db.session.add(profile)
|
||||
stats.created += 1
|
||||
else:
|
||||
stats.updated += 1
|
||||
|
||||
profile.label = f"TEST математика #{external_no} — {parsed['label'][:48]}"
|
||||
profile.ration_type = ration_type
|
||||
profile.external_no = parsed["external_no"]
|
||||
parsed["indicators"]["rnb"] = {"min": -10.0, "max": 20.0}
|
||||
save_norms_from_payload(
|
||||
profile,
|
||||
{
|
||||
"massKg": parsed["mass_kg"],
|
||||
"externalNo": parsed["external_no"],
|
||||
"indicators": parsed["indicators"],
|
||||
},
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
_log.info("lab math test profiles: created=%s updated=%s", stats.created, stats.updated)
|
||||
return stats
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app import db
|
||||
from app.lab.commands.write_audit import write_audit
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.loaders.execution_loader import load_execution
|
||||
from app.lab.models import LabRationLine, LabRecipeRation
|
||||
from app.models import Component
|
||||
from app.models.base import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def sync_from_execution(recipe_id: str, user_id: str = "system") -> dict:
|
||||
"""Обновить мастер из execution (обратный перенос /recipes → lab)."""
|
||||
execution = load_execution(recipe_id)
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
else:
|
||||
header.updated_by = user_id
|
||||
|
||||
existing = LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
|
||||
for line in existing:
|
||||
line.soft_delete(user_id)
|
||||
|
||||
created = 0
|
||||
for idx, line in enumerate(execution.lines):
|
||||
comp = Component.query.get(line.component_id) if line.component_id else None
|
||||
db.session.add(
|
||||
LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
component_id=line.component_id,
|
||||
ingredient_name=line.name or (comp.name if comp else None),
|
||||
row_index=idx,
|
||||
daily_kg=line.daily_kg_total,
|
||||
in_ration=True,
|
||||
in_compound=False,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
|
||||
header.seed_source = "synced_from"
|
||||
write_audit("SYNC_FROM_EXECUTION", "lab_recipe_ration", recipe_id, user_id)
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "lines": created}
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app import db
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.models import LabAnimalProfile
|
||||
from app.lab.calc.nutrients import parse_num
|
||||
from app.lab.calc.norms_resolver import normalize_norms_method
|
||||
from app.lab.services.norms_params import save_norms_params
|
||||
from app.lab.services.profile_norms import save_norms_from_payload, sync_racion_norms_to_profile
|
||||
from app.models.base import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def upsert_animal_profile(profile_id: str | None, payload: dict[str, Any], user_id: str = "system") -> dict:
|
||||
pid = (profile_id or payload.get("id") or "").strip() or default_uuid()
|
||||
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
|
||||
is_new = profile is None
|
||||
if is_new:
|
||||
profile = LabAnimalProfile(id=pid, created_by=user_id)
|
||||
db.session.add(profile)
|
||||
|
||||
key = (payload.get("profileKey") or payload.get("profile_key") or "").strip()
|
||||
label = (payload.get("label") or "").strip()
|
||||
ration_type = (payload.get("rationType") or payload.get("ration_type") or "BEEF").strip().upper()
|
||||
if not key or not label:
|
||||
raise ValueError("profileKey и label обязательны")
|
||||
|
||||
profile.profile_key = key
|
||||
profile.label = label
|
||||
profile.ration_type = ration_type
|
||||
if "normsMethod" in payload or "norms_method" in payload:
|
||||
profile.norms_method = normalize_norms_method(
|
||||
payload.get("normsMethod", payload.get("norms_method"))
|
||||
)
|
||||
if "normsParams" in payload or "norms_params" in payload:
|
||||
raw_params = payload.get("normsParams", payload.get("norms_params"))
|
||||
save_norms_params(profile, raw_params if isinstance(raw_params, dict) else None)
|
||||
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
|
||||
profile.milk_yield_kg = parse_num(payload.get("milkYieldKg", payload.get("milk_yield_kg")))
|
||||
|
||||
norms_payload: dict[str, Any] = {}
|
||||
if "normsData" in payload or "norms_data" in payload:
|
||||
raw = payload.get("normsData") if "normsData" in payload else payload.get("norms_data")
|
||||
if isinstance(raw, dict):
|
||||
norms_payload = dict(raw)
|
||||
if payload.get("massKg") is not None or payload.get("mass_kg") is not None:
|
||||
norms_payload["massKg"] = payload.get("massKg", payload.get("mass_kg"))
|
||||
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
|
||||
norms_payload["milkYieldKg"] = payload.get("milkYieldKg", payload.get("milk_yield_kg"))
|
||||
if payload.get("externalNo") is not None or payload.get("external_no") is not None:
|
||||
norms_payload["externalNo"] = payload.get("externalNo", payload.get("external_no"))
|
||||
if isinstance(payload.get("indicators"), dict):
|
||||
norms_payload["indicators"] = payload["indicators"]
|
||||
if norms_payload:
|
||||
save_norms_from_payload(profile, norms_payload)
|
||||
|
||||
profile.updated_by = user_id
|
||||
sync_racion_norms_to_profile(profile)
|
||||
db.session.commit()
|
||||
return {"id": profile.id, "profileKey": profile.profile_key, "label": profile.label}
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app import db
|
||||
from app.lab.db_guard import retry_locked
|
||||
from app.lab.models import LabRationLine, LabRecipeRation
|
||||
from app.models import Recipe
|
||||
from app.models.base import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def upsert_ration(recipe_id: str, payload: dict[str, Any], user_id: str = "system") -> dict[str, Any]:
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
header.animal_profile_id = payload.get("animalProfileId") or payload.get("animal_profile_id")
|
||||
if "params" in payload:
|
||||
params = payload.get("params") or {}
|
||||
if isinstance(params, dict):
|
||||
seeded = params.get("seeded_from") or params.get("synced_from")
|
||||
if seeded:
|
||||
header.seed_source = str(seeded)
|
||||
if "rationType" in payload or "ration_type" in payload:
|
||||
recipe.ration_type = (payload.get("rationType") or payload.get("ration_type") or "").upper() or None
|
||||
header.updated_by = user_id
|
||||
|
||||
incoming = payload.get("lines") or []
|
||||
existing = {
|
||||
str(l.id): l
|
||||
for l in LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
|
||||
}
|
||||
seen_ids: set[str] = set()
|
||||
for idx, row in enumerate(incoming):
|
||||
line_id = str(row.get("id") or "")
|
||||
line = existing.get(line_id) if line_id else None
|
||||
if line is None:
|
||||
line = LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.session.add(line)
|
||||
line.row_index = int(row.get("rowIndex", row.get("row_index", idx)))
|
||||
line.component_id = row.get("componentId") or row.get("component_id")
|
||||
line.ingredient_name = row.get("ingredientName") or row.get("ingredient_name")
|
||||
line.daily_kg = row.get("dailyKg", row.get("daily_kg"))
|
||||
line.in_ration = bool(row.get("inRation", row.get("in_ration", True)))
|
||||
line.in_compound = bool(row.get("inCompound", row.get("in_compound", False)))
|
||||
line.updated_by = user_id
|
||||
if line.id:
|
||||
seen_ids.add(str(line.id))
|
||||
|
||||
for lid, line in existing.items():
|
||||
if lid not in seen_ids:
|
||||
line.soft_delete(user_id)
|
||||
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "ok": True}
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger("app.lab.audit")
|
||||
|
||||
|
||||
def write_audit(
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: str | None,
|
||||
user_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
_log.info(
|
||||
"lab action=%s entity=%s/%s user=%s metadata=%s",
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
user_id or "system",
|
||||
json.dumps(metadata or {}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
def write_calculation_run(
|
||||
recipe_id: str,
|
||||
status: str,
|
||||
kpi_results: dict[str, Any],
|
||||
*,
|
||||
duration_ms: int | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
_log.info(
|
||||
"lab calculation recipe=%s status=%s duration_ms=%s engine=%s error=%s kpi=%s",
|
||||
recipe_id,
|
||||
status,
|
||||
duration_ms,
|
||||
"native-1",
|
||||
error_message or "",
|
||||
json.dumps(kpi_results, ensure_ascii=False),
|
||||
)
|
||||
Reference in New Issue
Block a user