Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+21
View File
@@ -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)}
+202
View File
@@ -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}
+299
View File
@@ -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
+69
View File
@@ -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}
+43
View File
@@ -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),
)