Интегрирован 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
@@ -0,0 +1,3 @@
"""Analytics helpers ported from WESP (import submodules directly)."""
__all__: list[str] = []
@@ -0,0 +1,37 @@
"""Разбор периода для analytics."""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Optional, Tuple
def parse_iso_date(value: Optional[str]) -> Optional[date]:
if not value:
return None
try:
return date.fromisoformat(str(value).strip()[:10])
except ValueError:
return None
def resolve_date_bounds(
date_from: Optional[str],
date_to: Optional[str],
) -> Tuple[datetime, datetime]:
"""Включительно date_from … date_to (конец дня date_to)."""
today = date.today()
start_d = parse_iso_date(date_from) or today
end_d = parse_iso_date(date_to) or start_d
if end_d < start_d:
start_d, end_d = end_d, start_d
start_dt = datetime.combine(start_d, datetime.min.time())
end_dt = datetime.combine(end_d, datetime.max.time())
return start_dt, end_dt
def iter_dates_in_range(start_d: date, end_d: date):
cur = start_d
while cur <= end_d:
yield cur
cur += timedelta(days=1)
@@ -0,0 +1,105 @@
"""Итоги по кормораздатчикам для PDF/Excel."""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from app import db
from app.models import LoadingReportComponent
from app.services.analytics.date_range import resolve_date_bounds
from app.services.analytics.finance_summary import _report_filters
from app.services.analytics.prices import component_prices, price_for
from app.services.analytics.recipe_filter import parse_recipe_ids
from app.services.analytics.recipe_location import build_recipe_location_maps
def build_dispenser_summary_rows(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
recipe_id: Optional[str] = None,
recipe_ids: Optional[str] = None,
) -> List[Dict[str, Any]]:
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
reports = db.session.execute(
_report_filters(start_dt=start_dt, end_dt=end_dt, recipe_ids=ids)
).scalars().all()
if not reports:
return []
report_by_id = {r.id: r for r in reports}
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
farm_by_recipe, dispenser_by_recipe = build_recipe_location_maps(recipe_ids_set)
report_ids = [r.id for r in reports]
components = db.session.execute(
select(LoadingReportComponent).where(
LoadingReportComponent.report_id.in_(report_ids),
LoadingReportComponent.is_deleted.is_(False),
)
).scalars().all()
prices = component_prices(
component_ids=[c.component_id for c in components if c.component_id],
names=[c.component_name for c in components],
)
agg: Dict[str, Dict[str, Any]] = defaultdict(
lambda: {
"dispenser": "",
"farm": "",
"overloadRub": 0.0,
"underloadRub": 0.0,
"netRub": 0.0,
}
)
for comp in components:
report = report_by_id.get(comp.report_id)
if not report:
continue
dispenser = dispenser_by_recipe.get(report.recipe_id, "")
farm = farm_by_recipe.get(report.recipe_id, "")
target = float(comp.target_weight or 0)
actual = float(comp.actual_weight or 0)
if target <= 0 and actual <= 0:
continue
price = price_for(
prices=prices,
component_id=comp.component_id,
name=str(comp.component_name or ""),
)
dev_rub = (actual - target) * price
row = agg[dispenser]
row["dispenser"] = dispenser
row["farm"] = farm
if dev_rub > 0:
row["overloadRub"] += dev_rub
elif dev_rub < 0:
row["underloadRub"] += abs(dev_rub)
row["netRub"] += dev_rub
out: List[Dict[str, Any]] = []
for row in agg.values():
if max(row["overloadRub"], row["underloadRub"]) <= 0:
continue
out.append(
{
"dispenser": row["dispenser"],
"farm": row["farm"],
"overloadRub": round(row["overloadRub"], 2),
"underloadRub": round(row["underloadRub"], 2),
"netRub": round(row["netRub"], 2),
}
)
out.sort(
key=lambda r: max(r["overloadRub"], r["underloadRub"]),
reverse=True,
)
return out
@@ -0,0 +1,51 @@
"""Текст шапки экспорта с применёнными фильтрами."""
from __future__ import annotations
from typing import Any, Optional
_NET_DOMINANT_HINTS = {
"overload": "Преобладает перерасход — прямой убыток бюджета",
"underload": "Преобладает недогруз — экономия сейчас, риск падения надоев",
"balanced": "Перерасход и недогруз за период уравновешены",
}
def net_rub_display(summary: dict[str, Any]) -> float:
"""Итог без знака минус — как на вкладке «Итоги» в интерфейсе."""
return round(abs(float(summary.get("netRub") or 0)), 2)
def net_rub_dominant_hint(summary: dict[str, Any]) -> str:
return _NET_DOMINANT_HINTS.get(str(summary.get("dominantIssue") or "balanced"), _NET_DOMINANT_HINTS["balanced"])
def _count_label(raw: Optional[str], *, all_label: str) -> str:
text = (raw or "").strip()
if not text or text.lower() == "all":
return all_label
parts = [p.strip() for p in text.split(",") if p.strip()]
if not parts:
return all_label
if len(parts) == 1:
return parts[0]
return str(len(parts))
def build_export_header_lines(
*,
date_from: str,
date_to: str,
filter_farms: Optional[str] = None,
filter_dispensers: Optional[str] = None,
filter_recipes: Optional[str] = None,
) -> list[str]:
period = f"{date_from}{date_to}"
farms = _count_label(filter_farms, all_label="все")
dispensers = _count_label(filter_dispensers, all_label="все")
recipes = _count_label(filter_recipes, all_label="все")
lines = [f"Отчёт о загрузках кормов за период {period}."]
filter_bits = [f"Фермы: {farms}", f"Кормораздатчики: {dispensers}", f"Рейсы: {recipes}"]
lines.append("Выбрано: " + ", ".join(filter_bits) + ".")
return lines
@@ -0,0 +1,29 @@
"""Разделы экспорта analytics: итоги, сравнение, отчёты."""
from __future__ import annotations
VALID_EXPORT_SECTIONS = frozenset({"summary", "comparison", "reports"})
_SECTION_FILENAMES = {
"summary": "itogi",
"comparison": "sravnenie",
"reports": "podrobno",
}
def parse_export_sections(section: str | None = None) -> frozenset[str]:
if not section or not str(section).strip() or str(section).strip().lower() == "all":
return VALID_EXPORT_SECTIONS
parts = {p.strip().lower() for p in str(section).split(",") if p.strip()}
filtered = parts & VALID_EXPORT_SECTIONS
return filtered if filtered else VALID_EXPORT_SECTIONS
def export_filename_base(*, date_from: str, date_to: str, sections: frozenset[str]) -> str:
if sections == VALID_EXPORT_SECTIONS:
return f"otchety_{date_from}_{date_to}"
if len(sections) == 1:
key = next(iter(sections))
return f"{_SECTION_FILENAMES[key]}_{date_from}_{date_to}"
joined = "_".join(sorted(_SECTION_FILENAMES[s] for s in sections))
return f"{joined}_{date_from}_{date_to}"
@@ -0,0 +1,130 @@
"""Итоги в рублях: перерасход, недогруз, топ компонентов."""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from app import db
from app.models import LoadingReport, LoadingReportComponent
from app.services.analytics.date_range import resolve_date_bounds
from app.services.analytics.prices import component_prices, price_for
from app.services.analytics.recipe_filter import parse_recipe_ids
def _report_filters(
*,
start_dt,
end_dt,
recipe_id: Optional[str] = None,
recipe_ids: Optional[list[str]] = None,
):
q = select(LoadingReport).where(
LoadingReport.is_deleted.is_(False),
LoadingReport.start_time >= start_dt,
LoadingReport.start_time <= end_dt,
)
ids = recipe_ids or parse_recipe_ids(recipe_id=recipe_id)
if ids:
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
return q.order_by(LoadingReport.start_time.desc())
def build_finance_summary(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
recipe_id: Optional[str] = None,
recipe_ids: Optional[str] = None,
) -> Dict[str, Any]:
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
reports = db.session.execute(_report_filters(
start_dt=start_dt, end_dt=end_dt, recipe_ids=ids
)).scalars().all()
if not reports:
return {
"overloadRub": 0.0,
"underloadRub": 0.0,
"netRub": 0.0,
"dominantIssue": "balanced",
"topComponents": [],
"reportCount": 0,
}
report_ids = [r.id for r in reports]
components = db.session.execute(
select(LoadingReportComponent).where(
LoadingReportComponent.report_id.in_(report_ids),
LoadingReportComponent.is_deleted.is_(False),
)
).scalars().all()
comp_ids = [c.component_id for c in components if c.component_id]
names = [c.component_name for c in components]
prices = component_prices(component_ids=comp_ids, names=names)
by_component: Dict[str, Dict[str, Any]] = defaultdict(
lambda: {"name": "", "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0}
)
overload_total = 0.0
underload_total = 0.0
for comp in components:
target = float(comp.target_weight or 0)
actual = float(comp.actual_weight or 0)
if target <= 0 and actual <= 0:
continue
price = price_for(
prices=prices,
component_id=comp.component_id,
name=str(comp.component_name or ""),
)
dev_kg = actual - target
dev_rub = dev_kg * price
key = str(comp.component_id or comp.component_name or "unknown")
row = by_component[key]
row["name"] = str(comp.component_name or "")
row["componentId"] = comp.component_id
if dev_rub > 0:
row["overloadRub"] += dev_rub
overload_total += dev_rub
elif dev_rub < 0:
row["underloadRub"] += abs(dev_rub)
underload_total += abs(dev_rub)
row["netRub"] += dev_rub
top = sorted(
by_component.values(),
key=lambda x: max(x["overloadRub"], x["underloadRub"]),
reverse=True,
)[:3]
top_out = [
{
"name": r["name"],
"componentId": r.get("componentId"),
"overloadRub": round(r["overloadRub"], 2),
"underloadRub": round(r["underloadRub"], 2),
"netRub": round(r["netRub"], 2),
}
for r in top
if max(r["overloadRub"], r["underloadRub"]) > 0
]
net = overload_total - underload_total
if overload_total > underload_total:
dominant = "overload"
elif underload_total > overload_total:
dominant = "underload"
else:
dominant = "balanced"
return {
"overloadRub": round(overload_total, 2),
"underloadRub": round(underload_total, 2),
"netRub": round(net, 2),
"dominantIssue": dominant,
"topComponents": top_out,
"reportCount": len(reports),
}
@@ -0,0 +1,425 @@
"""PDF: итоги + сравнение + подробно (для директора)."""
from __future__ import annotations
import io
from typing import Any, Iterable, List, Optional
from xml.sax.saxutils import escape
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from app.services.analytics.dispenser_summary_export_rows import build_dispenser_summary_rows
from app.services.analytics.export_context import (
build_export_header_lines,
net_rub_display,
net_rub_dominant_hint,
)
from app.services.analytics.export_sections import parse_export_sections
from app.services.analytics.finance_summary import build_finance_summary
from app.services.analytics.plan_fact_layers import build_plan_fact_rows
from app.services.analytics.reports_detail import build_reports_detail_rows
from app.services.feed_accounting.pdf_feed_accounting import (
_register_cyrillic_font,
_register_cyrillic_font_bold,
)
_PAGE_MARGIN = 5 * mm
_LANDSCAPE_WIDTH = landscape(A4)[0]
_USABLE_WIDTH = _LANDSCAPE_WIDTH - 2 * _PAGE_MARGIN
def _widths_from_percentages(percentages: list[float]) -> list[float]:
total = float(sum(percentages)) or 100.0
scale = _USABLE_WIDTH / total
return [pct * scale for pct in percentages]
def _format_rub(amount: float) -> str:
"""Сумма в рублях без символа ₽ — DejaVu Sans его не всегда отрисовывает."""
return f"{abs(float(amount or 0)):,.0f} руб.".replace(",", " ")
def _format_rub_plain(amount: float) -> str:
"""Число для ячеек таблицы без суффикса валюты."""
return f"{abs(float(amount or 0)):,.0f}".replace(",", " ")
def _para(text: Any, style: ParagraphStyle) -> Paragraph:
return Paragraph(escape(str(text if text is not None else "")), style)
def _styles():
font = _register_cyrillic_font()
bold_font = _register_cyrillic_font_bold()
styles = getSampleStyleSheet()
for key in ("Title", "Normal", "Heading2", "Heading3"):
styles[key].fontName = font
styles.add(
ParagraphStyle(
name="KpiValue",
parent=styles["Normal"],
fontName=font,
fontSize=16,
leading=20,
spaceAfter=2,
)
)
styles.add(
ParagraphStyle(
name="KpiLabel",
parent=styles["Normal"],
fontName=font,
fontSize=8,
textColor=colors.grey,
spaceAfter=8,
)
)
styles.add(
ParagraphStyle(
name="PdfTableCell",
fontName=font,
fontSize=6.5,
leading=8,
wordWrap="LTR",
)
)
styles.add(
ParagraphStyle(
name="PdfTableHeaderCell",
fontName=bold_font,
fontSize=7,
leading=8.5,
wordWrap="LTR",
)
)
return styles, font, bold_font
def _table(
data: List[List[Any]],
col_widths,
font: str,
styles,
*,
header_rows: int = 1,
bold_font: str | None = None,
wrap_cols: Iterable[int] = (),
numeric_cols: Iterable[int] = (),
):
wrap_set = frozenset(wrap_cols)
numeric_set = frozenset(numeric_cols)
cell_style: ParagraphStyle = styles["PdfTableCell"]
header_style: ParagraphStyle = styles["PdfTableHeaderCell"]
processed: List[List[Any]] = []
for row_idx, row in enumerate(data):
is_header = row_idx < header_rows
new_row: List[Any] = []
for col_idx, val in enumerate(row):
if col_idx in wrap_set:
new_row.append(_para(val, header_style if is_header else cell_style))
elif val is None:
new_row.append("")
else:
new_row.append(str(val))
processed.append(new_row)
tbl = Table(processed, colWidths=col_widths, repeatRows=header_rows, splitByRow=1)
header_font = bold_font or font
style_rules: List[Any] = [
("FONTNAME", (0, 0), (-1, -1), font),
("FONTSIZE", (0, header_rows), (-1, -1), 7),
("BACKGROUND", (0, 0), (-1, header_rows - 1), colors.Color(0.82, 0.82, 0.82)),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("FONTNAME", (0, 0), (-1, header_rows - 1), header_font),
("FONTSIZE", (0, 0), (-1, header_rows - 1), 7.5),
("TEXTCOLOR", (0, 0), (-1, header_rows - 1), colors.black),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]
for col_idx in numeric_set:
style_rules.append(("ALIGN", (col_idx, header_rows), (col_idx, -1), "RIGHT"))
tbl.setStyle(TableStyle(style_rules))
return tbl
def _kpi_block(
story: List[Any],
styles,
font: str,
bold_font: str,
summary: dict[str, Any],
*,
date_from: str,
date_to: str,
recipe_id: str | None,
recipe_ids: str | None,
) -> None:
story.append(Paragraph("Итоги", styles["Heading2"]))
kpis = [
("Перерасход", _format_rub(summary.get("overloadRub", 0))),
("Недогруз", _format_rub(summary.get("underloadRub", 0))),
("Итого", _format_rub(net_rub_display(summary))),
]
kpi_data = [[label, value] for label, value in kpis]
tbl = Table(kpi_data, colWidths=[45 * mm, 55 * mm])
tbl.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, -1), font),
("FONTSIZE", (0, 0), (0, -1), 8),
("FONTSIZE", (1, 0), (1, -1), 14),
("TEXTCOLOR", (0, 0), (0, -1), colors.grey),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.append(tbl)
story.append(Paragraph(net_rub_dominant_hint(summary), styles["KpiLabel"]))
story.append(Spacer(1, 6))
top = summary.get("topComponents") or []
if top:
story.append(Paragraph("Где больше всего потеряли", styles["Heading3"]))
top_data = [["Компонент", "Перерасход", "Недогруз", "Итого"]]
for row in top[:8]:
top_data.append(
[
str(row.get("name") or ""),
_format_rub_plain(row.get("overloadRub", 0)),
_format_rub_plain(row.get("underloadRub", 0)),
_format_rub_plain(row.get("netRub", 0)),
]
)
story.append(
_table(
top_data,
[70 * mm, 35 * mm, 35 * mm, 35 * mm],
font,
styles,
bold_font=bold_font,
wrap_cols=(0,),
numeric_cols=(1, 2, 3),
)
)
dispensers = build_dispenser_summary_rows(
date_from=date_from,
date_to=date_to,
recipe_id=recipe_id,
recipe_ids=recipe_ids,
)
if dispensers:
story.append(Spacer(1, 8))
story.append(Paragraph("Кормораздатчики: итоговые отклонения", styles["Heading3"]))
disp_data = [["Кормораздатчик", "Ферма", "Перерасход", "Недогруз", "Итого"]]
for row in dispensers:
disp_data.append(
[
str(row.get("dispenser") or ""),
str(row.get("farm") or ""),
_format_rub_plain(row.get("overloadRub", 0)),
_format_rub_plain(row.get("underloadRub", 0)),
_format_rub_plain(row.get("netRub", 0)),
]
)
story.append(
_table(
disp_data,
[52 * mm, 48 * mm, 32 * mm, 32 * mm, 32 * mm],
font,
styles,
bold_font=bold_font,
wrap_cols=(0, 1),
numeric_cols=(2, 3, 4),
)
)
# Ширины «Сравнение» — укладываются в usable width, длинный текст переносится.
_COMPARISON_COL_WIDTHS = [
34 * mm, # Рейс
16 * mm, # Дата
38 * mm, # Компонент
16 * mm, # База
16 * mm, # План
16 * mm, # Факт
18 * mm, # Δ База
18 * mm, # Δ План
]
# «Подробно»: проценты от рабочей ширины — текст переносится, числа не сжимаются.
_DETAIL_COL_PCT = [5, 15, 16, 14, 10, 11, 11, 9, 9]
_DETAIL_COL_WIDTHS = _widths_from_percentages(_DETAIL_COL_PCT)
def build_analytics_pdf(
*,
date_from: str,
date_to: str,
recipe_id: str | None = None,
recipe_ids: str | None = None,
section: str | None = None,
filter_farms: Optional[str] = None,
filter_dispensers: Optional[str] = None,
filter_recipes: Optional[str] = None,
) -> bytes:
sections = parse_export_sections(section)
summary = None
plan_fact = None
detail_rows = None
if "summary" in sections:
summary = build_finance_summary(
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
)
if "comparison" in sections:
plan_fact = build_plan_fact_rows(
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
)
if "reports" in sections:
detail_rows = build_reports_detail_rows(
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
)
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=landscape(A4),
leftMargin=_PAGE_MARGIN,
rightMargin=_PAGE_MARGIN,
topMargin=_PAGE_MARGIN,
bottomMargin=_PAGE_MARGIN,
)
styles, font, bold_font = _styles()
story: List[Any] = []
section_list = [s for s in ("summary", "comparison", "reports") if s in sections]
story.append(Paragraph("Отчёты о загрузках", styles["Title"]))
for line in build_export_header_lines(
date_from=date_from,
date_to=date_to,
filter_farms=filter_farms,
filter_dispensers=filter_dispensers,
filter_recipes=filter_recipes,
):
story.append(Paragraph(line, styles["Normal"]))
story.append(Spacer(1, 8))
for idx, key in enumerate(section_list):
if idx > 0:
story.append(PageBreak())
if key == "summary":
_kpi_block(
story,
styles,
font,
bold_font,
summary or {},
date_from=date_from,
date_to=date_to,
recipe_id=recipe_id,
recipe_ids=recipe_ids,
)
elif key == "comparison":
story.append(Paragraph("Сравнение", styles["Heading2"]))
pf_data = [
[
"Рейс",
"Дата",
"Компонент",
"База, кг",
"План, кг",
"Факт, кг",
"Δ База, кг",
"Δ План, кг",
]
]
for item in (plan_fact or {}).get("items") or []:
for comp in item.get("components") or []:
base_kg = float(comp.get("baseKg") or 0)
plan_kg = float(comp.get("planTodayKg") or 0)
fact_kg = float(comp.get("actualKg") or 0)
pf_data.append(
[
str(item.get("recipeName") or ""),
str(item.get("date") or ""),
str(comp.get("name") or ""),
f"{base_kg:.1f}",
f"{plan_kg:.1f}",
f"{fact_kg:.1f}",
f"{fact_kg - base_kg:.1f}",
f"{fact_kg - plan_kg:.1f}",
]
)
if len(pf_data) == 1:
pf_data.append(["", "", "", "", "", "", "", ""])
story.append(
_table(
pf_data,
_COMPARISON_COL_WIDTHS,
font,
styles,
bold_font=bold_font,
wrap_cols=(0, 2),
numeric_cols=(3, 4, 5, 6, 7),
)
)
elif key == "reports":
story.append(Paragraph("Подробно", styles["Heading2"]))
detail_data = [
[
"Дата",
"Ферма",
"Рейс",
"Компонент",
"Группа",
"План, кг",
"Факт, кг",
"Откл., кг",
"Перерасх., кг",
]
]
for row in detail_rows or []:
target = float(row.get("targetKg") or 0)
actual = float(row.get("actualKg") or 0)
overload_kg = max(0.0, round(actual - target, 2))
detail_data.append(
[
str(row.get("dateDay") or ""),
str(row.get("farm") or ""),
str(row.get("recipeName") or ""),
str(row.get("feedComponent") or ""),
str(row.get("animalGroup") or ""),
f"{target:.1f}",
f"{actual:.1f}",
f"{actual - target:.1f}",
f"{overload_kg:.1f}" if overload_kg > 0 else "",
]
)
if len(detail_data) == 1:
detail_data.append(["", "", "", "", "", "", "", "", ""])
story.append(
_table(
detail_data,
_DETAIL_COL_WIDTHS,
font,
styles,
bold_font=bold_font,
wrap_cols=(1, 2, 3, 4),
numeric_cols=(5, 6, 7, 8),
)
)
doc.build(story)
return buf.getvalue()
@@ -0,0 +1,705 @@
"""Сравнение: по рецепту / на сегодня / сделали."""
from __future__ import annotations
from datetime import date
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select
from app import db
from app.models import (
Component,
Ingredient,
LoadingReport,
LoadingReportComponent,
Recipe,
UnloadingGroup,
UnloadingReport,
UnloadingReportGroup,
)
from app.services.analytics.date_range import resolve_date_bounds
from app.services.analytics.recipe_filter import parse_recipe_ids
from app.services.daily_plan.builder import ALL_DISPENSERS_ID, build_daily_plan
from app.services.daily_plan.skips import get_skipped_ingredient_ids
from app.services.recipe_calculator import calculate_recipe
_EXEC_TOLERANCE_KG = 20.0
_MIN_PLAN_KG = 0.05
def _base_kg(recipe: Recipe, ing: Ingredient) -> float:
heads = int(recipe.heads_per_trip or 0)
tp = float(recipe.trip_percent or 100) / 100.0
return round(float(ing.weight_per_head or 0) * heads * tp, 2)
def _find_trip_in_plan(plan: Dict[str, Any], recipe_id: str) -> Optional[Dict[str, Any]]:
for period in plan.get("periods") or []:
for trip in period.get("trips") or []:
if trip.get("recipeId") == recipe_id:
return trip
return None
def _plan_cache() -> Dict[str, Dict[str, Any]]:
return {}
def _get_plan_for_date(iso_date: str, cache: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
if iso_date not in cache:
cache[iso_date] = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date=iso_date)
return cache[iso_date]
def _match_plan_ingredient(
trip: Optional[Dict[str, Any]],
*,
component_id: Optional[str],
component_name: str,
) -> Optional[Dict[str, Any]]:
if not trip:
return None
name_lo = (component_name or "").strip().lower()
for ing in trip.get("ingredients") or []:
if component_id and ing.get("componentId") == component_id:
return ing
if ing.get("replacementComponentId") == component_id:
return ing
orig = str(ing.get("originalName") or ing.get("name") or "").lower()
if name_lo and orig == name_lo:
return ing
return None
def _build_deviation_notes(
*,
base_kg: float,
plan_today_kg: float,
actual_kg: float,
report_date: date,
skipped_today: bool = False,
phase: str = "loading",
) -> Tuple[List[Dict[str, str]], str]:
"""notes[].kind: zootech | execution.
fault:
execution — механизатор отклонился от плана на сегодня;
excluded — зоотехник исключил компонент, загрузка совпала с планом (0);
adjusted — зоотехник скорректировал план, загрузка совпала с новым планом;
none — без отклонений.
"""
notes: List[Dict[str, str]] = []
ds = report_date.strftime("%d.%m")
is_unloading = phase == "unloading"
overload_label = "перегруз при выгрузке" if is_unloading else "перегруз при загрузке"
underload_label = "недогруз при выгрузке" if is_unloading else "недогруз при загрузке"
loaded_despite_label = (
"выгрузили несмотря на исключение" if is_unloading else "загрузили несмотря на исключение"
)
plan_ok_label = (
"выгрузка по новому плану" if is_unloading else "загрузка по новому плану"
)
zootech_changed = base_kg > plan_today_kg + _EXEC_TOLERANCE_KG
execution_ok = abs(actual_kg - plan_today_kg) <= _EXEC_TOLERANCE_KG
fully_excluded = skipped_today and plan_today_kg <= _MIN_PLAN_KG
if fully_excluded:
notes.append(
{
"text": f"исключено из плана на сегодня ({ds})",
"kind": "zootech",
}
)
elif zootech_changed:
diff = round(base_kg - plan_today_kg, 1)
if execution_ok:
text = f"план скорректирован −{diff} кг ({ds}) — {plan_ok_label}"
else:
text = f"план скорректирован −{diff} кг ({ds})"
notes.append({"text": text, "kind": "zootech"})
if plan_today_kg > _MIN_PLAN_KG and actual_kg > plan_today_kg + _EXEC_TOLERANCE_KG:
diff = round(actual_kg - plan_today_kg, 1)
notes.append(
{
"text": f"{overload_label} +{diff} кг ({ds})",
"kind": "execution",
}
)
elif plan_today_kg > _MIN_PLAN_KG and actual_kg < plan_today_kg - _EXEC_TOLERANCE_KG:
diff = round(plan_today_kg - actual_kg, 1)
notes.append(
{
"text": f"{underload_label} {diff} кг ({ds})",
"kind": "execution",
}
)
elif fully_excluded and actual_kg > _MIN_PLAN_KG:
diff = round(actual_kg, 1)
notes.append(
{
"text": f"{loaded_despite_label} +{diff} кг ({ds})",
"kind": "execution",
}
)
has_execution = any(n["kind"] == "execution" for n in notes)
if has_execution:
fault = "execution"
elif fully_excluded and execution_ok:
fault = "excluded"
elif zootech_changed and execution_ok:
fault = "adjusted"
elif zootech_changed:
fault = "adjusted"
else:
fault = "none"
return notes, fault
def _plan_ing_matches_report_comp(
plan_ing: Dict[str, Any],
comp: LoadingReportComponent,
) -> bool:
cid = comp.component_id
cname = (comp.component_name or "").strip().lower()
pcid = plan_ing.get("componentId")
repl = plan_ing.get("replacementComponentId")
if cid:
sc = str(cid)
if pcid and sc == str(pcid):
return True
if repl and sc == str(repl):
return True
for key in ("originalName", "name"):
plan_name = str(plan_ing.get(key) or "").strip().lower()
if cname and plan_name and cname == plan_name:
return True
return False
def _component_row(
*,
recipe: Recipe,
ing: Optional[Ingredient],
plan_ing: Optional[Dict[str, Any]],
comp: Optional[LoadingReportComponent],
report_date: date,
skipped_ingredients: set,
) -> Dict[str, Any]:
cid = comp.component_id if comp else None
if plan_ing and not cid:
cid = plan_ing.get("replacementComponentId") or plan_ing.get("componentId")
cname = str(
(comp.component_name if comp else None)
or (plan_ing or {}).get("name")
or (plan_ing or {}).get("originalName")
or (ing.name if ing else None)
or ""
)
base_kg = _base_kg(recipe, ing) if ing else float((comp.target_weight if comp else 0) or 0)
skipped_today = False
if plan_ing:
skipped_today = bool(plan_ing.get("skippedToday"))
if skipped_today:
plan_today_kg = 0.0
base_kg = float(plan_ing.get("baselineTotalKg") or base_kg)
else:
plan_today_kg = float(plan_ing.get("totalKg") or 0)
elif ing and ing.id in skipped_ingredients:
skipped_today = True
plan_today_kg = 0.0
elif comp is not None:
plan_today_kg = float(comp.target_weight or base_kg)
else:
plan_today_kg = 0.0
actual_kg = float(comp.actual_weight or 0) if comp is not None else 0.0
notes, fault = _build_deviation_notes(
base_kg=base_kg,
plan_today_kg=plan_today_kg,
actual_kg=actual_kg,
report_date=report_date,
skipped_today=skipped_today,
)
ingredient_id = None
if plan_ing:
ingredient_id = plan_ing.get("id")
elif ing:
ingredient_id = ing.id
return {
"componentId": cid,
"ingredientId": ingredient_id,
"name": cname,
"baseKg": round(base_kg, 2),
"planTodayKg": round(plan_today_kg, 2),
"actualKg": round(actual_kg, 2),
"skippedToday": skipped_today,
"loadingOrder": int(comp.loading_order or 0) if comp is not None else None,
"notes": notes,
"fault": fault,
}
def _match_plan_unloading_group(
trip: Optional[Dict[str, Any]],
*,
group_id: Optional[str],
group_name: str,
) -> Optional[Dict[str, Any]]:
if not trip:
return None
name_lo = (group_name or "").strip().lower()
for grp in trip.get("unloadingGroups") or []:
if group_id and grp.get("id") == group_id:
return grp
grp_name = str(grp.get("name") or "").strip().lower()
if name_lo and grp_name == name_lo:
return grp
return None
def _baseline_unloading_weights(
recipe: Recipe,
*,
cache: Dict[str, Dict[str, float]],
) -> Dict[str, float]:
rid = recipe.id
if rid in cache:
return cache[rid]
groups = list(
db.session.execute(
select(UnloadingGroup)
.where(UnloadingGroup.recipe_id == rid, UnloadingGroup.is_deleted.is_(False))
.order_by(UnloadingGroup.order.asc())
).scalars().all()
)
if not groups:
cache[rid] = {}
return cache[rid]
ingredients = list(
db.session.execute(
select(Ingredient)
.where(Ingredient.recipe_id == rid, Ingredient.is_deleted.is_(False))
.order_by(Ingredient.order.asc())
).scalars().all()
)
comp_ids = [str(i.component_id) for i in ingredients if i.component_id]
components = (
db.session.execute(
select(Component).where(
Component.id.in_(set(comp_ids)),
Component.is_deleted.is_(False),
)
).scalars().all()
if comp_ids
else []
)
dm_map = {str(c.id): float(c.dry_matter or 0) for c in components}
calc_inputs = [
{
"weightPerHead": float(ing.weight_per_head or 0),
"dryMatter": float(ing.dry_matter or dm_map.get(str(ing.component_id or ""), 0) or 0),
"component_id": ing.component_id,
}
for ing in ingredients
]
calc_groups = [
{
"distributionType": g.distribution_type or "percent",
"value": float(g.value or 0),
}
for g in groups
]
result = calculate_recipe(
calc_inputs,
heads_count=int(recipe.heads_per_trip or 0),
trip_percent=float(recipe.trip_percent or 100),
unloading_groups=calc_groups,
component_dry_matter_map=dm_map,
)
calc_unloading = result.get("unloadingGroups") or []
out: Dict[str, float] = {}
for idx, grp in enumerate(groups):
if idx < len(calc_unloading):
out[grp.id] = float(calc_unloading[idx].get("calculatedWeight") or 0)
cache[rid] = out
return out
def _unloading_group_row(
*,
plan_grp: Optional[Dict[str, Any]],
report_grp: Optional[UnloadingReportGroup],
base_kg: float,
report_date: date,
) -> Dict[str, Any]:
gid = plan_grp.get("id") if plan_grp else None
name = str(
(report_grp.name if report_grp is not None else None)
or (plan_grp or {}).get("name")
or ""
)
skipped_today = bool((plan_grp or {}).get("skippedToday"))
if plan_grp is not None:
plan_today_kg = 0.0 if skipped_today else float(plan_grp.get("weightKg") or 0)
elif report_grp is not None:
plan_today_kg = float(report_grp.target_weight or base_kg)
else:
plan_today_kg = 0.0
actual_kg = float(report_grp.unloaded_weight or 0) if report_grp is not None else 0.0
notes, fault = _build_deviation_notes(
base_kg=base_kg,
plan_today_kg=plan_today_kg,
actual_kg=actual_kg,
report_date=report_date,
skipped_today=skipped_today,
phase="unloading",
)
return {
"groupId": gid,
"name": name,
"baseKg": round(base_kg, 2),
"planTodayKg": round(plan_today_kg, 2),
"actualKg": round(actual_kg, 2),
"skippedToday": skipped_today,
"unloadingOrder": int(report_grp.order or 0) if report_grp is not None else None,
"notes": notes,
"fault": fault,
}
def _mixer_remainder_row(
*,
remaining_kg: float,
report_date: date,
) -> Dict[str, Any]:
base_kg = 0.0
plan_today_kg = 0.0
actual_kg = float(remaining_kg or 0)
notes: List[Dict[str, str]] = []
fault = "none"
if abs(actual_kg) > _EXEC_TOLERANCE_KG:
ds = report_date.strftime("%d.%m")
notes.append(
{
"text": f"остаток в миксере {round(actual_kg, 1):+g} кг ({ds})",
"kind": "execution",
}
)
fault = "execution"
return {
"name": "Остаток в миксере",
"baseKg": round(base_kg, 2),
"planTodayKg": round(plan_today_kg, 2),
"actualKg": round(actual_kg, 2),
"notes": notes,
"fault": fault,
}
def _build_unloading_section(
*,
trip: Optional[Dict[str, Any]],
unloading_report: Optional[UnloadingReport],
report_groups: List[UnloadingReportGroup],
baseline_by_group: Dict[str, float],
report_date: date,
) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
matched_plan_ids: set = set()
groups_out: List[Dict[str, Any]] = []
for report_grp in report_groups:
plan_grp = _match_plan_unloading_group(
trip,
group_id=None,
group_name=str(report_grp.name or ""),
)
if plan_grp and plan_grp.get("id"):
matched_plan_ids.add(plan_grp["id"])
base_kg = float(baseline_by_group.get(plan_grp.get("id") if plan_grp else "", 0) or 0)
if base_kg <= 0 and report_grp is not None:
base_kg = float(report_grp.target_weight or 0)
groups_out.append(
_unloading_group_row(
plan_grp=plan_grp,
report_grp=report_grp,
base_kg=base_kg,
report_date=report_date,
)
)
if trip:
report_names_lo = {str(g.name or "").strip().lower() for g in report_groups}
for plan_grp in trip.get("unloadingGroups") or []:
if not plan_grp.get("skippedToday"):
continue
pid = plan_grp.get("id")
if pid and pid in matched_plan_ids:
continue
pname = str(plan_grp.get("name") or "").strip().lower()
if pname and pname in report_names_lo:
continue
if pid:
matched_plan_ids.add(pid)
base_kg = float(baseline_by_group.get(pid or "", 0) or 0)
groups_out.append(
_unloading_group_row(
plan_grp=plan_grp,
report_grp=None,
base_kg=base_kg,
report_date=report_date,
)
)
if not report_groups:
for plan_grp in trip.get("unloadingGroups") or []:
pid = plan_grp.get("id")
if pid and pid in matched_plan_ids:
continue
if pid:
matched_plan_ids.add(pid)
base_kg = float(baseline_by_group.get(pid or "", 0) or 0)
groups_out.append(
_unloading_group_row(
plan_grp=plan_grp,
report_grp=None,
base_kg=base_kg,
report_date=report_date,
)
)
def _sort_key(row: Dict[str, Any]) -> tuple:
order = row.get("unloadingOrder")
if order is not None:
return (order, row.get("name") or "")
return (10_000, row.get("name") or "")
groups_out.sort(key=_sort_key)
mixer = None
if unloading_report is not None:
mixer = _mixer_remainder_row(
remaining_kg=float(unloading_report.remaining_weight or 0),
report_date=report_date,
)
return groups_out, mixer
def build_plan_fact_rows(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
recipe_id: Optional[str] = None,
recipe_ids: Optional[str] = None,
client_id: Optional[str] = None,
) -> Dict[str, Any]:
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
q = select(LoadingReport).where(
LoadingReport.is_deleted.is_(False),
LoadingReport.start_time >= start_dt,
LoadingReport.start_time <= end_dt,
)
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
if ids:
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
if client_id:
q = q.where(LoadingReport.client_id == client_id)
reports = db.session.execute(q.order_by(LoadingReport.start_time.desc())).scalars().all()
if not reports:
return {"items": [], "reportCount": 0}
report_ids = [r.id for r in reports]
comp_rows = db.session.execute(
select(LoadingReportComponent).where(
LoadingReportComponent.report_id.in_(report_ids),
LoadingReportComponent.is_deleted.is_(False),
).order_by(LoadingReportComponent.loading_order.asc())
).scalars().all()
comps_by_report: Dict[str, List[LoadingReportComponent]] = {}
for c in comp_rows:
comps_by_report.setdefault(c.report_id, []).append(c)
recipe_ids = {r.recipe_id for r in reports}
recipes = {
r.id: r
for r in db.session.execute(
select(Recipe).where(Recipe.id.in_(recipe_ids), Recipe.is_deleted.is_(False))
).scalars().all()
}
ingredients_by_recipe: Dict[str, List[Ingredient]] = {}
for rid in recipe_ids:
ingredients_by_recipe[rid] = list(
db.session.execute(
select(Ingredient)
.where(Ingredient.recipe_id == rid, Ingredient.is_deleted.is_(False))
.order_by(Ingredient.order.asc())
).scalars().all()
)
unloading_reports = db.session.execute(
select(UnloadingReport).where(
UnloadingReport.loading_report_id.in_(report_ids),
UnloadingReport.is_deleted.is_(False),
)
).scalars().all()
unloading_by_loading: Dict[str, UnloadingReport] = {
ur.loading_report_id: ur for ur in unloading_reports
}
unloading_report_ids = [ur.id for ur in unloading_reports]
unloading_groups_rows: List[UnloadingReportGroup] = []
if unloading_report_ids:
unloading_groups_rows = list(
db.session.execute(
select(UnloadingReportGroup)
.where(
UnloadingReportGroup.report_id.in_(unloading_report_ids),
UnloadingReportGroup.is_deleted.is_(False),
)
.order_by(UnloadingReportGroup.order.asc())
).scalars().all()
)
groups_by_unloading: Dict[str, List[UnloadingReportGroup]] = {}
for grp in unloading_groups_rows:
groups_by_unloading.setdefault(grp.report_id, []).append(grp)
plan_cache: Dict[str, Dict[str, Any]] = {}
skip_cache: Dict[str, Dict[str, set]] = {}
baseline_unloading_cache: Dict[str, Dict[str, float]] = {}
items: List[Dict[str, Any]] = []
for report in reports:
recipe = recipes.get(report.recipe_id)
if recipe is None:
continue
report_date = report.start_time.date() if report.start_time else date.today()
iso = report_date.isoformat()
plan = _get_plan_for_date(iso, plan_cache)
if iso not in skip_cache:
skip_cache[iso] = get_skipped_ingredient_ids(iso)
skipped_ingredients = skip_cache[iso].get(report.recipe_id, set())
trip = _find_trip_in_plan(plan, report.recipe_id)
ing_by_cid: Dict[str, Ingredient] = {}
ing_by_name: Dict[str, Ingredient] = {}
ing_by_id: Dict[str, Ingredient] = {}
for ing in ingredients_by_recipe.get(report.recipe_id, []):
ing_by_id[ing.id] = ing
if ing.component_id:
ing_by_cid[str(ing.component_id)] = ing
ing_by_name[(ing.name or "").strip().lower()] = ing
report_comps = comps_by_report.get(report.id, [])
matched_plan_ing_ids: set = set()
components_out: List[Dict[str, Any]] = []
for comp in report_comps:
cid = comp.component_id
cname = str(comp.component_name or "")
ing = None
if cid and str(cid) in ing_by_cid:
ing = ing_by_cid[str(cid)]
elif cname.lower() in ing_by_name:
ing = ing_by_name[cname.lower()]
plan_ing = _match_plan_ingredient(trip, component_id=cid, component_name=cname)
if plan_ing and plan_ing.get("id"):
matched_plan_ing_ids.add(plan_ing["id"])
components_out.append(
_component_row(
recipe=recipe,
ing=ing,
plan_ing=plan_ing,
comp=comp,
report_date=report_date,
skipped_ingredients=skipped_ingredients,
)
)
if trip:
for plan_ing in trip.get("ingredients") or []:
if not plan_ing.get("skippedToday"):
continue
pid = plan_ing.get("id")
if pid and pid in matched_plan_ing_ids:
continue
if any(_plan_ing_matches_report_comp(plan_ing, c) for c in report_comps):
continue
ing = ing_by_id.get(pid) if pid else None
if ing is None:
pcid = plan_ing.get("componentId")
if pcid and str(pcid) in ing_by_cid:
ing = ing_by_cid[str(pcid)]
else:
name_lo = str(
plan_ing.get("originalName") or plan_ing.get("name") or ""
).strip().lower()
ing = ing_by_name.get(name_lo)
if pid:
matched_plan_ing_ids.add(pid)
components_out.append(
_component_row(
recipe=recipe,
ing=ing,
plan_ing=plan_ing,
comp=None,
report_date=report_date,
skipped_ingredients=skipped_ingredients,
)
)
recipe_ings = ingredients_by_recipe.get(report.recipe_id, [])
ing_order = {ing.id: idx for idx, ing in enumerate(recipe_ings)}
def _sort_key(row: Dict[str, Any]) -> tuple:
iid = row.get("ingredientId")
if iid and iid in ing_order:
return (ing_order[iid], 0)
lor = row.get("loadingOrder")
if lor is not None:
return (10_000, lor)
return (20_000, row.get("name") or "")
components_out.sort(key=_sort_key)
unloading_report = unloading_by_loading.get(report.id)
report_unloading_groups = (
groups_by_unloading.get(unloading_report.id, []) if unloading_report else []
)
baseline_by_group = _baseline_unloading_weights(
recipe,
cache=baseline_unloading_cache,
)
unloading_out, mixer_out = _build_unloading_section(
trip=trip,
unloading_report=unloading_report,
report_groups=report_unloading_groups,
baseline_by_group=baseline_by_group,
report_date=report_date,
)
items.append(
{
"loadingReportId": report.id,
"recipeId": report.recipe_id,
"recipeName": report.recipe_name,
"date": iso,
"startTime": report.start_time.isoformat() if report.start_time else None,
"clientId": report.client_id,
"components": components_out,
"unloadingGroups": unloading_out,
"mixerRemainder": mixer_out,
}
)
return {"items": items, "reportCount": len(items)}
@@ -0,0 +1,46 @@
"""Цены компонентов для analytics."""
from __future__ import annotations
from typing import Dict, List, Optional
from sqlalchemy import select
from app import db
from app.models import Component
def component_prices(
*,
component_ids: Optional[List[str]] = None,
names: Optional[List[str]] = None,
) -> Dict[str, float]:
"""component_id или имя -> price руб/кг."""
prices: Dict[str, float] = {}
ids = [x for x in (component_ids or []) if x]
if ids:
rows = db.session.execute(
select(Component).where(
Component.id.in_(ids),
Component.is_deleted.is_(False),
)
).scalars().all()
for row in rows:
prices[row.id] = float(row.price or 0)
for name in names or []:
if not name or name in prices:
continue
row = db.session.execute(
select(Component)
.where(Component.name == name, Component.is_deleted.is_(False))
.limit(1)
).scalar_one_or_none()
if row:
prices[name] = float(row.price or 0)
return prices
def price_for(*, prices: Dict[str, float], component_id: Optional[str], name: str) -> float:
if component_id and component_id in prices:
return prices[component_id]
return prices.get(name, 0.0)
@@ -0,0 +1,17 @@
"""Фильтрация отчётов analytics по recipe_id / recipe_ids."""
from __future__ import annotations
from typing import List, Optional
def parse_recipe_ids(
recipe_id: Optional[str] = None,
recipe_ids: Optional[str] = None,
) -> Optional[List[str]]:
if recipe_id and str(recipe_id).strip():
return [str(recipe_id).strip()]
if not recipe_ids:
return None
ids = [part.strip() for part in str(recipe_ids).split(",") if part.strip()]
return ids or None
@@ -0,0 +1,41 @@
"""Ферма и кормораздатчик по recipe_id для analytics export."""
from __future__ import annotations
from typing import Dict, Iterable, Set
from sqlalchemy import select
from app import db
from app.models import FeedDispenser, FeedingPeriod, PeriodRecipe
def build_recipe_location_maps(
recipe_ids: Iterable[str],
) -> tuple[Dict[str, str], Dict[str, str]]:
"""recipe_id -> farm, recipe_id -> dispenser_name."""
ids = {str(rid) for rid in recipe_ids if rid}
farm_by_recipe: Dict[str, str] = {}
dispenser_by_recipe: Dict[str, str] = {}
if not ids:
return farm_by_recipe, dispenser_by_recipe
rows = db.session.execute(
select(PeriodRecipe.recipe_id, FeedDispenser.farm, FeedDispenser.name)
.join(FeedingPeriod, FeedingPeriod.id == PeriodRecipe.period_id)
.join(FeedDispenser, FeedDispenser.id == FeedingPeriod.dispenser_id)
.where(
PeriodRecipe.recipe_id.in_(ids),
PeriodRecipe.is_deleted.is_(False),
FeedingPeriod.is_deleted.is_(False),
FeedDispenser.is_deleted.is_(False),
)
).all()
for recipe_id, farm, disp_name in rows:
rid = str(recipe_id)
if rid not in farm_by_recipe and farm:
farm_by_recipe[rid] = str(farm).strip()
if rid not in dispenser_by_recipe and disp_name:
dispenser_by_recipe[rid] = str(disp_name).strip()
return farm_by_recipe, dispenser_by_recipe
@@ -0,0 +1,198 @@
"""Детальные строки отчётов загрузки для экспорта."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from app import db
from app.models import (
ComponentLoadingTime,
LoadingReport,
LoadingReportComponent,
UnloadingReport,
UnloadingReportGroup,
)
from app.services.analytics.date_range import resolve_date_bounds
from app.services.analytics.recipe_filter import parse_recipe_ids
from app.services.analytics.recipe_location import build_recipe_location_maps
UNLOADING_FEED_COMPONENT_LABEL = "Выгрузка полного миксера"
_UNLOADING_NAME_PREFIX = "выгрузка:"
def _parse_unloading_group_name(raw: str) -> str:
text = (raw or "").strip()
if text.lower().startswith(_UNLOADING_NAME_PREFIX):
parsed = text[len(_UNLOADING_NAME_PREFIX) :].strip()
return parsed or ""
return text or ""
def _overload_kg(actual: float, target: float) -> float:
"""Перерасход только при факте выше плана; недогруз не дублируем в эту колонку."""
return round(max(0.0, actual - target), 2)
def _dedupe_detail_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Убрать полные дубликаты строк (одинаковый рейс, время, компонент, план и факт)."""
seen: set[tuple[Any, ...]] = set()
out: List[Dict[str, Any]] = []
for row in rows:
key = (
row.get("reportId"),
row.get("kind"),
row.get("feedComponent"),
row.get("animalGroup"),
row.get("dateTime"),
row.get("recipeName"),
row.get("targetKg"),
row.get("actualKg"),
)
if key in seen:
continue
seen.add(key)
out.append(row)
return out
def build_reports_detail_rows(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
recipe_id: Optional[str] = None,
recipe_ids: Optional[str] = None,
) -> List[Dict[str, Any]]:
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
q = select(LoadingReport).where(
LoadingReport.is_deleted.is_(False),
LoadingReport.start_time >= start_dt,
LoadingReport.start_time <= end_dt,
)
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
if ids:
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
reports = db.session.execute(q.order_by(LoadingReport.start_time.desc())).scalars().all()
if not reports:
return []
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
farm_by_recipe, dispenser_by_recipe = build_recipe_location_maps(recipe_ids_set)
report_ids = [r.id for r in reports]
components = db.session.execute(
select(LoadingReportComponent).where(
LoadingReportComponent.report_id.in_(report_ids),
LoadingReportComponent.is_deleted.is_(False),
).order_by(LoadingReportComponent.loading_order.asc())
).scalars().all()
loading_times = db.session.execute(
select(ComponentLoadingTime).where(
ComponentLoadingTime.report_id.in_(report_ids),
ComponentLoadingTime.is_deleted.is_(False),
)
).scalars().all()
unloading_rows = db.session.execute(
select(UnloadingReport).where(
UnloadingReport.loading_report_id.in_(report_ids),
UnloadingReport.is_deleted.is_(False),
)
).scalars().all()
comps_by_report: Dict[str, List[LoadingReportComponent]] = {}
for c in components:
comps_by_report.setdefault(c.report_id, []).append(c)
load_sec_by_report_name: Dict[str, Dict[str, float]] = {}
for t in loading_times:
if not t.report_id or not t.component_name:
continue
load_sec_by_report_name.setdefault(t.report_id, {})[
str(t.component_name)
] = float(t.loading_duration or 0)
unloading_by_loading = {u.loading_report_id: u for u in unloading_rows}
unloading_ids = [u.id for u in unloading_rows]
unloading_groups: List[UnloadingReportGroup] = []
if unloading_ids:
unloading_groups = list(
db.session.execute(
select(UnloadingReportGroup).where(
UnloadingReportGroup.report_id.in_(unloading_ids),
UnloadingReportGroup.is_deleted.is_(False),
).order_by(UnloadingReportGroup.order.asc())
).scalars().all()
)
groups_by_unloading: Dict[str, List[UnloadingReportGroup]] = {}
for g in unloading_groups:
groups_by_unloading.setdefault(g.report_id, []).append(g)
rows: List[Dict[str, Any]] = []
for report in reports:
when = report.start_time.strftime("%d.%m.%Y %H:%M") if report.start_time else ""
date_day = report.start_time.strftime("%d.%m.%Y") if report.start_time else ""
farm = farm_by_recipe.get(report.recipe_id, "")
dispenser = dispenser_by_recipe.get(report.recipe_id, "")
mix_plan = int(report.target_mixing_time or 0)
mix_fact = int(report.actual_mixing_time or 0)
load_secs = load_sec_by_report_name.get(report.id, {})
for comp in comps_by_report.get(report.id, []):
target = float(comp.target_weight or 0)
actual = float(comp.actual_weight or 0)
dev = round(actual - target, 2)
cname = str(comp.component_name or "")
rows.append(
{
"kind": "component",
"reportId": report.id,
"dateDay": date_day,
"farm": farm,
"dispenserName": dispenser,
"recipeName": report.recipe_name,
"dateTime": when,
"feedComponent": cname,
"animalGroup": "",
"name": cname,
"targetKg": round(target, 2),
"actualKg": round(actual, 2),
"deviationKg": dev,
"overloadKg": _overload_kg(actual, target),
"loadingSec": round(load_secs.get(cname, 0), 1),
"mixPlanSec": mix_plan,
"mixFactSec": mix_fact,
}
)
unloading = unloading_by_loading.get(report.id)
if unloading:
for group in groups_by_unloading.get(unloading.id, []):
target = float(group.target_weight or 0)
actual = float(group.unloaded_weight or 0)
group_name = _parse_unloading_group_name(str(group.name or ""))
rows.append(
{
"kind": "unloading",
"reportId": report.id,
"dateDay": date_day,
"farm": farm,
"dispenserName": dispenser,
"recipeName": report.recipe_name,
"dateTime": when,
"feedComponent": UNLOADING_FEED_COMPONENT_LABEL,
"animalGroup": group_name,
"name": f"выгрузка: {group_name}",
"targetKg": round(target, 2),
"actualKg": round(actual, 2),
"deviationKg": round(actual - target, 2),
"overloadKg": _overload_kg(actual, target),
"loadingSec": None,
"mixPlanSec": mix_plan,
"mixFactSec": mix_fact,
}
)
rows = _dedupe_detail_rows(rows)
rows.sort(key=lambda r: (r.get("dateDay") or "", r.get("recipeName") or "", r.get("name") or ""))
return rows
@@ -0,0 +1,217 @@
"""Прогноз запаса: хватит по рецепту vs с учётом плана и факта."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Dict, List, Optional, Tuple
from app import db
from app.models import Ingredient, LoadingReport, LoadingReportComponent, Recipe
from app.services.daily_plan.builder import ALL_DISPENSERS_ID, build_daily_plan
from app.services.sklad_metrics import (
consumed_by_component_id,
list_stock_balance_items,
stock_item_key,
)
_CRITICAL_DAYS = 2.0
def _plan_kg_by_component(plan_date: str) -> Dict[str, float]:
plan = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date=plan_date)
out: Dict[str, float] = {}
for period in plan.get("periods") or []:
for trip in period.get("trips") or []:
for ing in trip.get("ingredients") or []:
if ing.get("skippedToday"):
continue
cid = ing.get("replacementComponentId") or ing.get("componentId")
if not cid:
continue
out[str(cid)] = out.get(str(cid), 0.0) + float(ing.get("totalKg") or 0)
return {k: round(v, 2) for k, v in out.items()}
def format_days_label(days: Optional[float]) -> str:
if days is None:
return ""
if days < 1:
hours = round(days * 24)
return f"~{hours} ч"
d = round(days, 1)
if d == int(d):
return f"~{int(d)} дн"
return f"~{d} дн"
def _days_left(remaining_kg: float, daily_kg: float) -> Optional[float]:
if daily_kg <= 0 or remaining_kg <= 0:
return None
d = remaining_kg / daily_kg
return round(min(d, 9999.9), 1) if d < 9999.9 else 9999.9
def enrich_stock_balance_item(
item: Dict[str, Any],
*,
plan_by_cid: Dict[str, float],
consumed_period: Dict[str, float],
lookback_days: int,
plan_d: date,
) -> Tuple[Dict[str, Any], Optional[str]]:
"""Добавляет прогноз «хватит с учётом плана» к строке склада."""
cid = str(item.get("component_id") or "").strip()
name = str(item.get("name") or "")
remaining = float(item.get("remaining_kg") or 0)
plan_recipe = float(item.get("planned_consumption_per_day_kg") or 0)
plan_today = plan_by_cid.get(cid, plan_recipe) if cid else plan_recipe
period_kg = consumed_period.get(cid, 0.0) if cid else 0.0
if period_kg <= 0:
period_kg = float(item.get("consumed_total_kg") or item.get("consumed_kg") or 0)
avg_daily = round(period_kg / max(1, lookback_days), 2) if period_kg > 0 else 0.0
if avg_daily > 0:
expected_daily = max(avg_daily, plan_today)
else:
expected_daily = plan_today if plan_today > 0 else plan_recipe
days_recipe = item.get("days_left_plan")
days_adjusted = _days_left(remaining, expected_daily)
explanation_parts: List[str] = []
if plan_today < plan_recipe - 0.01 and plan_recipe > 0:
explanation_parts.append(
f"убрали из плана на {plan_d.strftime('%d.%m')} → расход меньше на "
f"{round(plan_recipe - plan_today, 1)} кг/сут"
)
if avg_daily > plan_recipe * 1.05 and plan_recipe > 0:
explanation_parts.append(
f"расход выше плана: ~{avg_daily} кг/сут за {lookback_days} дн"
)
explanation = ""
if explanation_parts:
base = " · ".join(explanation_parts)
if days_recipe is not None and days_adjusted is not None and days_adjusted != days_recipe:
delta = round(days_adjusted - days_recipe, 1)
sign = "+" if delta > 0 else ""
explanation = f"{base} → запас {sign}{delta} дн"
else:
explanation = base
row = dict(item)
row.update(
{
"forecast_key": stock_item_key(item),
"planTodayKgPerDay": round(plan_today, 2),
"avgConsumptionKgPerDay": avg_daily,
"expectedDailyKg": round(expected_daily, 2),
"days_left_adjusted": days_adjusted,
"days_left_adjusted_label": format_days_label(days_adjusted),
"daysLeftAdjusted": days_adjusted,
"daysLeftAdjustedLabel": format_days_label(days_adjusted),
"daysLeftRecipeLabel": format_days_label(days_recipe),
"explanation": explanation,
"removedFromPlanToday": plan_today < plan_recipe - 0.01 and plan_recipe > 0,
}
)
alert: Optional[str] = None
if days_adjusted is not None and days_adjusted <= _CRITICAL_DAYS:
alert = f"{name} — хватит {format_days_label(days_adjusted)}"
elif row["removedFromPlanToday"] and days_adjusted and days_recipe:
if days_adjusted > days_recipe + 0.3:
alert = (
f"{name} — запас {format_days_label(days_adjusted)} "
f"(убрали из плана на сегодня)"
)
return row, alert
def enrich_stock_balance_items(
items: List[Dict[str, Any]],
*,
plan_date: Optional[str] = None,
lookback_days: int = 7,
) -> List[Dict[str, Any]]:
today = date.today()
iso = (plan_date or today.isoformat())[:10]
try:
plan_d = date.fromisoformat(iso)
except ValueError:
plan_d = today
iso = today.isoformat()
from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat()
to_d = plan_d.isoformat()
plan_by_cid = _plan_kg_by_component(iso)
consumed_period = consumed_by_component_id(
db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d
)
enriched: List[Dict[str, Any]] = []
for item in items:
row, _ = enrich_stock_balance_item(
item,
plan_by_cid=plan_by_cid,
consumed_period=consumed_period,
lookback_days=lookback_days,
plan_d=plan_d,
)
enriched.append(row)
return enriched
def build_stock_forecast(
*,
plan_date: Optional[str] = None,
lookback_days: int = 7,
Component=None,
) -> Dict[str, Any]:
today = date.today()
iso = (plan_date or today.isoformat())[:10]
try:
plan_d = date.fromisoformat(iso)
except ValueError:
plan_d = today
iso = today.isoformat()
from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat()
to_d = plan_d.isoformat()
items = list_stock_balance_items(
db,
LoadingReport,
LoadingReportComponent,
Ingredient,
Recipe,
date_from=from_d,
date_to=to_d,
Component=Component,
)
plan_by_cid = _plan_kg_by_component(iso)
consumed_period = consumed_by_component_id(
db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d
)
enriched: List[Dict[str, Any]] = []
alerts: List[str] = []
for item in items:
row, alert = enrich_stock_balance_item(
item,
plan_by_cid=plan_by_cid,
consumed_period=consumed_period,
lookback_days=lookback_days,
plan_d=plan_d,
)
enriched.append(row)
if alert:
alerts.append(alert)
return {
"planDate": iso,
"items": enriched,
"alerts": alerts,
"alertBanner": " · ".join(alerts[:5]) if alerts else "",
}
@@ -0,0 +1,115 @@
"""Строки листа «Итоги» для Excel (экономист)."""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from app import db
from app.models import LoadingReport, LoadingReportComponent
from app.services.analytics.date_range import resolve_date_bounds
from app.services.analytics.finance_summary import _report_filters
from app.services.analytics.prices import component_prices, price_for
from app.services.analytics.recipe_filter import parse_recipe_ids
from app.services.analytics.recipe_location import build_recipe_location_maps
def build_summary_table_rows(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
recipe_id: Optional[str] = None,
recipe_ids: Optional[str] = None,
) -> List[Dict[str, Any]]:
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
reports = db.session.execute(
_report_filters(start_dt=start_dt, end_dt=end_dt, recipe_ids=ids)
).scalars().all()
if not reports:
return []
report_by_id = {r.id: r for r in reports}
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
farm_by_recipe, _ = build_recipe_location_maps(recipe_ids_set)
report_ids = [r.id for r in reports]
components = db.session.execute(
select(LoadingReportComponent).where(
LoadingReportComponent.report_id.in_(report_ids),
LoadingReportComponent.is_deleted.is_(False),
)
).scalars().all()
prices = component_prices(
component_ids=[c.component_id for c in components if c.component_id],
names=[c.component_name for c in components],
)
agg: Dict[tuple[str, str], Dict[str, Any]] = defaultdict(
lambda: {
"farm": "",
"component": "",
"planKg": 0.0,
"factKg": 0.0,
"pricePerKg": 0.0,
"overloadRub": 0.0,
"underloadRub": 0.0,
}
)
for comp in components:
report = report_by_id.get(comp.report_id)
if not report:
continue
farm = farm_by_recipe.get(report.recipe_id, "")
cname = str(comp.component_name or "")
key = (farm, cname)
row = agg[key]
row["farm"] = farm
row["component"] = cname
target = float(comp.target_weight or 0)
actual = float(comp.actual_weight or 0)
if target <= 0 and actual <= 0:
continue
price = price_for(
prices=prices,
component_id=comp.component_id,
name=cname,
)
row["planKg"] += target
row["factKg"] += actual
row["pricePerKg"] = price
dev_kg = actual - target
dev_rub = dev_kg * price
if dev_rub > 0:
row["overloadRub"] += dev_rub
elif dev_rub < 0:
row["underloadRub"] += abs(dev_rub)
out: List[Dict[str, Any]] = []
for row in agg.values():
if row["planKg"] <= 0 and row["factKg"] <= 0:
continue
plan = round(row["planKg"], 2)
fact = round(row["factKg"], 2)
out.append(
{
"farm": row["farm"],
"component": row["component"],
"planKg": plan,
"factKg": fact,
"deviationKg": round(fact - plan, 2),
"pricePerKg": round(row["pricePerKg"], 2),
"overloadRub": round(row["overloadRub"], 2),
"underloadRub": round(row["underloadRub"], 2),
}
)
out.sort(key=lambda r: (r["farm"], r["component"]))
return out
@@ -0,0 +1,402 @@
"""Excel: итоги + сравнение + подробно."""
from __future__ import annotations
import io
from typing import Any, Optional
from openpyxl import Workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from app.services.analytics.export_context import (
build_export_header_lines,
net_rub_display,
net_rub_dominant_hint,
)
from app.services.analytics.export_sections import parse_export_sections
from app.services.analytics.finance_summary import build_finance_summary
from app.services.analytics.plan_fact_layers import build_plan_fact_rows
from app.services.analytics.reports_detail import build_reports_detail_rows
from app.services.analytics.summary_export_rows import build_summary_table_rows
_OVERLOAD_FILL = PatternFill(start_color="FFEBEE", end_color="FFEBEE", fill_type="solid")
_UNDERLOAD_FILL = PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid")
_HEADER_FONT = Font(bold=True)
def _apply_row_tint(ws: Worksheet, row: int, cols: int, *, overload_rub: float, underload_rub: float) -> None:
fill = None
if overload_rub > 0 and underload_rub <= 0:
fill = _OVERLOAD_FILL
elif underload_rub > 0 and overload_rub <= 0:
fill = _UNDERLOAD_FILL
elif overload_rub > 0 and underload_rub > 0:
fill = _OVERLOAD_FILL if overload_rub >= underload_rub else _UNDERLOAD_FILL
if not fill:
return
for col in range(1, cols + 1):
ws.cell(row=row, column=col).fill = fill
def _cell_width_estimate(value: Any) -> float:
if value is None:
return 0.0
total = 0.0
for ch in str(value):
total += 1.35 if ord(ch) > 127 else 1.0
return total
def _auto_fit_columns(
ws: Worksheet,
*,
min_width: float = 10,
max_width: float = 64,
padding: float = 3.0,
) -> None:
if ws.max_row < 1:
return
max_col = ws.max_column or 1
for col_idx in range(1, max_col + 1):
max_len = 0.0
for row in ws.iter_rows(min_col=col_idx, max_col=col_idx, min_row=1, max_row=ws.max_row):
max_len = max(max_len, _cell_width_estimate(row[0].value))
if max_len <= 0:
continue
letter = get_column_letter(col_idx)
ws.column_dimensions[letter].width = min(max(max_len + padding, min_width), max_width)
def _style_table_header_row(ws: Worksheet, row: int, cols: int) -> None:
for col in range(1, cols + 1):
cell = ws.cell(row=row, column=col)
cell.font = _HEADER_FONT
cell.alignment = Alignment(wrap_text=True, vertical="center")
# Процентная ширина столбцов листа «Подробно» (сумма = 100).
# Текстовые колонки шире для переноса; числовые — фиксированный минимум.
_DETAIL_SHEET_COL_PCT = [
7, # Дата
14, # Ферма
10, # Кормораздатчик
14, # Рейс
10, # Время
6, # Тип
12, # Компонент
9, # Группа
8, # План
8, # Факт
8, # Отклонение
8, # Перерасход
6, # Загрузка
6, # Смешивание план
6, # Смешивание факт
]
_DETAIL_SHEET_WRAP_COLS = frozenset({2, 3, 4, 5, 7, 8})
_DETAIL_SHEET_BASE_WIDTH = 118.0
def _detail_sheet_col_widths() -> list[float]:
scale = _DETAIL_SHEET_BASE_WIDTH / 100.0
return [pct * scale for pct in _DETAIL_SHEET_COL_PCT]
def _apply_detail_sheet_layout(ws: Worksheet, *, header_row: int, last_row: int, col_count: int) -> None:
wrap_align = Alignment(wrap_text=True, vertical="top")
num_align = Alignment(vertical="top")
for col_idx, width in enumerate(_detail_sheet_col_widths(), start=1):
if col_idx > col_count:
break
ws.column_dimensions[get_column_letter(col_idx)].width = width
for row_idx in range(header_row, last_row + 1):
for col_idx in range(1, col_count + 1):
cell = ws.cell(row=row_idx, column=col_idx)
cell.alignment = wrap_align if col_idx in _DETAIL_SHEET_WRAP_COLS else num_align
def _write_export_header(
ws: Worksheet,
*,
date_from: str,
date_to: str,
filter_farms: Optional[str],
filter_dispensers: Optional[str],
filter_recipes: Optional[str],
) -> None:
for line in build_export_header_lines(
date_from=date_from,
date_to=date_to,
filter_farms=filter_farms,
filter_dispensers=filter_dispensers,
filter_recipes=filter_recipes,
):
ws.append([line])
def _fill_summary_sheet(
ws: Worksheet,
*,
summary: dict[str, Any],
table_rows: list[dict[str, Any]],
date_from: str,
date_to: str,
filter_farms: Optional[str],
filter_dispensers: Optional[str],
filter_recipes: Optional[str],
) -> None:
ws.title = "Итоги"
_write_export_header(
ws,
date_from=date_from,
date_to=date_to,
filter_farms=filter_farms,
filter_dispensers=filter_dispensers,
filter_recipes=filter_recipes,
)
ws.append([])
ws.append(["Перерасход, ₽", summary.get("overloadRub", 0)])
ws.append(["Недогруз, ₽", summary.get("underloadRub", 0)])
ws.append(["Итого по деньгам, ₽", net_rub_display(summary)])
ws.append([net_rub_dominant_hint(summary)])
ws.append([])
headers = [
"Ферма",
"Компонент",
"План, кг",
"Факт, кг",
"Отклонение, кг",
"Цена за кг, ₽",
"Перерасход, ₽",
"Недогруз, ₽",
]
ws.append(headers)
header_row = ws.max_row
_style_table_header_row(ws, header_row, len(headers))
for row in table_rows:
ws.append(
[
row.get("farm"),
row.get("component"),
row.get("planKg"),
row.get("factKg"),
row.get("deviationKg"),
row.get("pricePerKg"),
row.get("overloadRub"),
row.get("underloadRub"),
]
)
_apply_row_tint(
ws,
ws.max_row,
len(headers),
overload_rub=float(row.get("overloadRub") or 0),
underload_rub=float(row.get("underloadRub") or 0),
)
def _fill_comparison_sheet(ws: Worksheet, plan_fact: dict[str, Any]) -> None:
ws.title = "Сравнение"
headers = [
"Рейс",
"Дата",
"Компонент",
"По рецепту (База), кг",
"На сегодня (План), кг",
"Сделали (Факт), кг",
"Разница База/Факт, кг",
"Разница План/Факт, кг",
"Статус",
]
ws.append(headers)
header_row = ws.max_row
_style_table_header_row(ws, header_row, len(headers))
status_col = len(headers)
status_col_letter = get_column_letter(status_col)
for item in plan_fact.get("items") or []:
for comp in item.get("components") or []:
base_kg = float(comp.get("baseKg") or 0)
plan_kg = float(comp.get("planTodayKg") or 0)
fact_kg = float(comp.get("actualKg") or 0)
fault = comp.get("fault") or "none"
status = {
"execution": "Ошибка при загрузке",
"excluded": "Исключено из плана",
"adjusted": "План скорректирован",
}.get(fault, "")
ws.append(
[
item.get("recipeName"),
item.get("date"),
comp.get("name"),
round(base_kg, 2),
round(plan_kg, 2),
round(fact_kg, 2),
round(fact_kg - base_kg, 2),
round(fact_kg - plan_kg, 2),
status,
]
)
row_num = ws.max_row
if fault == "execution":
for col in range(1, len(headers) + 1):
ws.cell(row=row_num, column=col).fill = _OVERLOAD_FILL
elif fault in ("adjusted", "excluded"):
for col in range(1, len(headers) + 1):
ws.cell(row=row_num, column=col).fill = _UNDERLOAD_FILL
if ws.max_row > header_row:
ws.conditional_formatting.add(
f"{status_col_letter}{header_row + 1}:{status_col_letter}{ws.max_row}",
FormulaRule(
formula=[f'{status_col_letter}{header_row + 1}="Ошибка при загрузке"'],
fill=_OVERLOAD_FILL,
),
)
_auto_fit_columns(ws, min_width=12, max_width=70, padding=3.5)
def _fill_detail_sheet(ws: Worksheet, detail_rows: list[dict[str, Any]]) -> None:
ws.title = "Подробно"
headers = [
"Дата",
"Ферма",
"Кормораздатчик",
"Рейс",
"Время",
"Тип",
"Компонент",
"Группа",
"План, кг",
"Факт, кг",
"Отклонение, кг",
"Перерасход, кг",
"Загрузка, с",
"Смешивание план, с",
"Смешивание факт, с",
]
ws.append(headers)
_style_table_header_row(ws, 1, len(headers))
for row in detail_rows:
dev = float(row.get("deviationKg") or 0)
target = float(row.get("targetKg") or 0)
actual = float(row.get("actualKg") or 0)
overload_kg = max(0.0, round(actual - target, 2))
is_unloading = row.get("kind") == "unloading"
values = [
row.get("dateDay"),
row.get("farm"),
row.get("dispenserName"),
row.get("recipeName"),
row.get("dateTime"),
"Выгрузка" if is_unloading else "Загрузка",
row.get("feedComponent") if is_unloading else (row.get("feedComponent") or ""),
row.get("animalGroup") if is_unloading else "",
round(target, 2),
round(actual, 2),
round(dev, 2),
overload_kg if overload_kg > 0 else None,
row.get("loadingSec"),
row.get("mixPlanSec"),
row.get("mixFactSec"),
]
row_num = ws.max_row + 1
for col_idx, value in enumerate(values, start=1):
ws.cell(row=row_num, column=col_idx, value=value)
if dev > 0.05:
_apply_row_tint(ws, row_num, len(headers), overload_rub=1, underload_rub=0)
elif dev < -0.05:
_apply_row_tint(ws, row_num, len(headers), overload_rub=0, underload_rub=1)
_apply_detail_sheet_layout(
ws,
header_row=1,
last_row=ws.max_row,
col_count=len(headers),
)
def build_finance_summary_xlsx(
*,
date_from: str,
date_to: str,
recipe_id: str | None = None,
recipe_ids: str | None = None,
section: str | None = None,
filter_farms: str | None = None,
filter_dispensers: str | None = None,
filter_recipes: str | None = None,
) -> bytes:
sections = parse_export_sections(section)
summary = None
table_rows: list[dict[str, Any]] = []
plan_fact = None
detail_rows = None
if "summary" in sections:
summary = build_finance_summary(
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
)
table_rows = build_summary_table_rows(
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
)
if "comparison" in sections:
plan_fact = build_plan_fact_rows(
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
)
if "reports" in sections:
detail_rows = build_reports_detail_rows(
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
)
wb = Workbook()
wb.remove(wb.active)
if "summary" in sections:
ws = wb.create_sheet("Итоги")
_fill_summary_sheet(
ws,
summary=summary or {},
table_rows=table_rows,
date_from=date_from,
date_to=date_to,
filter_farms=filter_farms,
filter_dispensers=filter_dispensers,
filter_recipes=filter_recipes,
)
if "comparison" in sections:
ws = wb.create_sheet("Сравнение")
_fill_comparison_sheet(ws, plan_fact or {})
if "reports" in sections:
ws = wb.create_sheet("Подробно")
_fill_detail_sheet(ws, detail_rows or [])
if not wb.sheetnames:
ws = wb.create_sheet("Итоги")
_fill_summary_sheet(
ws,
summary={},
table_rows=[],
date_from=date_from,
date_to=date_to,
filter_farms=filter_farms,
filter_dispensers=filter_dispensers,
filter_recipes=filter_recipes,
)
for ws_cur in wb.worksheets:
if ws_cur.title == "Подробно":
continue
_auto_fit_columns(ws_cur)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
+12 -1
View File
@@ -154,7 +154,18 @@ def _apply_fields(row, data: dict[str, Any], model) -> None:
elif key not in ("enterprise_id", "created_at", "id"):
extra[key] = value
if model is ZootechRecipe and "ingredients" in data:
row.payload_json = json.dumps(data.get("ingredients"), ensure_ascii=False)
preserved: dict[str, Any] = {}
if row.payload_json:
try:
existing = json.loads(row.payload_json)
if isinstance(existing, dict) and existing.get("dispenser_id"):
preserved["dispenser_id"] = existing["dispenser_id"]
except json.JSONDecodeError:
pass
if preserved:
row.payload_json = json.dumps(preserved, ensure_ascii=False)
else:
row.payload_json = json.dumps(data.get("ingredients"), ensure_ascii=False)
elif extra and "payload_json" in allowed:
row.payload_json = json.dumps(extra, ensure_ascii=False)
@@ -141,6 +141,18 @@ class ZootechDailyComponentNormAdjustment(_CatalogMixin, Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
class ZootechSkladStock(_CatalogMixin, Base):
__tablename__ = "zootech_sklad_stock"
__table_args__ = (
UniqueConstraint("enterprise_id", "component_id", name="uq_zootech_sklad_stock_ent_comp"),
)
component_id: Mapped[str] = mapped_column(String(36), primary_key=True)
total_kg: Mapped[float] = mapped_column(Float, nullable=False, default=0)
inflow_kg: Mapped[float] = mapped_column(Float, nullable=False, default=0)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
CATALOG_TABLE_ORDER = [
"component",
"recipe",
@@ -0,0 +1 @@
"""План на день — агрегат периодов и рейсов."""
@@ -0,0 +1,328 @@
"""Временная правка норм по компонентам в плане на день."""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, List, Optional, Set
from sqlalchemy import select
from app import db
from app.models import Component, DailyComponentNormAdjustment, Ingredient, Recipe
from app.services.daily_plan.ingredient_weights import (
ingredient_dry_matter_per_head,
ingredient_dry_matter_percent,
)
from app.services.daily_plan.skips import (
_parse_plan_date,
_serialize_skip_dates,
_skip_active_filters,
_soft_unskip_part,
_upsert_part_skip,
get_skipped_ingredient_ids,
resolve_skip_range,
)
def get_component_adjustment_map(
plan_date: Optional[str] = None,
) -> Dict[str, Dict[str, Optional[float]]]:
"""component_id -> {dry_matter, dry_matter_locked, weight_per_head, dry_matter_per_head}."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(
DailyComponentNormAdjustment.component_id,
DailyComponentNormAdjustment.dry_matter,
DailyComponentNormAdjustment.dry_matter_locked,
DailyComponentNormAdjustment.weight_per_head,
DailyComponentNormAdjustment.dry_matter_per_head,
).where(*_skip_active_filters(DailyComponentNormAdjustment, d))
).all()
out: Dict[str, Dict[str, Optional[float]]] = {}
for cid, dm, locked, wph, dm_ph in rows:
out[str(cid)] = {
"dry_matter": float(dm) if dm is not None else None,
"dry_matter_locked": bool(locked),
"weight_per_head": float(wph) if wph is not None else None,
"dry_matter_per_head": float(dm_ph) if dm_ph is not None else None,
}
return out
def get_adjusted_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
"""Рейсы с хотя бы одним ингредиентом с активной правкой нормы."""
d = _parse_plan_date(plan_date)
adj_map = get_component_adjustment_map(plan_date)
if not adj_map:
return set()
skip_map = get_skipped_ingredient_ids(plan_date)
rows = db.session.execute(
select(Ingredient.recipe_id, Ingredient.id, Ingredient.component_id).where(
Ingredient.is_deleted.is_(False),
Ingredient.component_id.isnot(None),
)
).all()
out: Set[str] = set()
for recipe_id, ing_id, component_id in rows:
if str(component_id) not in adj_map:
continue
if ing_id in skip_map.get(recipe_id, set()):
continue
out.add(recipe_id)
return out
def list_component_norm_adjustments(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyComponentNormAdjustment, Component.name)
.join(Component, Component.id == DailyComponentNormAdjustment.component_id)
.where(
*_skip_active_filters(DailyComponentNormAdjustment, d),
Component.is_deleted.is_(False),
)
.order_by(Component.name.asc())
).all()
return [
{
"id": row.id,
"componentId": row.component_id,
"componentName": name or "",
"dryMatter": row.dry_matter,
"dryMatterLocked": bool(row.dry_matter_locked),
"weightPerHead": row.weight_per_head,
"dryMatterPerHead": row.dry_matter_per_head,
**_serialize_skip_dates(row, fallback=d),
}
for row, name in rows
]
def _recipe_usages_summary(usages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Уникальные рецепты, где компонент встречается в плане на день."""
by_recipe: Dict[str, Dict[str, Any]] = {}
for usage in usages:
rid = str(usage.get("recipeId") or "")
if not rid:
continue
if rid not in by_recipe:
by_recipe[rid] = {
"recipeId": rid,
"recipeName": usage.get("recipeName") or "",
"masterWeightPerHead": usage.get("masterWeightPerHead"),
"masterDryMatterPerHead": usage.get("masterDryMatterPerHead"),
"masterDryMatterPct": usage.get("masterDryMatterPct"),
"dryMatterLocked": bool(usage.get("dryMatterLocked")),
"tripCount": 1,
}
else:
by_recipe[rid]["tripCount"] = int(by_recipe[rid].get("tripCount") or 0) + 1
return sorted(by_recipe.values(), key=lambda row: (row.get("recipeName") or "").lower())
def _master_norm_snapshot(
component_id: str,
usages: List[Dict[str, Any]],
) -> Dict[str, Any]:
wph_vals = [float(u["masterWeightPerHead"]) for u in usages if u.get("masterWeightPerHead") is not None]
dm_vals = [float(u["masterDryMatterPerHead"]) for u in usages if u.get("masterDryMatterPerHead") is not None]
dm_pct_vals = [float(u["masterDryMatterPct"]) for u in usages if u.get("masterDryMatterPct") is not None]
return {
"masterWeightPerHeadMin": min(wph_vals) if wph_vals else None,
"masterWeightPerHeadMax": max(wph_vals) if wph_vals else None,
"masterDryMatterPerHeadMin": min(dm_vals) if dm_vals else None,
"masterDryMatterPerHeadMax": max(dm_vals) if dm_vals else None,
"masterDryMatterPctMin": min(dm_pct_vals) if dm_pct_vals else None,
"masterDryMatterPctMax": max(dm_pct_vals) if dm_pct_vals else None,
"masterWeightPerHead": round(sum(wph_vals) / len(wph_vals), 3) if wph_vals else None,
"masterDryMatterPerHead": round(sum(dm_vals) / len(dm_vals), 4) if dm_vals else None,
"masterDryMatterPct": round(sum(dm_pct_vals) / len(dm_pct_vals), 2) if dm_pct_vals else None,
}
def list_component_norms_for_plan(
plan_date: Optional[str] = None,
*,
dispenser_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Уникальные компоненты плана с мастер-нормами и активными правками."""
iso_date = _parse_plan_date(plan_date).isoformat()
if not dispenser_id:
return []
from app.services.daily_plan.builder import build_daily_plan
try:
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
except LookupError:
return []
skip_map = get_skipped_ingredient_ids(iso_date)
adj_map = get_component_adjustment_map(iso_date)
comp_ids: Set[str] = set()
usages: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
comp_names: Dict[str, str] = {}
comp_dm: Dict[str, float] = {}
plan_effective: Dict[str, Dict[str, Optional[float]]] = {}
for period in plan.get("periods") or []:
for trip in period.get("trips") or []:
recipe_id = trip.get("recipeId")
dry_matter_locked = bool(trip.get("dryMatterLocked"))
for ing in trip.get("ingredients") or []:
if ing.get("skippedToday"):
continue
cid = str(ing.get("componentId") or ing.get("originalComponentId") or "")
if not cid:
continue
if cid not in plan_effective:
plan_effective[cid] = {
"weightPerHead": (
float(ing["weightPerHead"])
if ing.get("weightPerHead") is not None
else None
),
"dryMatterPerHead": (
float(ing["dryMatterPerHead"])
if ing.get("dryMatterPerHead") is not None
else None
),
"dryMatterPct": (
float(ing["dryMatterPct"])
if ing.get("dryMatterPct") is not None
else None
),
}
comp_ids.add(cid)
comp_names[cid] = str(ing.get("originalName") or ing.get("name") or "")
if ing.get("dryMatterPct") is not None:
comp_dm[cid] = float(ing["dryMatterPct"])
master_wph = ing.get("originalWeightPerHead")
master_dm = ing.get("originalDryMatterPerHead")
if master_wph is None:
master_wph = ing.get("weightPerHead")
if master_dm is None:
master_dm = ing.get("dryMatterPerHead")
master_dm_pct = ing.get("originalDryMatterPct")
if master_dm_pct is None:
master_dm_pct = ing.get("dryMatterPct")
usages[cid].append(
{
"recipeId": recipe_id,
"recipeName": trip.get("recipeName"),
"ingredientId": ing.get("id"),
"masterWeightPerHead": master_wph,
"masterDryMatterPerHead": master_dm,
"masterDryMatterPct": master_dm_pct,
"dryMatterLocked": dry_matter_locked,
}
)
if comp_ids:
comps = db.session.execute(
select(Component).where(
Component.id.in_(comp_ids),
Component.is_deleted.is_(False),
)
).scalars().all()
for comp in comps:
comp_names[comp.id] = comp.name
comp_dm[comp.id] = float(comp.dry_matter or 0)
rows: List[Dict[str, Any]] = []
for cid in sorted(comp_ids, key=lambda x: (comp_names.get(x) or x).lower()):
usage_list = usages.get(cid, [])
adj = adj_map.get(cid)
snap = _master_norm_snapshot(cid, usage_list)
eff = plan_effective.get(cid) or {}
rows.append(
{
"componentId": cid,
"componentName": comp_names.get(cid, ""),
"dryMatterPct": comp_dm.get(cid, 0),
"usageCount": len(usage_list),
"recipes": sorted({u.get("recipeName") or "" for u in usage_list if u.get("recipeName")}),
"usages": _recipe_usages_summary(usage_list),
**snap,
"planDryMatterPct": adj.get("dry_matter") if adj else eff.get("dryMatterPct"),
"planDryMatterLocked": bool(adj.get("dry_matter_locked")) if adj else None,
"planWeightPerHead": eff.get("weightPerHead"),
"planDryMatterPerHead": eff.get("dryMatterPerHead"),
"adjustedToday": bool(adj),
}
)
return rows
def adjust_component_norm(
component_id: str,
plan_date: Optional[str] = None,
*,
dry_matter: Optional[float] = None,
dry_matter_locked: bool = False,
weight_per_head: Optional[float] = None,
dry_matter_per_head: Optional[float] = None,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyComponentNormAdjustment:
if dry_matter is None and weight_per_head is None and dry_matter_per_head is None:
raise ValueError("Укажите dryMatter (СВ%)")
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
component = db.session.execute(
select(Component).where(
Component.id == component_id,
Component.is_deleted.is_(False),
)
).scalar_one_or_none()
if component is None:
raise LookupError("Компонент не найден")
if dry_matter is not None:
weight_per_head = None
dry_matter_per_head = None
return _upsert_part_skip(
DailyComponentNormAdjustment,
lookup_filters=(DailyComponentNormAdjustment.component_id == component_id,),
create_fields={
"component_id": component_id,
"plan_date": start,
"valid_until": valid_until if valid_until != start else None,
"dry_matter": float(dry_matter) if dry_matter is not None else None,
"dry_matter_locked": bool(dry_matter_locked),
"weight_per_head": float(weight_per_head) if weight_per_head is not None else None,
"dry_matter_per_head": float(dry_matter_per_head)
if dry_matter_per_head is not None
else None,
},
user=user,
)
def undo_component_norm_adjustment(
component_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
d = _parse_plan_date(plan_date)
return _soft_unskip_part(
DailyComponentNormAdjustment,
lookup_filters=(
DailyComponentNormAdjustment.component_id == component_id,
*_skip_active_filters(DailyComponentNormAdjustment, d),
),
user=user,
table_name="daily_component_norm_adjustment",
)
def master_ingredient_norms(ing: Ingredient, components_by_id: Dict[str, Component]) -> Dict[str, float]:
dm_pct = ingredient_dry_matter_percent(ing, components_by_id)
dm_ph = ingredient_dry_matter_per_head(ing, dry_matter_percent=dm_pct)
return {
"weightPerHead": float(ing.weight_per_head or 0),
"dryMatterPerHead": round(dm_ph, 4),
"dryMatterPct": dm_pct,
}
@@ -0,0 +1,622 @@
"""Сборка плана на день из периодов, рейсов и ингредиентов."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import exists, select
from app import db
from app.models import (
Component,
FeedDispenser,
FeedingPeriod,
Ingredient,
PeriodRecipe,
Recipe,
UnloadingGroup,
)
from app.services.daily_plan.adjustments import (
get_component_adjustment_map,
list_component_norm_adjustments,
)
from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights
from app.services.daily_plan.replacements import (
get_ingredient_replacement_map,
list_ingredient_replacements,
)
from app.services.daily_plan.skips import (
get_skipped_ingredient_ids,
get_skipped_recipe_ids,
get_skipped_unloading_group_ids,
list_ingredient_skips,
list_skips,
list_unloading_group_skips,
)
from app.services.period_recipes_query import recipes_for_period_ordered
from app.services.recipe_calculator import calculate_ingredients, calculate_recipe
ALL_DISPENSERS_ID = "__all_dispensers__"
ALL_MILLS_ID = "__all_mills__"
def _parse_plan_date(value: Optional[str]) -> str:
if value:
try:
return date.fromisoformat(str(value).strip()[:10]).isoformat()
except ValueError:
pass
return date.today().isoformat()
def _ingredient_display_name(ing: Ingredient, comp_names: Dict[str, str]) -> str:
raw = (ing.name or "").strip()
if raw:
return raw
if ing.component_id and ing.component_id in comp_names:
return comp_names[ing.component_id]
return ""
def _distribution_label(dist_type: Optional[str], value: float) -> str:
if (dist_type or "percent") == "heads":
rounded = int(value) if value == int(value) else value
return f"{rounded} гол."
rounded = int(value) if value == int(value) else value
return f"{rounded}%"
def _load_components_by_id(comp_ids: List[str]) -> Dict[str, Component]:
if not comp_ids:
return {}
comps = db.session.execute(
select(Component).where(
Component.id.in_(set(comp_ids)),
Component.is_deleted.is_(False),
)
).scalars().all()
return {c.id: c for c in comps}
def _serialize_trip(
recipe: Recipe,
*,
order: int,
skipped_ingredient_ids: Optional[set[str]] = None,
skipped_group_ids: Optional[set[str]] = None,
replacement_by_ingredient: Optional[Dict[str, str]] = None,
component_adjustment_map: Optional[Dict[str, Dict[str, Optional[float]]]] = None,
) -> Dict[str, Any]:
ingredients = db.session.execute(
select(Ingredient)
.where(Ingredient.recipe_id == recipe.id, Ingredient.is_deleted.is_(False))
.order_by(Ingredient.order.asc())
).scalars().all()
comp_ids = [i.component_id for i in ingredients if i.component_id]
repl_ids = list((replacement_by_ingredient or {}).values())
comp_ids.extend(repl_ids)
components_by_id = _load_components_by_id(comp_ids)
comp_names = {cid: c.name for cid, c in components_by_id.items()}
heads = int(recipe.heads_per_trip or 0)
skip_ings = skipped_ingredient_ids or set()
skip_groups = skipped_group_ids or set()
repl_map = replacement_by_ingredient or {}
adj_map = component_adjustment_map or {}
dm_map = {cid: float(c.dry_matter or 0) for cid, c in components_by_id.items()}
prepared: List[Dict[str, Any]] = []
calc_inputs: List[Dict[str, Any]] = []
calc_index_by_ing: Dict[str, int] = {}
for ing in ingredients:
is_skipped = ing.id in skip_ings
replacement_id = repl_map.get(ing.id) if not is_skipped else None
component_adj = adj_map.get(str(ing.component_id)) if ing.component_id else None
weights = resolve_plan_ingredient_weights(
ing,
recipe,
heads=heads,
components_by_id=components_by_id,
replacement_component_id=replacement_id,
component_adjustment=component_adj,
)
original_name = _ingredient_display_name(ing, comp_names)
replaced_today = bool(replacement_id)
adjusted_today = bool(weights.get("adjustedToday"))
display_name = original_name
replacement_name = None
if replaced_today:
replacement_name = comp_names.get(replacement_id, "")
display_name = f"{original_name}{replacement_name}"
trip_kg = 0.0
if not is_skipped:
calc_index_by_ing[ing.id] = len(calc_inputs)
calc_inputs.append(
{
"weightPerHead": weights["weightPerHead"],
"dryMatter": weights["dryMatterPct"],
"component_id": replacement_id or ing.component_id,
}
)
prepared.append(
{
"ing": ing,
"is_skipped": is_skipped,
"weights": weights,
"original_name": original_name,
"display_name": display_name,
"replacement_id": replacement_id,
"replacement_name": replacement_name,
"replaced_today": replaced_today,
"adjusted_today": adjusted_today,
"trip_kg": trip_kg,
}
)
groups = db.session.execute(
select(UnloadingGroup)
.where(UnloadingGroup.recipe_id == recipe.id, UnloadingGroup.is_deleted.is_(False))
.order_by(UnloadingGroup.order.asc())
).scalars().all()
active_groups = [g for g in groups if g.id not in skip_groups]
calc_groups_payload = [
{
"distributionType": g.distribution_type or "percent",
"value": float(g.value or 0),
}
for g in active_groups
]
calc_result = calculate_recipe(
calc_inputs,
heads_count=heads,
trip_percent=float(recipe.trip_percent or 100),
unloading_groups=calc_groups_payload,
component_dry_matter_map=dm_map,
)
calc_ingredients = calc_result.get("ingredients") or []
calc_unloading = calc_result.get("unloadingGroups") or []
total_weight = float(calc_result.get("totals", {}).get("totalTripWeight") or 0)
unloading_total = float(calc_result.get("unloadingTotals", {}).get("totalWeightKg") or 0)
baseline_inputs = [
{
"weightPerHead": item["weights"]["weightPerHead"],
"dryMatter": item["weights"]["dryMatterPct"],
"component_id": item["replacement_id"] or item["ing"].component_id,
}
for item in prepared
]
baseline_ingredients = calculate_ingredients(
baseline_inputs,
heads_count=heads,
trip_percent=float(recipe.trip_percent or 100),
component_dry_matter_map=dm_map,
)
group_weight_by_id = {
g.id: float(calc_unloading[idx].get("calculatedWeight") or 0)
for idx, g in enumerate(active_groups)
if idx < len(calc_unloading)
}
ing_rows = []
for idx, item in enumerate(prepared):
ing = item["ing"]
weights = item["weights"]
if not item["is_skipped"]:
calc_idx = calc_index_by_ing.get(ing.id)
if calc_idx is not None and calc_idx < len(calc_ingredients):
item["trip_kg"] = float(calc_ingredients[calc_idx].get("tripWeight") or 0)
row: Dict[str, Any] = {
"id": ing.id,
"name": item["display_name"],
"originalName": item["original_name"],
"weightPerHead": weights["weightPerHead"],
"totalKg": item["trip_kg"],
"dryMatterPct": weights["dryMatterPct"],
"dryMatterPerHead": weights["dryMatterPerHead"],
"componentId": ing.component_id,
"originalComponentId": ing.component_id,
"replacementComponentId": item["replacement_id"],
"replacementName": item["replacement_name"],
"skippedToday": item["is_skipped"],
"replacedToday": item["replaced_today"],
"adjustedToday": item["adjusted_today"],
}
if item["replaced_today"] or item["adjusted_today"]:
row.update(
{
"originalWeightPerHead": weights.get("originalWeightPerHead"),
"originalDryMatterPerHead": weights.get("originalDryMatterPerHead"),
"originalDryMatterPct": weights.get("originalDryMatterPct"),
}
)
if item["replaced_today"]:
row["recalculationMode"] = weights.get("recalculationMode")
if item["is_skipped"] and idx < len(baseline_ingredients):
baseline = baseline_ingredients[idx]
row["baselineWeightPerHead"] = float(baseline.get("weightPerHead") or 0)
row["baselineTotalKg"] = float(baseline.get("tripWeight") or 0)
ing_rows.append(row)
return {
"order": order,
"recipeId": recipe.id,
"recipeName": recipe.name,
"headsPerTrip": heads,
"mixingTimeSec": int(recipe.mixing_time or 0),
"tripPercent": float(recipe.trip_percent or 100),
"dryMatterLocked": bool(recipe.dry_matter_locked),
"totalWeightKg": round(total_weight, 2),
"unloadingTotalKg": round(unloading_total, 2),
"ingredients": ing_rows,
"unloadingGroups": [
{
"id": g.id,
"name": g.name,
"weightKg": 0.0
if g.id in skip_groups
else float(group_weight_by_id.get(g.id, 0)),
"distributionType": g.distribution_type,
"distributionLabel": _distribution_label(
g.distribution_type, float(g.value or 0)
),
"value": float(g.value or 0),
"skippedToday": g.id in skip_groups,
}
for g in groups
],
}
def _periods_for_dispenser(
dispenser: FeedDispenser,
*,
skipped_ids: set[str],
skipped_ingredients: Dict[str, set[str]],
skipped_groups: Dict[str, set[str]],
replacement_map: Dict[str, Dict[str, str]],
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
) -> List[Dict[str, Any]]:
periods = db.session.execute(
select(FeedingPeriod)
.where(
FeedingPeriod.dispenser_id == dispenser.id,
FeedingPeriod.is_deleted.is_(False),
FeedingPeriod.is_active.is_(True),
)
.order_by(FeedingPeriod.created_at.asc())
).unique().scalars().all()
payload = []
for period in periods:
recipes = [
r for r in recipes_for_period_ordered(period.id) if r.id not in skipped_ids
]
trips = [
_serialize_trip(
r,
order=idx + 1,
skipped_ingredient_ids=skipped_ingredients.get(r.id, set()),
skipped_group_ids=skipped_groups.get(r.id, set()),
replacement_by_ingredient=replacement_map.get(r.id, {}),
component_adjustment_map=component_adjustment_map,
)
for idx, r in enumerate(recipes)
]
payload.append(
{
"id": period.id,
"name": period.name,
"trips": trips,
}
)
return payload
def _mill_trips_fallback(
dispenser_id: str,
*,
skipped_ids: set[str],
skipped_ingredients: Dict[str, set[str]],
skipped_groups: Dict[str, set[str]],
replacement_map: Dict[str, Dict[str, str]],
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
) -> List[Dict[str, Any]]:
recipes = db.session.execute(
select(Recipe)
.where(
Recipe.is_deleted.is_(False),
~exists(
select(1).where(
PeriodRecipe.recipe_id == Recipe.id,
PeriodRecipe.is_deleted.is_(False),
)
),
)
.order_by(Recipe.updated_at.desc())
).scalars().all()
recipes = [r for r in recipes if r.id not in skipped_ids]
trips = [
_serialize_trip(
r,
order=idx + 1,
skipped_ingredient_ids=skipped_ingredients.get(r.id, set()),
skipped_group_ids=skipped_groups.get(r.id, set()),
replacement_by_ingredient=replacement_map.get(r.id, {}),
)
for idx, r in enumerate(recipes)
]
if not trips:
return []
return [{"id": None, "name": "Рейсы", "trips": trips}]
def _aggregate_ingredient_totals(periods: List[Dict[str, Any]]) -> Dict[str, Any]:
totals: Dict[str, float] = {}
grand_total = 0.0
for period in periods:
for trip in period.get("trips") or []:
for ing in trip.get("ingredients") or []:
if ing.get("skippedToday"):
continue
if ing.get("replacedToday") and ing.get("replacementName"):
name = str(ing.get("replacementName") or "")
else:
name = str(ing.get("originalName") or ing.get("name") or "")
kg = float(ing.get("totalKg") or 0)
totals[name] = totals.get(name, 0.0) + kg
grand_total += kg
rows = [
{"name": name, "totalKg": round(kg, 2)}
for name, kg in sorted(totals.items(), key=lambda x: x[0].lower())
]
return {
"rows": rows,
"grandTotalKg": round(grand_total, 2),
}
def _periods_for_dispenser_named(
dispenser: FeedDispenser,
*,
prefix_name: bool = False,
skipped_ids: set[str],
skipped_ingredients: Dict[str, set[str]],
skipped_groups: Dict[str, set[str]],
replacement_map: Dict[str, Dict[str, str]],
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
) -> List[Dict[str, Any]]:
if dispenser.type == "mill":
periods = _mill_trips_fallback(
dispenser.id,
skipped_ids=skipped_ids,
skipped_ingredients=skipped_ingredients,
skipped_groups=skipped_groups,
replacement_map=replacement_map,
component_adjustment_map=component_adjustment_map,
)
else:
periods = _periods_for_dispenser(
dispenser,
skipped_ids=skipped_ids,
skipped_ingredients=skipped_ingredients,
skipped_groups=skipped_groups,
replacement_map=replacement_map,
component_adjustment_map=component_adjustment_map,
)
if not prefix_name:
return periods
prefixed: List[Dict[str, Any]] = []
for period in periods:
item = dict(period)
item["name"] = f"{dispenser.name} · {period.get('name') or 'Период'}"
item["dispenserId"] = dispenser.id
prefixed.append(item)
return prefixed
def _plan_extras(
periods: List[Dict[str, Any]],
*,
iso_date: str,
) -> Dict[str, Any]:
totals = _aggregate_ingredient_totals(periods)
return {
"ingredientTotals": totals["rows"],
"ingredientGrandTotalKg": totals["grandTotalKg"],
"ingredientReplacements": list_ingredient_replacements(iso_date),
"componentNormAdjustments": list_component_norm_adjustments(iso_date),
}
def _plan_payload_base(plan_date: Optional[str]) -> tuple[str, set[str], List, List, List]:
iso_date = _parse_plan_date(plan_date)
skipped_ids = get_skipped_recipe_ids(iso_date)
skipped_trips = list_skips(iso_date)
skipped_ingredients = list_ingredient_skips(iso_date)
skipped_groups = list_unloading_group_skips(iso_date)
return iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups
def _part_skip_maps(plan_date: Optional[str]) -> tuple[Dict[str, set[str]], Dict[str, set[str]]]:
iso_date = _parse_plan_date(plan_date)
return get_skipped_ingredient_ids(iso_date), get_skipped_unloading_group_ids(iso_date)
def _build_all_dispensers_plan(plan_date: Optional[str]) -> Dict[str, Any]:
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
plan_date
)
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
replacement_map = get_ingredient_replacement_map(iso_date)
component_adjustment_map = get_component_adjustment_map(iso_date)
dispensers = db.session.execute(
select(FeedDispenser)
.where(
FeedDispenser.is_deleted.is_(False),
FeedDispenser.type == "dispenser",
)
.order_by(FeedDispenser.name.asc())
).scalars().all()
periods: List[Dict[str, Any]] = []
for dispenser in dispensers:
periods.extend(
_periods_for_dispenser_named(
dispenser,
prefix_name=True,
skipped_ids=skipped_ids,
skipped_ingredients=skip_ing_map,
skipped_groups=skip_grp_map,
replacement_map=replacement_map,
component_adjustment_map=component_adjustment_map,
)
)
return {
"date": iso_date,
"generatedAt": datetime.now().isoformat(timespec="seconds"),
"dispenserId": ALL_DISPENSERS_ID,
"dispenserName": "Все кормораздатчики",
"farm": "",
"dispenserType": "dispenser",
"periods": periods,
**_plan_extras(periods, iso_date=iso_date),
"skippedTrips": skipped_trips,
"skippedIngredients": skipped_ingredients,
"skippedUnloadingGroups": skipped_groups,
}
def _build_all_mills_plan(plan_date: Optional[str]) -> Dict[str, Any]:
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
plan_date
)
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
replacement_map = get_ingredient_replacement_map(iso_date)
component_adjustment_map = get_component_adjustment_map(iso_date)
mills = db.session.execute(
select(FeedDispenser)
.where(
FeedDispenser.is_deleted.is_(False),
FeedDispenser.type == "mill",
)
.order_by(FeedDispenser.name.asc())
).scalars().all()
periods = _mill_trips_fallback(
ALL_MILLS_ID,
skipped_ids=skipped_ids,
skipped_ingredients=skip_ing_map,
skipped_groups=skip_grp_map,
replacement_map=replacement_map,
component_adjustment_map=component_adjustment_map,
)
if mills:
mill_names = ", ".join(m.name for m in mills)
farm = mills[0].farm if len(mills) == 1 else ""
else:
mill_names = ""
farm = ""
return {
"date": iso_date,
"generatedAt": datetime.now().isoformat(timespec="seconds"),
"dispenserId": ALL_MILLS_ID,
"dispenserName": "Все кормоцеха",
"farm": farm,
"dispenserType": "mill",
"scopeNote": mill_names,
"periods": periods,
**_plan_extras(periods, iso_date=iso_date),
"skippedTrips": skipped_trips,
"skippedIngredients": skipped_ingredients,
"skippedUnloadingGroups": skipped_groups,
}
def build_daily_plan(
*,
dispenser_id: str,
plan_date: Optional[str] = None,
) -> Dict[str, Any]:
"""План на дату: текущая конфигурация периодов кормораздатчика."""
if dispenser_id == ALL_DISPENSERS_ID:
return _build_all_dispensers_plan(plan_date)
if dispenser_id == ALL_MILLS_ID:
return _build_all_mills_plan(plan_date)
dispenser = db.session.execute(
select(FeedDispenser).where(
FeedDispenser.id == dispenser_id,
FeedDispenser.is_deleted.is_(False),
)
).scalar_one_or_none()
if dispenser is None:
raise LookupError("Кормораздатчик не найден")
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
plan_date
)
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
replacement_map = get_ingredient_replacement_map(iso_date)
component_adjustment_map = get_component_adjustment_map(iso_date)
periods = _periods_for_dispenser_named(
dispenser,
skipped_ids=skipped_ids,
skipped_ingredients=skip_ing_map,
skipped_groups=skip_grp_map,
replacement_map=replacement_map,
component_adjustment_map=component_adjustment_map,
)
return {
"date": iso_date,
"generatedAt": datetime.now().isoformat(timespec="seconds"),
"dispenserId": dispenser.id,
"dispenserName": dispenser.name,
"farm": dispenser.farm,
"dispenserType": dispenser.type,
"periods": periods,
**_plan_extras(periods, iso_date=iso_date),
"skippedTrips": skipped_trips,
"skippedIngredients": skipped_ingredients,
"skippedUnloadingGroups": skipped_groups,
}
def recipe_total_weights_by_id(
*,
dispenser_id: str,
plan_date: Optional[str] = None,
) -> Dict[str, float]:
"""totalWeightKg по recipeId из плана на день (список рейсов на терминале)."""
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=plan_date)
weights: Dict[str, float] = {}
for period in plan.get("periods") or []:
for trip in period.get("trips") or []:
recipe_id = trip.get("recipeId")
if recipe_id:
weights[str(recipe_id)] = float(trip.get("totalWeightKg") or 0)
return weights
def trip_overlay_for_recipe(recipe: Recipe, plan_date: str) -> Dict[str, Any]:
"""Рейс из плана на день для одного recipe — те же веса, что в build_daily_plan."""
skipped_ing_by_recipe = get_skipped_ingredient_ids(plan_date)
skipped_grp_by_recipe = get_skipped_unloading_group_ids(plan_date)
replacement_map = get_ingredient_replacement_map(plan_date)
component_adjustment_map = get_component_adjustment_map(plan_date)
return _serialize_trip(
recipe,
order=1,
skipped_ingredient_ids=skipped_ing_by_recipe.get(recipe.id, set()),
skipped_group_ids=skipped_grp_by_recipe.get(recipe.id, set()),
replacement_by_ingredient=replacement_map.get(recipe.id, {}),
component_adjustment_map=component_adjustment_map,
)
@@ -0,0 +1,184 @@
"""Веса строки рейса в плане на день с учётом замены компонента и правки нормы."""
from __future__ import annotations
from typing import Any, Dict, Optional
from app.models import Component, Ingredient, Recipe
_EPSILON = 1e-6
def ingredient_dry_matter_percent(ing: Ingredient, components_by_id: Dict[str, Component]) -> float:
dm = float(ing.dry_matter or 0)
if dm > 0:
return dm
if ing.component_id and ing.component_id in components_by_id:
return float(components_by_id[ing.component_id].dry_matter or 0)
return 0.0
def ingredient_dry_matter_per_head(
ing: Ingredient,
*,
dry_matter_percent: Optional[float] = None,
) -> float:
if ing.dry_matter_per_head is not None and float(ing.dry_matter_per_head) > 0:
return float(ing.dry_matter_per_head)
wph = float(ing.weight_per_head or 0)
dm = dry_matter_percent if dry_matter_percent is not None else float(ing.dry_matter or 0)
if wph > 0 and dm > 0:
return wph * (dm / 100.0)
return 0.0
def _round_wph(value: float) -> float:
if 0 < value < 0.01:
return round(value, 3)
return round(value, 2)
def _total_kg(weight_per_head: float, heads: int, fallback_amount: float) -> float:
if weight_per_head > 0:
return round(weight_per_head * max(heads, 0), 2)
return round(float(fallback_amount or 0), 2)
def _apply_component_adjustment(
*,
recipe: Recipe,
component_id: Optional[str],
components_by_id: Dict[str, Component],
master_wph: float,
master_dm_ph: float,
master_dm_pct: float,
component_adjustment: Optional[Dict[str, Optional[float]]],
) -> tuple[float, float, float, bool]:
if not component_adjustment or not component_id:
return master_wph, master_dm_ph, master_dm_pct, False
adj_dm_pct = component_adjustment.get("dry_matter")
if adj_dm_pct is not None:
new_dm_pct = float(adj_dm_pct)
locked = bool(getattr(recipe, "dry_matter_locked", False))
if locked:
new_dm_ph = master_dm_ph
if new_dm_pct > _EPSILON:
new_wph = (master_dm_ph * 100.0) / new_dm_pct
if new_wph < 0.01 and master_dm_ph >= 0.001:
new_wph = 0.01
else:
new_wph = 0.0
return _round_wph(new_wph), round(new_dm_ph, 4), new_dm_pct, True
new_dm_ph = master_wph * (new_dm_pct / 100.0) if master_wph > 0 else master_dm_ph
return master_wph, round(new_dm_ph, 4), new_dm_pct, True
adj_wph = component_adjustment.get("weight_per_head")
adj_dm_ph = component_adjustment.get("dry_matter_per_head")
if adj_wph is None and adj_dm_ph is None:
return master_wph, master_dm_ph, master_dm_pct, False
comp = components_by_id.get(component_id)
dm_pct = float(comp.dry_matter or 0) if comp else master_dm_pct
locked = bool(getattr(recipe, "dry_matter_locked", False))
if locked and adj_dm_ph is not None:
new_dm_ph = float(adj_dm_ph)
new_wph = (new_dm_ph * 100.0) / dm_pct if dm_pct > _EPSILON else 0.0
return _round_wph(new_wph), new_dm_ph, dm_pct, True
if not locked and adj_wph is not None:
new_wph = float(adj_wph)
new_dm_ph = new_wph * (dm_pct / 100.0) if dm_pct > 0 else 0.0
return _round_wph(new_wph), round(new_dm_ph, 4), dm_pct, True
if adj_dm_ph is not None:
new_dm_ph = float(adj_dm_ph)
new_wph = (new_dm_ph * 100.0) / dm_pct if dm_pct > _EPSILON else master_wph
return _round_wph(new_wph), new_dm_ph, dm_pct, True
if adj_wph is not None:
new_wph = float(adj_wph)
new_dm_ph = new_wph * (dm_pct / 100.0) if dm_pct > 0 else master_dm_ph
return _round_wph(new_wph), round(new_dm_ph, 4), dm_pct, True
return master_wph, master_dm_ph, master_dm_pct, False
def resolve_plan_ingredient_weights(
ing: Ingredient,
recipe: Recipe,
*,
heads: int,
components_by_id: Dict[str, Component],
replacement_component_id: Optional[str] = None,
component_adjustment: Optional[Dict[str, Optional[float]]] = None,
) -> Dict[str, Any]:
"""
Эффективные веса для плана.
Приоритет: adjustment (по component_id) → замена → базовый рецепт.
Мастер-значения ingredient не изменяются.
"""
master_wph = float(ing.weight_per_head or 0)
master_dm_pct = ingredient_dry_matter_percent(ing, components_by_id)
master_dm_ph = ingredient_dry_matter_per_head(ing, dry_matter_percent=master_dm_pct)
effective_wph, effective_dm_ph, effective_dm_pct, adjusted_today = _apply_component_adjustment(
recipe=recipe,
component_id=ing.component_id,
components_by_id=components_by_id,
master_wph=master_wph,
master_dm_ph=master_dm_ph,
master_dm_pct=master_dm_pct,
component_adjustment=component_adjustment,
)
replacement = (
components_by_id.get(replacement_component_id)
if replacement_component_id
else None
)
if replacement is None:
wph = effective_wph
return {
"weightPerHead": wph,
"totalKg": _total_kg(wph, heads, float(ing.amount or 0)),
"dryMatterPct": effective_dm_pct,
"dryMatterPerHead": round(effective_dm_ph, 4),
"originalWeightPerHead": master_wph,
"originalDryMatterPerHead": round(master_dm_ph, 4),
"originalDryMatterPct": master_dm_pct,
"adjustedToday": adjusted_today,
"replacedToday": False,
}
repl_dm_pct = float(replacement.dry_matter or 0)
locked = bool(getattr(recipe, "dry_matter_locked", False))
if locked:
new_dm_ph = effective_dm_ph
if repl_dm_pct > _EPSILON:
new_wph = (new_dm_ph * 100.0) / repl_dm_pct
else:
new_wph = 0.0
mode = "dry_matter"
else:
new_wph = effective_wph
new_dm_ph = new_wph * (repl_dm_pct / 100.0) if repl_dm_pct > 0 else 0.0
mode = "weight"
new_wph = _round_wph(new_wph)
return {
"weightPerHead": new_wph,
"totalKg": _total_kg(new_wph, heads, float(ing.amount or 0)),
"dryMatterPct": repl_dm_pct,
"dryMatterPerHead": round(new_dm_ph, 4),
"originalWeightPerHead": master_wph,
"originalDryMatterPerHead": round(master_dm_ph, 4),
"originalDryMatterPct": master_dm_pct,
"recalculationMode": mode,
"adjustedToday": adjusted_today,
"replacedToday": True,
}
# Backward-compatible aliases for internal callers
_ingredient_dry_matter_percent = ingredient_dry_matter_percent
_ingredient_dry_matter_per_head = ingredient_dry_matter_per_head
@@ -0,0 +1,30 @@
"""Рецепт для экрана оператора загрузки (весы / дублёр) — с overlay «План на день»."""
from __future__ import annotations
from datetime import date as date_cls
from typing import Any
from app.models import Recipe
def plan_ingredients_for_loading(
recipe: Recipe, *, plan_date: str | None = None
) -> list[dict[str, Any]]:
"""Ингредиенты с весами плана на день, как на киоске тракториста (без пропущенных)."""
from app.routes.recipes import _serialize_recipe
resolved = (plan_date or date_cls.today().isoformat())[:10]
payload = _serialize_recipe(recipe, plan_date=resolved, exclude_skipped=True)
rows: list[dict[str, Any]] = []
for ing in payload.get("ingredients") or []:
rows.append(
{
"id": ing.get("id"),
"name": (ing.get("name") or "").strip() or "",
"amount": float(ing.get("amount") or 0),
"order": int(ing.get("order") or 0),
}
)
rows.sort(key=lambda row: (row["order"], str(row.get("id") or "")))
return rows
@@ -0,0 +1,365 @@
"""Уведомления центра «К» при изменениях плана на день."""
from __future__ import annotations
import logging
from datetime import date
from typing import Optional
from sqlalchemy import select
from app import db
from app.models import Component, Ingredient, Recipe, UnloadingGroup
from app.services.notification_center_service import create_notification, format_detail_timestamp
logger = logging.getLogger(__name__)
def _q(name: Optional[str]) -> str:
text = (name or "").strip()
return f"«{text}»" if text else "«без названия»"
def _recipe_name(recipe_id: str) -> str:
recipe = db.session.execute(
select(Recipe.name).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
return recipe or "без названия"
def _ingredient_name(ingredient_id: str) -> str:
row = db.session.execute(
select(Ingredient.name).where(
Ingredient.id == ingredient_id,
Ingredient.is_deleted.is_(False),
)
).scalar_one_or_none()
return row or "без названия"
def _group_name(group_id: str) -> str:
row = db.session.execute(
select(UnloadingGroup.name).where(
UnloadingGroup.id == group_id,
UnloadingGroup.is_deleted.is_(False),
)
).scalar_one_or_none()
return row or "без названия"
def _component_name(component_id: str) -> str:
row = db.session.execute(
select(Component.name).where(
Component.id == component_id,
Component.is_deleted.is_(False),
)
).scalar_one_or_none()
return row or "без названия"
def _plan_date_label(plan_date: date) -> str:
months = (
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
)
return f"{plan_date.day} {months[plan_date.month - 1]} {plan_date.year}"
def _duration_hint(*, plan_date: date, valid_until: Optional[date]) -> str:
if valid_until and valid_until > plan_date:
return f", действует до {_plan_date_label(valid_until)}"
return ""
def _on_date(plan_date: date) -> str:
return f"на {_plan_date_label(plan_date)}"
def _actor(user: str) -> str:
name = (user or "").strip()
if not name or name == "system":
return "система"
return name
def _detail_body(*parts: str, when: str, user: str) -> str:
core = ". ".join(p for p in parts if p)
return f"{core}. {when}, {_actor(user)}"
def _notify(
*,
title: str,
detail: str,
kind: str,
plan_date: date,
user: str,
) -> None:
try:
create_notification(
title=title,
detail=detail,
kind=kind,
category="daily_plan",
page="daily_plan",
link_kind="daily_plan",
link_id=plan_date.isoformat(),
user_login=user or None,
)
except Exception:
logger.exception("[DAILY-PLAN-NOTIFY] %s", title)
def notify_trip_skipped(
recipe_id: str,
plan_date: date,
*,
valid_until: Optional[date] = None,
user: str = "system",
) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
_notify(
title=f"Рейс {recipe} убран из плана",
detail=_detail_body(
f"Рейс {recipe} исключён из плана {_on_date(plan_date)}{dur}",
when=when,
user=user,
),
kind="warning",
plan_date=plan_date,
user=user,
)
def notify_trip_unskipped(recipe_id: str, plan_date: date, *, user: str = "system") -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
_notify(
title=f"Рейс {recipe} снова в плане",
detail=_detail_body(
f"Рейс {recipe} снова включён в план {_on_date(plan_date)}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
def notify_ingredient_skipped(
recipe_id: str,
ingredient_id: str,
plan_date: date,
*,
valid_until: Optional[date] = None,
user: str = "system",
) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
component = _q(_ingredient_name(ingredient_id))
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
_notify(
title=f"{component} убран из плана {recipe}",
detail=_detail_body(
f"Компонент {component} убран из плана рейса {recipe} {_on_date(plan_date)}{dur}",
when=when,
user=user,
),
kind="warning",
plan_date=plan_date,
user=user,
)
def notify_ingredient_unskipped(
recipe_id: str,
ingredient_id: str,
plan_date: date,
*,
user: str = "system",
) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
component = _q(_ingredient_name(ingredient_id))
_notify(
title=f"{component} снова в плане {recipe}",
detail=_detail_body(
f"Компонент {component} снова в плане рейса {recipe} {_on_date(plan_date)}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
def notify_ingredients_unskipped_all(recipe_id: str, plan_date: date, *, count: int, user: str) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
_notify(
title=f"Компоненты снова в плане {recipe}",
detail=_detail_body(
f"Сняты все исключения компонентов ({count}) рейса {recipe} {_on_date(plan_date)}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
def notify_unloading_group_skipped(
recipe_id: str,
group_id: str,
plan_date: date,
*,
valid_until: Optional[date] = None,
user: str = "system",
) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
group = _q(_group_name(group_id))
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
_notify(
title=f"Группа {group} убрана из плана {recipe}",
detail=_detail_body(
f"Группа {group} убрана из плана рейса {recipe} {_on_date(plan_date)}{dur}",
when=when,
user=user,
),
kind="warning",
plan_date=plan_date,
user=user,
)
def notify_unloading_group_unskipped(
recipe_id: str,
group_id: str,
plan_date: date,
*,
user: str = "system",
) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
group = _q(_group_name(group_id))
_notify(
title=f"Группа {group} снова в плане {recipe}",
detail=_detail_body(
f"Группа {group} снова в плане рейса {recipe} {_on_date(plan_date)}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
def notify_unloading_groups_unskipped_all(recipe_id: str, plan_date: date, *, count: int, user: str) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
_notify(
title=f"Группы снова в плане {recipe}",
detail=_detail_body(
f"Сняты все исключения групп ({count}) рейса {recipe} {_on_date(plan_date)}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
def notify_ingredient_replaced(
recipe_id: str,
ingredient_id: str,
replacement_component_id: str,
plan_date: date,
*,
valid_until: Optional[date] = None,
user: str = "system",
) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
original = _q(_ingredient_name(ingredient_id))
replacement = _q(_component_name(replacement_component_id))
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
_notify(
title=f"{original}{replacement} в {recipe}",
detail=_detail_body(
f"В рейсе {recipe} вместо {original} {_on_date(plan_date)} будет {replacement}{dur}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
def notify_component_replaced_in_plan(
component_id: str,
replacement_component_id: str,
plan_date: date,
*,
recipe_count: int,
valid_until: Optional[date] = None,
user: str = "system",
) -> None:
when = format_detail_timestamp()
original = _q(_component_name(component_id))
replacement = _q(_component_name(replacement_component_id))
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
count = max(int(recipe_count or 0), 1)
recipes_word = "рецепте" if count == 1 else "рецептах"
_notify(
title=f"{original}{replacement} в {count} {recipes_word}",
detail=_detail_body(
f"Во всех рейсах плана вместо {original} {_on_date(plan_date)} будет {replacement}{dur}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
def notify_ingredient_replacement_undone(
recipe_id: str,
ingredient_id: str,
plan_date: date,
*,
user: str = "system",
) -> None:
when = format_detail_timestamp()
recipe = _q(_recipe_name(recipe_id))
original = _q(_ingredient_name(ingredient_id))
_notify(
title=f"{original} снова в плане {recipe}",
detail=_detail_body(
f"Замена отменена: {original} снова в плане рейса {recipe} {_on_date(plan_date)}",
when=when,
user=user,
),
kind="info",
plan_date=plan_date,
user=user,
)
@@ -0,0 +1,129 @@
"""PDF плана на день (reportlab)."""
from __future__ import annotations
import io
from typing import Any, Dict, List
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from app.services.feed_accounting.pdf_feed_accounting import _register_cyrillic_font
def _styles():
font = _register_cyrillic_font()
styles = getSampleStyleSheet()
styles["Title"].fontName = font
styles["Normal"].fontName = font
styles["Heading2"].fontName = font
return styles, font
def build_daily_plan_pdf(plan: Dict[str, Any]) -> bytes:
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=14 * mm, rightMargin=14 * mm)
styles, font = _styles()
story: List[Any] = []
story.append(Paragraph("План на день", styles["Title"]))
story.append(
Paragraph(
f"Дата: {plan.get('date', '')} · {plan.get('dispenserName', '')} · {plan.get('farm', '')}",
styles["Normal"],
)
)
story.append(Spacer(1, 8))
periods = plan.get("periods") or []
if not periods:
story.append(Paragraph("Нет периодов или рейсов для выбранного кормораздатчика.", styles["Normal"]))
else:
for period in periods:
story.append(Paragraph(str(period.get("name") or "Период"), styles["Heading2"]))
for trip in period.get("trips") or []:
story.append(
Paragraph(
f"Рейс {trip.get('order', '')}: {trip.get('recipeName', '')} "
f"({trip.get('headsPerTrip', 0)} гол., смеш. {trip.get('mixingTimeSec', 0)} с)",
styles["Normal"],
)
)
data = [["Компонент", "кг/гол", "Всего, кг"]]
for ing in trip.get("ingredients") or []:
data.append(
[
str(ing.get("name") or ""),
str(ing.get("weightPerHead") or ""),
str(ing.get("totalKg") or ""),
]
)
if len(data) == 1:
data.append(["", "", ""])
tbl = Table(data, colWidths=[80 * mm, 35 * mm, 35 * mm])
tbl.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, -1), font),
("FONTSIZE", (0, 0), (-1, -1), 8),
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
]
)
)
story.append(tbl)
groups = trip.get("unloadingGroups") or []
if groups:
gdata = [["Группа", "кг", "Распределение"]]
for g in groups:
gdata.append(
[
str(g.get("name") or ""),
str(g.get("weightKg") or ""),
str(
g.get("distributionLabel")
or g.get("distributionType")
or ""
),
]
)
gtbl = Table(gdata, colWidths=[55 * mm, 30 * mm, 40 * mm])
gtbl.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, -1), font),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
]
)
)
story.append(gtbl)
story.append(Spacer(1, 6))
totals = plan.get("ingredientTotals") or []
if totals:
story.append(Paragraph("Итого по компонентам", styles["Heading2"]))
tdata = [["Компонент", "Всего, кг"]]
for row in totals:
tdata.append([str(row.get("name") or ""), str(row.get("totalKg") or "")])
grand = plan.get("ingredientGrandTotalKg")
if grand is not None:
tdata.append(["Итого", str(grand)])
ttbl = Table(tdata, colWidths=[100 * mm, 40 * mm])
ttbl.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, -1), font),
("FONTSIZE", (0, 0), (-1, -1), 9),
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
]
)
)
story.append(ttbl)
doc.build(story)
return buf.getvalue()
@@ -0,0 +1,320 @@
"""Временная замена компонентов в плане на день."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set, Tuple
from sqlalchemy import select
from app import db
from app.models import Component, DailyIngredientReplacement, Ingredient, Recipe
from app.services.daily_plan.skips import (
_parse_plan_date,
_serialize_skip_dates,
_skip_active_filters,
_soft_delete_skip_row,
_soft_unskip_part,
_upsert_part_skip,
resolve_skip_range,
)
def _component_payload(component: Component, *, similar: bool = False) -> Dict[str, Any]:
return {
"id": component.id,
"name": component.name,
"type": component.type or "",
"dryMatter": float(component.dry_matter or 0),
"protein": float(component.protein or 0),
"energy": float(component.energy or 0),
"similar": similar,
}
def _similarity_score(source: Component, candidate: Component) -> float:
if source.id == candidate.id:
return -1.0
score = 0.0
src_type = (source.type or "").strip().lower()
cand_type = (candidate.type or "").strip().lower()
if src_type and cand_type and src_type == cand_type:
score += 100.0
dm_diff = abs(float(source.dry_matter or 0) - float(candidate.dry_matter or 0))
score += max(0.0, 50.0 - dm_diff * 2.0)
prot_diff = abs(float(source.protein or 0) - float(candidate.protein or 0))
score += max(0.0, 20.0 - prot_diff)
return score
def find_component_alternatives(
component_id: str,
*,
query: str = "",
limit: int = 20,
) -> Dict[str, Any]:
"""Похожие компоненты и поиск по всем активным."""
source = db.session.execute(
select(Component).where(
Component.id == component_id,
Component.is_deleted.is_(False),
)
).scalar_one_or_none()
if source is None:
raise LookupError("Компонент не найден")
q = (query or "").strip().lower()
try:
limit_val = max(1, min(int(limit), 100))
except (TypeError, ValueError):
limit_val = 20
candidates = db.session.execute(
select(Component).where(
Component.is_active.is_(True),
Component.is_deleted.is_(False),
Component.id != component_id,
)
).scalars().all()
scored: List[Tuple[float, Component]] = []
for candidate in candidates:
if q and q not in (candidate.name or "").lower():
continue
score = _similarity_score(source, candidate)
if score < 0:
continue
scored.append((score, candidate))
scored.sort(key=lambda item: (-item[0], (item[1].name or "").lower()))
similar_cutoff = 80.0 if (source.type or "").strip() else 40.0
similar: List[Dict[str, Any]] = []
all_items: List[Dict[str, Any]] = []
for score, candidate in scored:
is_similar = score >= similar_cutoff
payload = _component_payload(candidate, similar=is_similar)
all_items.append(payload)
if is_similar and len(similar) < 8:
similar.append(payload)
if len(all_items) >= limit_val:
break
return {
"sourceComponent": _component_payload(source),
"similar": similar,
"results": all_items,
}
def get_ingredient_replacement_map(
plan_date: Optional[str] = None,
) -> Dict[str, Dict[str, str]]:
"""recipe_id -> ingredient_id -> replacement_component_id."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(
DailyIngredientReplacement.recipe_id,
DailyIngredientReplacement.ingredient_id,
DailyIngredientReplacement.replacement_component_id,
).where(*_skip_active_filters(DailyIngredientReplacement, d))
).all()
out: Dict[str, Dict[str, str]] = {}
for recipe_id, ingredient_id, replacement_id in rows:
out.setdefault(recipe_id, {})[ingredient_id] = replacement_id
return out
def get_replaced_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientReplacement.recipe_id).where(
*_skip_active_filters(DailyIngredientReplacement, d)
)
).scalars().all()
return set(rows)
def list_ingredient_replacements(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(
DailyIngredientReplacement,
Recipe.name,
Ingredient.name,
Component.name,
)
.join(Recipe, Recipe.id == DailyIngredientReplacement.recipe_id)
.join(Ingredient, Ingredient.id == DailyIngredientReplacement.ingredient_id)
.join(Component, Component.id == DailyIngredientReplacement.replacement_component_id)
.where(
*_skip_active_filters(DailyIngredientReplacement, d),
Recipe.is_deleted.is_(False),
Ingredient.is_deleted.is_(False),
Component.is_deleted.is_(False),
)
.order_by(Recipe.name.asc(), Ingredient.order.asc())
).all()
return [
{
"id": row.id,
"recipeId": row.recipe_id,
"recipeName": recipe_name,
"ingredientId": row.ingredient_id,
"ingredientName": ing_name or "",
"replacementComponentId": row.replacement_component_id,
"replacementName": replacement_name or "",
**_serialize_skip_dates(row, fallback=d),
}
for row, recipe_name, ing_name, replacement_name in rows
]
def replace_ingredient(
recipe_id: str,
ingredient_id: str,
replacement_component_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyIngredientReplacement:
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
ingredient = db.session.execute(
select(Ingredient).where(
Ingredient.id == ingredient_id,
Ingredient.recipe_id == recipe_id,
Ingredient.is_deleted.is_(False),
)
).scalar_one_or_none()
if ingredient is None:
raise LookupError("Компонент рейса не найден")
replacement = db.session.execute(
select(Component).where(
Component.id == replacement_component_id,
Component.is_deleted.is_(False),
Component.is_active.is_(True),
)
).scalar_one_or_none()
if replacement is None:
raise LookupError("Компонент-замена не найден")
if ingredient.component_id and ingredient.component_id == replacement_component_id:
raise LookupError("Выберите другой компонент")
return _upsert_part_skip(
DailyIngredientReplacement,
lookup_filters=(
DailyIngredientReplacement.recipe_id == recipe_id,
DailyIngredientReplacement.ingredient_id == ingredient_id,
),
create_fields={
"recipe_id": recipe_id,
"ingredient_id": ingredient_id,
"replacement_component_id": replacement_component_id,
"plan_date": start,
"valid_until": valid_until if valid_until != start else None,
},
user=user,
)
def collect_component_replacement_targets(
component_id: str,
plan_date: Optional[str] = None,
*,
dispenser_id: Optional[str] = None,
) -> List[Tuple[str, str]]:
"""Уникальные пары (recipe_id, ingredient_id) в плане для мастер-компонента."""
from app.services.daily_plan.builder import build_daily_plan
iso_date = _parse_plan_date(plan_date).isoformat()
if not dispenser_id:
return []
try:
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
except LookupError:
return []
seen: Set[Tuple[str, str]] = set()
targets: List[Tuple[str, str]] = []
for period in plan.get("periods") or []:
for trip in period.get("trips") or []:
recipe_id = str(trip.get("recipeId") or "")
if not recipe_id:
continue
for ing in trip.get("ingredients") or []:
if ing.get("skippedToday"):
continue
orig = str(ing.get("originalComponentId") or ing.get("componentId") or "")
if orig != component_id:
continue
ingredient_id = str(ing.get("id") or "")
if not ingredient_id:
continue
key = (recipe_id, ingredient_id)
if key in seen:
continue
seen.add(key)
targets.append(key)
return targets
def replace_component_in_plan(
component_id: str,
replacement_component_id: str,
plan_date: Optional[str] = None,
*,
dispenser_id: Optional[str] = None,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> List[DailyIngredientReplacement]:
"""Заменить компонент во всех рейсах плана, где он используется."""
if component_id == replacement_component_id:
raise LookupError("Выберите другой компонент")
targets = collect_component_replacement_targets(
component_id,
plan_date,
dispenser_id=dispenser_id,
)
if not targets:
raise LookupError("Компонент не найден в плане на эту дату")
rows: List[DailyIngredientReplacement] = []
for recipe_id, ingredient_id in targets:
rows.append(
replace_ingredient(
recipe_id,
ingredient_id,
replacement_component_id,
plan_date,
duration=duration,
until_date=until_date,
user=user,
)
)
return rows
def undo_ingredient_replacement(
recipe_id: str,
ingredient_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
d = _parse_plan_date(plan_date)
return _soft_unskip_part(
DailyIngredientReplacement,
lookup_filters=(
DailyIngredientReplacement.recipe_id == recipe_id,
DailyIngredientReplacement.ingredient_id == ingredient_id,
*_skip_active_filters(DailyIngredientReplacement, d),
),
user=user,
table_name="daily_ingredient_replacement",
)
@@ -0,0 +1,548 @@
"""Исключение рейсов и частей рейса из плана на день."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Dict, List, Optional, Set, TypeVar
from flask import request, session
from sqlalchemy import func, select
from app import db
from app.models import (
DailyIngredientSkip,
DailyTripSkip,
DailyUnloadingGroupSkip,
Ingredient,
Recipe,
UnloadingGroup,
)
from app.timeutil import utc_now_naive
TRecipe = TypeVar("TRecipe")
def is_kiosk_recipe_list_request() -> bool:
"""Запрос списка рейсов с терминала/киоска (не редактор зоотехника)."""
if request.args.get("for") in ("terminal", "kiosk"):
return True
if request.headers.get("X-Wesp-Kiosk") == "1":
return True
return False
def is_zootech_recipe_list_view() -> bool:
"""Редактор зоотехника: все рейсы + skippedToday; терминал — без skip."""
if is_kiosk_recipe_list_request():
return False
return bool(session.get("authenticated", False))
def filter_recipes_for_list_view(
recipes: List[TRecipe],
*,
plan_date: Optional[str] = None,
) -> tuple[List[TRecipe], Set[str]]:
"""Список рейсов для ответа API с учётом skip на дату."""
skipped = get_skipped_recipe_ids(plan_date)
if is_zootech_recipe_list_view():
return recipes, skipped
visible = [r for r in recipes if getattr(r, "id", None) not in skipped]
return visible, skipped
def _parse_plan_date(value: Optional[str]) -> date:
if value:
try:
return date.fromisoformat(str(value).strip()[:10])
except ValueError:
pass
return date.today()
def _skip_end_expr(model):
return func.coalesce(model.valid_until, model.plan_date)
def _skip_active_filters(model, target: date):
end = _skip_end_expr(model)
return (
model.is_deleted.is_(False),
model.plan_date <= target,
end >= target,
)
def resolve_skip_range(
start: date,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
) -> tuple[date, date]:
"""Диапазон skip: today | week | date (until_date)."""
dur = (duration or "today").strip().lower()
if dur == "week":
days_to_sunday = 6 - start.weekday()
return start, start + timedelta(days=days_to_sunday)
if dur == "date" and until_date:
try:
end = date.fromisoformat(str(until_date).strip()[:10])
except ValueError:
end = start
return start, max(start, end)
return start, start
def _serialize_skip_dates(row, *, fallback: date) -> Dict[str, str]:
start = row.plan_date.isoformat() if row.plan_date else fallback.isoformat()
end_val = row.valid_until or row.plan_date
end = end_val.isoformat() if end_val else start
return {"date": start, "validUntil": end}
def get_skipped_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
"""Активные skip рейсов на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyTripSkip.recipe_id).where(*_skip_active_filters(DailyTripSkip, d))
).scalars().all()
return set(rows)
def get_recipe_ids_with_any_skip(plan_date: Optional[str] = None) -> Set[str]:
"""Рейсы с любым активным skip или заменой на дату."""
from app.services.daily_plan.replacements import get_replaced_recipe_ids
d = _parse_plan_date(plan_date)
ids: Set[str] = set()
for model in (DailyTripSkip, DailyIngredientSkip, DailyUnloadingGroupSkip):
rows = db.session.execute(
select(model.recipe_id).where(*_skip_active_filters(model, d))
).scalars().all()
ids.update(rows)
ids.update(get_replaced_recipe_ids(plan_date))
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
ids.update(get_adjusted_recipe_ids(plan_date))
return ids
def list_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
"""Список исключённых рейсов на дату (для UI)."""
d = _parse_plan_date(plan_date)
skips = db.session.execute(
select(DailyTripSkip, Recipe.name)
.join(Recipe, Recipe.id == DailyTripSkip.recipe_id)
.where(
*_skip_active_filters(DailyTripSkip, d),
Recipe.is_deleted.is_(False),
)
.order_by(Recipe.name.asc())
).all()
return [
{
"id": skip.id,
"recipeId": skip.recipe_id,
"recipeName": name,
**_serialize_skip_dates(skip, fallback=d),
}
for skip, name in skips
]
def get_skipped_ingredient_ids(plan_date: Optional[str] = None) -> Dict[str, Set[str]]:
"""recipe_id -> ingredient_id для активных skip на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientSkip.recipe_id, DailyIngredientSkip.ingredient_id).where(
*_skip_active_filters(DailyIngredientSkip, d)
)
).all()
out: Dict[str, Set[str]] = {}
for recipe_id, ingredient_id in rows:
out.setdefault(recipe_id, set()).add(ingredient_id)
return out
def get_skipped_unloading_group_ids(plan_date: Optional[str] = None) -> Dict[str, Set[str]]:
"""recipe_id -> unloading_group_id для активных skip на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(
DailyUnloadingGroupSkip.recipe_id,
DailyUnloadingGroupSkip.unloading_group_id,
).where(
*_skip_active_filters(DailyUnloadingGroupSkip, d)
)
).all()
out: Dict[str, Set[str]] = {}
for recipe_id, group_id in rows:
out.setdefault(recipe_id, set()).add(group_id)
return out
def list_ingredient_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
"""Список исключённых компонентов на дату (для UI)."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientSkip, Recipe.name, Ingredient.name)
.join(Recipe, Recipe.id == DailyIngredientSkip.recipe_id)
.join(Ingredient, Ingredient.id == DailyIngredientSkip.ingredient_id)
.where(
*_skip_active_filters(DailyIngredientSkip, d),
Recipe.is_deleted.is_(False),
Ingredient.is_deleted.is_(False),
)
.order_by(Recipe.name.asc(), Ingredient.order.asc())
).all()
return [
{
"id": skip.id,
"recipeId": skip.recipe_id,
"recipeName": recipe_name,
"ingredientId": skip.ingredient_id,
"ingredientName": ing_name or "",
**_serialize_skip_dates(skip, fallback=d),
}
for skip, recipe_name, ing_name in rows
]
def list_unloading_group_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
"""Список исключённых групп выгрузки на дату (для UI)."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyUnloadingGroupSkip, Recipe.name, UnloadingGroup.name)
.join(Recipe, Recipe.id == DailyUnloadingGroupSkip.recipe_id)
.join(UnloadingGroup, UnloadingGroup.id == DailyUnloadingGroupSkip.unloading_group_id)
.where(
*_skip_active_filters(DailyUnloadingGroupSkip, d),
Recipe.is_deleted.is_(False),
UnloadingGroup.is_deleted.is_(False),
)
.order_by(Recipe.name.asc(), UnloadingGroup.order.asc())
).all()
return [
{
"id": skip.id,
"recipeId": skip.recipe_id,
"recipeName": recipe_name,
"unloadingGroupId": skip.unloading_group_id,
"groupName": group_name or "",
**_serialize_skip_dates(skip, fallback=d),
}
for skip, recipe_name, group_name in rows
]
def list_all_skips(plan_date: Optional[str] = None) -> Dict[str, Any]:
"""Все исключения на дату для UI."""
return {
"trips": list_skips(plan_date),
"ingredients": list_ingredient_skips(plan_date),
"unloadingGroups": list_unloading_group_skips(plan_date),
}
def skip_trip(
recipe_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyTripSkip:
"""Исключить рейс из плана (upsert / restore)."""
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
existing = db.session.execute(
select(DailyTripSkip).where(
DailyTripSkip.recipe_id == recipe_id,
DailyTripSkip.is_deleted.is_(False),
)
).scalar_one_or_none()
now = utc_now_naive()
if existing is not None:
existing.plan_date = start
existing.valid_until = valid_until if valid_until != start else None
if existing.is_deleted:
existing.is_deleted = False
existing.deleted_at = None
existing.deleted_by = None
existing.updated_by = user
existing.updated_at = now
existing.version = int(existing.version or 1) + 1
db.session.commit()
return existing
row = DailyTripSkip(
recipe_id=recipe_id,
plan_date=start,
valid_until=valid_until if valid_until != start else None,
created_by=user,
updated_by=user,
created_at=now,
updated_at=now,
)
db.session.add(row)
db.session.commit()
return row
def unskip_trip(
recipe_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
"""Вернуть рейс в план на дату. True если skip был активен."""
d = _parse_plan_date(plan_date)
existing = db.session.execute(
select(DailyTripSkip).where(
DailyTripSkip.recipe_id == recipe_id,
*_skip_active_filters(DailyTripSkip, d),
)
).scalar_one_or_none()
if existing is None:
return False
return _soft_delete_skip_row(existing, user=user, table_name="daily_trip_skip")
def _soft_delete_skip_row(row, *, user: str, table_name: str) -> bool:
from app.services.sync_manager import enqueue_sync_queue_task
row.soft_delete(deleted_by_user=user)
row.version = int(row.version or 1) + 1
row.updated_by = user
row.updated_at = utc_now_naive()
db.session.commit()
enqueue_sync_queue_task(table_name, row.id, "delete", priority=1)
return True
def _upsert_part_skip(model, *, lookup_filters, create_fields, user: str):
existing = db.session.execute(
select(model).where(*lookup_filters, model.is_deleted.is_(False))
).scalar_one_or_none()
now = utc_now_naive()
if existing is not None:
for key, value in create_fields.items():
setattr(existing, key, value)
existing.updated_by = user
existing.updated_at = now
existing.version = int(existing.version or 1) + 1
db.session.commit()
return existing
deleted = db.session.execute(
select(model).where(*lookup_filters, model.is_deleted.is_(True))
).scalar_one_or_none()
if deleted is not None:
for key, value in create_fields.items():
setattr(deleted, key, value)
deleted.is_deleted = False
deleted.deleted_at = None
deleted.deleted_by = None
deleted.updated_by = user
deleted.updated_at = now
deleted.version = int(deleted.version or 1) + 1
db.session.commit()
return deleted
row = model(**create_fields, created_by=user, updated_by=user, created_at=now, updated_at=now)
db.session.add(row)
db.session.commit()
return row
def _soft_unskip_part(model, *, lookup_filters, user: str, table_name: str) -> bool:
existing = db.session.execute(
select(model).where(*lookup_filters, model.is_deleted.is_(False))
).scalar_one_or_none()
if existing is None:
return False
return _soft_delete_skip_row(existing, user=user, table_name=table_name)
def skip_ingredient(
recipe_id: str,
ingredient_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyIngredientSkip:
"""Исключить компонент рейса из плана."""
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
ingredient = db.session.execute(
select(Ingredient).where(
Ingredient.id == ingredient_id,
Ingredient.recipe_id == recipe_id,
Ingredient.is_deleted.is_(False),
)
).scalar_one_or_none()
if ingredient is None:
raise LookupError("Компонент не найден")
return _upsert_part_skip(
DailyIngredientSkip,
lookup_filters=(
DailyIngredientSkip.recipe_id == recipe_id,
DailyIngredientSkip.ingredient_id == ingredient_id,
),
create_fields={
"recipe_id": recipe_id,
"ingredient_id": ingredient_id,
"plan_date": start,
"valid_until": valid_until if valid_until != start else None,
},
user=user,
)
def unskip_ingredient(
recipe_id: str,
ingredient_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
"""Вернуть компонент в план на дату."""
d = _parse_plan_date(plan_date)
return _soft_unskip_part(
DailyIngredientSkip,
lookup_filters=(
DailyIngredientSkip.recipe_id == recipe_id,
DailyIngredientSkip.ingredient_id == ingredient_id,
*_skip_active_filters(DailyIngredientSkip, d),
),
user=user,
table_name="daily_ingredient_skip",
)
def unskip_all_ingredient_parts(
recipe_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> int:
"""Снять все skip компонентов рейса, активные на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientSkip).where(
DailyIngredientSkip.recipe_id == recipe_id,
*_skip_active_filters(DailyIngredientSkip, d),
)
).scalars().all()
count = 0
for row in rows:
if _soft_delete_skip_row(row, user=user, table_name="daily_ingredient_skip"):
count += 1
return count
def skip_unloading_group(
recipe_id: str,
unloading_group_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyUnloadingGroupSkip:
"""Исключить группу выгрузки из плана."""
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
group = db.session.execute(
select(UnloadingGroup).where(
UnloadingGroup.id == unloading_group_id,
UnloadingGroup.recipe_id == recipe_id,
UnloadingGroup.is_deleted.is_(False),
)
).scalar_one_or_none()
if group is None:
raise LookupError("Группа выгрузки не найдена")
return _upsert_part_skip(
DailyUnloadingGroupSkip,
lookup_filters=(
DailyUnloadingGroupSkip.recipe_id == recipe_id,
DailyUnloadingGroupSkip.unloading_group_id == unloading_group_id,
),
create_fields={
"recipe_id": recipe_id,
"unloading_group_id": unloading_group_id,
"plan_date": start,
"valid_until": valid_until if valid_until != start else None,
},
user=user,
)
def unskip_unloading_group(
recipe_id: str,
unloading_group_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
"""Вернуть группу выгрузки в план на дату."""
d = _parse_plan_date(plan_date)
return _soft_unskip_part(
DailyUnloadingGroupSkip,
lookup_filters=(
DailyUnloadingGroupSkip.recipe_id == recipe_id,
DailyUnloadingGroupSkip.unloading_group_id == unloading_group_id,
*_skip_active_filters(DailyUnloadingGroupSkip, d),
),
user=user,
table_name="daily_unloading_group_skip",
)
def unskip_all_unloading_group_parts(
recipe_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> int:
"""Снять все skip групп выгрузки рейса, активные на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyUnloadingGroupSkip).where(
DailyUnloadingGroupSkip.recipe_id == recipe_id,
*_skip_active_filters(DailyUnloadingGroupSkip, d),
)
).scalars().all()
count = 0
for row in rows:
if _soft_delete_skip_row(row, user=user, table_name="daily_unloading_group_skip"):
count += 1
return count
def recipe_part_skip_flags(
recipe_id: str, plan_date: Optional[str] = None
) -> tuple[bool, bool]:
"""Есть ли skip ингредиента / группы выгрузки у рейса на дату."""
skipped_ings = get_skipped_ingredient_ids(plan_date)
skipped_grps = get_skipped_unloading_group_ids(plan_date)
return bool(skipped_ings.get(recipe_id)), bool(skipped_grps.get(recipe_id))
@@ -0,0 +1 @@
"""Zootech lab module (Neoton tab integration)."""
@@ -0,0 +1,4 @@
from .engine import calculate_ration
from .diff import compare_master_execution
__all__ = ["calculate_ration", "compare_master_execution"]
@@ -0,0 +1,112 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.derived import apply_content_derived
from app.modules.zootech.lab.calc.nutrients import norm_diff, weighted_average
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
def _compound_active(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
l
for l in lines
if l.get("in_compound") and (l.get("daily_kg") or 0) > 0
]
def _sum_kg(lines: list[dict[str, Any]]) -> float:
return sum(float(l.get("daily_kg") or 0) for l in lines)
def _compound_indicators(
lines: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | None]],
) -> list[dict[str, Any]]:
content_by_key: dict[str, float | None] = {}
for defn in RATION_ALL_INDICATORS:
if defn.get("derived"):
continue
content_by_key[defn["key"]] = weighted_average(
lines,
total_kg,
defn["nutrient_keys"],
indicator_key=defn.get("key"),
)
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
content_by_key[defn["key"]] = apply_content_derived(
content_by_key,
defn,
compound_mode=True,
)
rows = []
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
content = content_by_key.get(key)
bounds = norms.get(key, {})
min_v = bounds.get("min")
max_v = bounds.get("max")
diff = norm_diff(content, min_v, max_v)
if content is None and min_v is None and max_v is None:
continue
rows.append(
{
"key": key,
"label": defn["label"],
"unit": defn["unit"],
"min": min_v,
"max": max_v,
"content": content,
"diff": diff,
}
)
return rows
def calculate_compound_feed(
lines: list[dict[str, Any]],
norms: dict[str, dict[str, float | None]] | None = None,
*,
profile_mass_kg: float | None = None,
heads_per_trip: int = 1,
) -> dict[str, Any] | None:
del profile_mass_kg, heads_per_trip
active = _compound_active(lines)
if not active:
return None
total_kg = _sum_kg(active)
cost = None
any_cost = False
for line in active:
kg = line.get("daily_kg")
price = line.get("price_per_kg")
if kg is None or price is None:
continue
cost = (cost or 0) + float(kg) * float(price)
any_cost = True
return {
"totals": [
{"key": "compound_kg", "label": "Масса комбикорма, кг", "value": total_kg},
{
"key": "compound_cost",
"label": "Стоимость комбикорма",
"value": cost if any_cost else None,
},
],
"indicators": _compound_indicators(active, total_kg, norms or {}),
"lines": [
{
"ingredient_name": line.get("ingredient_name") or "",
"daily_kg": line.get("daily_kg"),
"share_pct": (
(float(line["daily_kg"]) / total_kg) * 100
if total_kg > 0 and line.get("daily_kg") is not None
else None
),
}
for line in active
],
}
@@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.nutrients import rnb
def apply_content_derived(
content_by_key: dict[str, float | None],
defn: dict[str, Any],
*,
total_kg: float = 0,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
compound_mode: bool = False,
) -> float | None:
derived = defn.get("derived")
if derived == "alias":
return content_by_key.get(defn.get("alias_of"))
if derived == "pct_of_dm":
src = content_by_key.get(defn.get("from_key"))
dm = content_by_key.get("dry_matter")
if src is None or not dm or dm <= 0:
return None
return src / dm * 100.0
if derived == "g_per_kg_dm":
src = content_by_key.get(defn.get("from_key"))
dm = content_by_key.get("dry_matter")
if src is None or not dm or dm <= 0:
return None
if compound_mode:
return src
return src / (dm / 1000.0)
if derived == "nel_per_kg_dm":
dm = content_by_key.get("dry_matter")
nel = content_by_key.get("nel")
if not dm or dm <= 0 or nel is None:
return None
if compound_mode:
return nel
return nel / (dm / 1000.0)
if derived == "ratio":
num = content_by_key.get(defn.get("ratio_num"))
den = content_by_key.get(defn.get("ratio_den"))
if num is None or den is None or den == 0:
return None
return num / den
if derived == "dm_pct_bw":
dm = content_by_key.get("dry_matter")
if dm is None or not profile_mass_kg or profile_mass_kg <= 0:
return None
return (dm / 1000.0 / profile_mass_kg) * 100.0
if derived == "ration_pct_bw":
if not profile_mass_kg or profile_mass_kg <= 0 or total_kg <= 0:
return None
heads = max(int(heads_per_trip or 1), 1)
kg_per_head = total_kg / heads
return (kg_per_head / profile_mass_kg) * 100.0
if derived in ("rnb", "bra_rnb"):
return rnb(
content_by_key.get("crude_protein"),
content_by_key.get("usp"),
)
return None
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.constants import DIFF_TOLERANCE_KG
def compare_master_execution(
master_lines: list[dict[str, Any]],
execution_lines: list[dict[str, Any]],
) -> dict[str, Any]:
master_map: dict[str, float] = {}
for line in master_lines:
if not line.get("in_ration"):
continue
cid = line.get("component_id")
if not cid:
continue
master_map[str(cid)] = float(line.get("daily_kg") or 0)
exec_map: dict[str, float] = {}
for line in execution_lines:
cid = line.get("component_id")
if not cid:
continue
exec_map[str(cid)] = float(line.get("daily_kg_total") or 0)
all_ids = set(master_map) | set(exec_map)
diff_lines = []
has_changes = False
for cid in sorted(all_ids):
m = master_map.get(cid)
e = exec_map.get(cid)
reasons = []
if m is None:
reasons.append("missing_in_master")
has_changes = True
if e is None:
reasons.append("missing_in_execution")
has_changes = True
if m is not None and e is not None and abs(m - e) > DIFF_TOLERANCE_KG:
reasons.append("kg_mismatch")
has_changes = True
if reasons:
diff_lines.append(
{
"component_id": cid,
"master_kg": m,
"execution_kg": e,
"reasons": reasons,
}
)
return {"has_changes": has_changes, "lines": diff_lines}
@@ -0,0 +1,184 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from app.modules.zootech.lab.calc.compound import calculate_compound_feed
from app.modules.zootech.lab.calc.derived import apply_content_derived
from app.modules.zootech.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
from app.modules.zootech.lab.constants import RATION_TOTAL_KEYS
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
def _active_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
l
for l in lines
if l.get("in_ration") and (l.get("daily_kg") or 0) > 0
]
def _sum_kg(lines: list[dict[str, Any]]) -> float:
return sum(float(l.get("daily_kg") or 0) for l in lines)
def _sum_cost(lines: list[dict[str, Any]]) -> float | None:
total = 0.0
any_cost = False
for line in lines:
kg = line.get("daily_kg")
price = line.get("price_per_kg")
if kg is None or price is None:
continue
total += float(kg) * float(price)
any_cost = True
return total if any_cost else None
def _sum_ration_percent(lines: list[dict[str, Any]], total_kg: float) -> float | None:
if total_kg <= 0:
return None
return sum((float(l.get("daily_kg") or 0) / total_kg) * 100 for l in lines)
def _compute_totals(
ration_type: str,
active: list[dict[str, Any]],
total_kg: float,
) -> list[dict[str, Any]]:
defs = RATION_TOTAL_KEYS.get(ration_type, RATION_TOTAL_KEYS["BEEF"])
ration_kg = _sum_kg(active)
values = {
"total_kg": total_kg if total_kg > 0 else None,
"ration_kg": ration_kg if ration_kg > 0 else None,
"ration_pct_sum": _sum_ration_percent(active, total_kg),
"cost_total": _sum_cost(active),
}
return [{"key": d["key"], "label": d["label"], "value": values.get(d["key"])} for d in defs]
def _indicator_content(
defn: dict[str, Any],
active: list[dict[str, Any]],
total_kg: float,
*,
heads_per_trip: int,
) -> float | None:
key = defn.get("key")
if defn.get("aggregation") == "weighted_avg":
return weighted_average(
active,
total_kg,
defn["nutrient_keys"],
indicator_key=key,
)
return daily_intake_total(
active,
defn["nutrient_keys"],
heads_per_trip=heads_per_trip,
indicator_key=key,
)
def _compute_indicators(
active: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | None]],
*,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
) -> list[dict[str, Any]]:
content_by_key: dict[str, float | None] = {}
for defn in RATION_ALL_INDICATORS:
if defn.get("derived"):
continue
content_by_key[defn["key"]] = _indicator_content(
defn, active, total_kg, heads_per_trip=heads_per_trip
)
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
content_by_key[defn["key"]] = apply_content_derived(
content_by_key,
defn,
total_kg=total_kg,
heads_per_trip=heads_per_trip,
profile_mass_kg=profile_mass_kg,
)
rows = []
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
content = content_by_key.get(key)
bounds = norms.get(key, {})
min_v = bounds.get("min")
max_v = bounds.get("max")
diff = norm_diff(content, min_v, max_v)
if content is None and min_v is None and max_v is None:
continue
rows.append(
{
"key": key,
"label": defn["label"],
"unit": defn["unit"],
"min": min_v,
"max": max_v,
"content": content,
"diff": diff,
}
)
return rows
def _missing_nutrient_warnings(active: list[dict[str, Any]]) -> list[str]:
warnings: list[str] = []
for line in active:
nutrients = line.get("nutrients") or {}
cp = get_nutrient_value(
line.get("dry_matter"),
nutrients,
["Сыр. Протеин"],
indicator_key="crude_protein",
)
if cp is not None:
continue
name = line.get("ingredient_name") or line.get("component_id") or "?"
warnings.append(f"nutrients_missing:{name}")
return warnings
def calculate_ration(
ration_type: str,
lines: list[dict[str, Any]],
norms: dict[str, dict[str, float | None]] | None = None,
*,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
) -> dict[str, Any]:
norms = norms or {}
errors: list[str] = []
active = _active_lines(lines)
total_kg = _sum_kg(active)
heads = max(int(heads_per_trip or 1), 1)
if not active:
errors.append("Нет строк сырья «в рационе» с дозировкой кг/день")
warnings = _missing_nutrient_warnings(active)
compound = calculate_compound_feed(
lines, norms, profile_mass_kg=profile_mass_kg, heads_per_trip=heads
)
return {
"calculated_at": datetime.now(timezone.utc).isoformat(),
"engine": "native",
"totals": _compute_totals(ration_type, active, total_kg),
"indicators": _compute_indicators(
active,
total_kg,
norms,
heads_per_trip=heads,
profile_mass_kg=profile_mass_kg,
),
"compound": compound,
"errors": errors,
"warnings": warnings,
}
@@ -0,0 +1,211 @@
"""Группы кормов для авторациона — канонический тип компонента + legacy-маппинг."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.wesp_bridge_models import Component
FEED_GROUPS: tuple[dict[str, Any], ...] = (
{
"id": "rough",
"label": "База — грубые",
"shortLabel": "База",
"required": True,
"minPick": 1,
"hint": "Без каркаса партия не взлетит. Я не бизнесмен — я специалист.",
"step": 1,
},
{
"id": "succulent",
"label": "Влага — сочные",
"shortLabel": "Влага",
"required": False,
"minPick": 0,
"hint": "Сочное сырьё. Можно не мешать — но чистота пострадает.",
"step": 2,
},
{
"id": "concentrate",
"label": "Энергия — концентраты",
"shortLabel": "Энергия",
"required": False,
"minPick": 0,
"hint": "Концентрат дозируй как реагент — точно. Держись подальше от моей территории.",
"step": 3,
},
{
"id": "other",
"label": "Добавки",
"shortLabel": "Добавки",
"required": False,
"minPick": 0,
"hint": "Минералы и премикс. Необязательно. Но Хайзенберг бы не пропустил.",
"step": 4,
},
)
# Канонические значения component.type (выбор в /components)
FEED_COMPONENT_TYPES: tuple[dict[str, str], ...] = (
{
"value": "Грубые корма",
"feedGroup": "rough",
"description": "Сено, солома.",
},
{
"value": "Сочные корма",
"feedGroup": "succulent",
"description": "Силос, корнеплоды.",
},
{
"value": "Концентрированные",
"feedGroup": "concentrate",
"description": "Зерно, комбикорм, жмых, шрот",
},
{
"value": "Добавки",
"feedGroup": "other",
"description": "Премикс, минералы, витамины, КЖП",
},
)
_GROUP_BY_ID = {g["id"]: g for g in FEED_GROUPS}
_LEGACY_TYPE_TO_GROUP: dict[str, str] = {
"зерновые": "concentrate",
"энергетические": "concentrate",
"белковые": "concentrate",
"минеральные": "other",
"витаминные": "other",
}
# Старый тип «Объемные корма» — уточните до Грубые/Сочные; эвристика по имени
_ROUGH_NAME_KEYS = ("солом", "сено", "hay", "straw")
_SUCCULENT_NAME_KEYS = ("силос", "сенаж", "сочн", "корнеплод", "свекл", "морков", "тыкв", "зелен")
_CONCENTRATE_NAME_KEYS = ("зерн", "концентр", "комбикорм", "комбик", "жмых", "шрот", "дробин", "пивн")
def _norm(text: str | None) -> str:
return (text or "").strip().lower()
def _canonical_type_map() -> dict[str, str]:
return {_norm(t["value"]): t["feedGroup"] for t in FEED_COMPONENT_TYPES}
def _canonical_values() -> list[str]:
return [t["value"] for t in FEED_COMPONENT_TYPES]
def list_component_feed_types() -> list[dict[str, str]]:
return [
{
"value": t["value"],
"feedGroup": t["feedGroup"],
"description": t["description"],
"groupLabel": _GROUP_BY_ID[t["feedGroup"]]["label"],
}
for t in FEED_COMPONENT_TYPES
]
def is_canonical_feed_type(component_type: str | None) -> bool:
return _norm(component_type) in _canonical_type_map()
def feed_group_for_type(component_type: str | None) -> str | None:
"""Группа авторациона по component.type (канон или legacy)."""
ctype = _norm(component_type)
if not ctype:
return None
canonical = _canonical_type_map().get(ctype)
if canonical:
return canonical
if ctype in ("объемные корма", "объёмные корма"):
return None
return _LEGACY_TYPE_TO_GROUP.get(ctype)
def classify_feed_group(comp: Component) -> str:
"""Группа для авторациона: сначала component.type, иначе эвристика по имени (legacy)."""
by_type = feed_group_for_type(comp.type)
if by_type:
return by_type
name = _norm(comp.name)
def has_any(keys: tuple[str, ...]) -> bool:
return any(k in name for k in keys)
if has_any(_ROUGH_NAME_KEYS):
return "rough"
if has_any(_SUCCULENT_NAME_KEYS):
return "succulent"
if has_any(_CONCENTRATE_NAME_KEYS):
return "concentrate"
if _norm(comp.type) in ("объемные корма", "объёмные корма"):
return "succulent"
return "other"
def list_feed_groups_api() -> list[dict[str, Any]]:
return [
{
"id": g["id"],
"label": g["label"],
"shortLabel": g["shortLabel"],
"required": g["required"],
"minPick": g["minPick"],
"hint": g["hint"],
"step": g["step"],
}
for g in FEED_GROUPS
]
def parse_group_selections(raw: Any) -> dict[str, list[str]]:
if not isinstance(raw, dict):
return {}
out: dict[str, list[str]] = {}
for gid in _GROUP_BY_ID:
vals = raw.get(gid) or []
if isinstance(vals, list):
out[gid] = [str(x) for x in vals if x]
return out
def flatten_group_selections(selections: dict[str, list[str]]) -> list[str]:
seen: list[str] = []
for gid in _GROUP_BY_ID:
for cid in selections.get(gid) or []:
if cid not in seen:
seen.append(cid)
return seen
def validate_group_selections(selections: dict[str, list[str]]) -> list[str]:
errors: list[str] = []
for g in FEED_GROUPS:
gid = g["id"]
picked = selections.get(gid) or []
if g["required"] and len(picked) < int(g["minPick"]):
errors.append(f"{gid}:need_{g['minPick']}")
total = len(flatten_group_selections(selections))
if total < 3:
errors.append("pool:need_3")
return errors
def triplet_meets_group_rules(
triplet_ids: set[str],
selections: dict[str, list[str]],
) -> bool:
for g in FEED_GROUPS:
if not g["required"]:
continue
pool = set(selections.get(g["id"]) or [])
if not pool:
continue
if not triplet_ids & pool:
return False
return True
@@ -0,0 +1,417 @@
from __future__ import annotations
import itertools
import time
from dataclasses import dataclass, field
from typing import Any
from app.modules.zootech.lab.calc.engine import calculate_ration
from app.modules.zootech.lab.calc.feed_groups import (
FEED_GROUPS,
flatten_group_selections,
triplet_meets_group_rules,
validate_group_selections,
)
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group as _classify_feed_group
from app.modules.zootech.lab.calc.formulate_optimize import optimize_shares_for_triplet
from app.modules.zootech.lab.calc.formulate_score import (
build_calc_lines,
build_triplet_score_model,
score_model_shares,
violation_score,
)
from app.modules.zootech.lab.calc.formulate_validate import validate_component, validate_components
from app.modules.zootech.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
from app.modules.zootech.lab.calc.nutrients import get_nutrient_value
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.services.component_nutrients import nutrients_calc_dict_batch
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
from app.modules.zootech.wesp_bridge_models import Component
@dataclass
class FormulateRequest:
profile_id: str
candidate_ids: list[str] = field(default_factory=list)
group_selections: dict[str, list[str]] = field(default_factory=dict)
main_feed_ids: list[str] = field(default_factory=list) # legacy → rough
mass_kg: float | None = None
milk_yield_kg: float | None = None
heads_per_trip: int | None = None
total_kg_per_head: float = 7.3
optimize_keys: list[str] = field(default_factory=list)
objective: str = "min_cost"
cost_weight: float = 100.0
grid_step: float = 0.1
prefilter_k: int = 18
min_share: float = 0.05
norms_method: str = "wesp"
norms_params: dict[str, Any] = field(default_factory=dict)
_DEFAULT_OPTIMIZE_KEYS = (
"dry_matter",
"usp",
"nel",
"crude_protein",
"rnb",
"nfc_pct_dm_uk",
)
def _indicator_def(key: str) -> dict[str, Any] | None:
for defn in RATION_ALL_INDICATORS:
if defn["key"] == key:
return defn
return None
def _resolve_norms(profile: LabAnimalProfile, req: FormulateRequest) -> tuple[dict, dict]:
stored = load_norms_dict(profile.id)
mass = req.mass_kg if req.mass_kg is not None else profile.mass_kg
milk = req.milk_yield_kg if req.milk_yield_kg is not None else profile.milk_yield_kg
from app.modules.zootech.lab.services.norms_params import load_norms_params
method = normalize_norms_method(req.norms_method or profile.norms_method)
params = load_norms_params(profile)
if req.norms_params:
merged = {
"milkFatPct": params.milk_fat_pct,
"lactationNo": params.lactation_no,
"lactationStage": params.lactation_stage,
"bodyCondition": params.body_condition,
"housingSystem": params.housing_system,
"koncOeSv": params.konc_oe_sv,
}
merged.update(req.norms_params)
params = NormsParams.from_dict(merged)
resolved, meta = resolve_norms(
NormsResolveRequest(
method=method,
stored=stored,
mass_kg=mass,
milk_yield_kg=milk,
ration_type=profile.ration_type,
force_dynamic=method == "wesp",
params=params,
)
)
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
return resolved, {"normsMethod": method, "dynamicNorms": dynamic, "normsMeta": meta.get("meta")}
def _rough_component_value(
comp: Component,
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
nutrient_cache: dict[str, dict[str, float]],
) -> float | None:
nutrients = nutrient_cache.get(comp.id, {})
dm_pct = comp.dry_matter
total = 0.0
counted = 0
for key in optimize_keys:
defn = _indicator_def(key)
if defn is None or defn.get("derived"):
continue
bounds = norms.get(key) or {}
target_min = bounds.get("min")
target_max = bounds.get("max")
if target_min is None and target_max is None:
continue
target = None
if target_min is not None and target_max is not None:
target = (float(target_min) + float(target_max)) / 2.0
elif target_max is not None:
target = float(target_max) * 0.9
elif target_min is not None:
target = float(target_min) * 1.1
if target is None or target == 0:
continue
val = get_nutrient_value(
dm_pct,
nutrients,
defn.get("nutrient_keys") or [],
indicator_key=key,
)
if val is None:
continue
rel = (float(val) - target) / abs(target)
total += rel * rel
counted += 1
return total / counted if counted else None
def _prefilter_candidates(
candidates: list[Component],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
nutrient_cache: dict[str, dict[str, float]],
*,
prefilter_k: int,
must_keep_ids: set[str] | None = None,
) -> list[Component]:
must_keep_ids = must_keep_ids or set()
pinned = [c for c in candidates if c.id in must_keep_ids]
rest = [c for c in candidates if c.id not in must_keep_ids]
slots = max(prefilter_k - len(pinned), 0)
if len(candidates) <= prefilter_k:
return candidates
if slots <= 0:
return pinned[:prefilter_k]
prices = [float(c.price) for c in rest if c.price is not None]
median_price = sorted(prices)[len(prices) // 2] if prices else 1.0
if median_price <= 0:
median_price = 1.0
scored: list[tuple[float, Component]] = []
for comp in rest:
price = float(comp.price) if comp.price is not None else median_price
cost_part = price / median_price
nutrient_part = _rough_component_value(comp, optimize_keys, norms, nutrient_cache)
if nutrient_part is None:
score = cost_part
else:
score = 0.4 * cost_part + 0.6 * nutrient_part
scored.append((score, comp))
scored.sort(key=lambda x: x[0])
return pinned + [comp for _, comp in scored[:slots]]
def _load_profile(profile_id: str) -> LabAnimalProfile:
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
return profile
def _load_eligible_components(candidate_ids: list[str]) -> tuple[list[Component], list[dict[str, Any]]]:
unique = list(dict.fromkeys(candidate_ids))
validations = validate_components(unique)
ineligible = [v for v in validations if not v["eligible"]]
if ineligible:
raise ValueError("ineligible_components", ineligible)
comps: list[Component] = []
for cid in unique:
comp = Component.query.filter_by(id=cid, is_deleted=False).first()
if comp is None:
raise ValueError("component_not_found", cid)
comps.append(comp)
return comps, validations
def _resolve_group_selections(req: FormulateRequest) -> dict[str, list[str]]:
selections = dict(req.group_selections or {})
if req.main_feed_ids and not selections.get("rough"):
selections["rough"] = list(req.main_feed_ids)
return selections
def formulate(req: FormulateRequest) -> dict[str, Any]:
group_selections = _resolve_group_selections(req)
candidate_ids = list(req.candidate_ids)
if group_selections:
pool_errors = validate_group_selections(group_selections)
if pool_errors:
raise ValueError("group_selection_invalid", pool_errors)
candidate_ids = flatten_group_selections(group_selections)
if len(candidate_ids) < 3:
raise ValueError("candidate_ids_min_3")
if len(set(candidate_ids)) != len(candidate_ids):
raise ValueError("candidate_ids_duplicate")
profile = _load_profile(req.profile_id)
candidates, _ = _load_eligible_components(candidate_ids)
nutrient_cache = nutrients_calc_dict_batch(candidate_ids)
norms, dynamic = _resolve_norms(profile, req)
optimize_keys = req.optimize_keys or list(_DEFAULT_OPTIMIZE_KEYS)
heads = max(int(req.heads_per_trip or 10), 1)
herd_scale = float(req.total_kg_per_head) * heads
profile_mass_kg = req.mass_kg if req.mass_kg is not None else profile.mass_kg
must_keep: set[str] = set()
for g in FEED_GROUPS:
if g["required"]:
must_keep.update(group_selections.get(g["id"]) or [])
prefilter_applied = len(candidates) > req.prefilter_k
shortlist = _prefilter_candidates(
candidates,
optimize_keys,
norms,
nutrient_cache,
prefilter_k=req.prefilter_k,
must_keep_ids=must_keep,
)
started = time.perf_counter()
evaluations = 0
triplets_evaluated = 0
triplet_winners: list[dict[str, Any]] = []
def _eval_triplet(
triplet: tuple[Component, Component, Component],
) -> dict[str, Any] | None:
nonlocal evaluations
model = build_triplet_score_model(
triplet,
nutrient_cache=nutrient_cache,
norms=norms,
optimize_keys=optimize_keys,
herd_scale=herd_scale,
heads=heads,
cost_weight=req.cost_weight,
profile_mass_kg=profile_mass_kg,
)
def score_fn(shares: tuple[float, float, float]) -> float:
_violation, _cost_head, score = score_model_shares(model, shares)
return score
opt = optimize_shares_for_triplet(
triplet,
score_fn,
min_share=req.min_share,
grid_step=req.grid_step,
)
if opt is None:
return None
evaluations += opt.evaluations
violation, cost_head, score = score_model_shares(model, opt.shares)
lines = build_calc_lines(triplet, opt.shares, herd_scale, nutrient_cache)
daily = [opt.shares[i] * herd_scale for i in range(3)]
return {
"score": score,
"violation": violation,
"costHead": cost_head,
"costTotal": cost_head * heads,
"triplet": triplet,
"shares": opt.shares,
"daily": daily,
"lines": lines,
}
for triplet in itertools.combinations(shortlist, 3):
triplet_ids = {c.id for c in triplet}
if not triplet_meets_group_rules(triplet_ids, group_selections):
continue
triplets_evaluated += 1
best_triplet_result = _eval_triplet(triplet)
if best_triplet_result is None:
continue
triplet_winners.append(best_triplet_result)
if not triplet_winners:
if group_selections:
raise ValueError("no_feasible_solution_groups")
raise ValueError("no_feasible_solution")
triplet_winners.sort(key=lambda x: x["score"])
best = triplet_winners[0]
full_calc = calculate_ration(
profile.ration_type or "DAIRY",
best["lines"],
norms,
heads_per_trip=heads,
profile_mass_kg=profile_mass_kg,
)
best["calc"] = full_calc
best["violation"] = violation_score(
full_calc.get("indicators") or [],
optimize_keys,
norms,
)
best["score"] = best["violation"] * req.cost_weight + best["costHead"]
duration_ms = int((time.perf_counter() - started) * 1000)
alternatives = [
{
"componentIds": [c.id for c in item["triplet"]],
"names": [c.name for c in item["triplet"]],
"score": item["score"],
"violation": item["violation"],
"costPerHead": item["costHead"],
}
for item in triplet_winners[:3]
]
alternatives.sort(key=lambda x: x["score"])
alternatives = alternatives[:3]
result_lines = []
for i, comp in enumerate(best["triplet"]):
s1, s2, s3 = best["shares"]
share = (s1, s2, s3)[i]
result_lines.append(
{
"componentId": comp.id,
"name": comp.name,
"dailyKg": round(best["daily"][i], 4),
"sharePct": round(share * 100, 2),
"pricePerKg": comp.price,
"dryMatterPct": comp.dry_matter,
}
)
violation = best["violation"]
return {
"lines": result_lines,
"candidatePoolSize": len(candidates),
"groupSelections": group_selections,
"shortlistedIds": [c.id for c in shortlist],
"costTotal": best["costTotal"],
"costPerHead": best["costHead"],
"score": best["score"],
"violation": violation,
"feasible": violation < 0.01,
"indicators": best["calc"].get("indicators") or [],
"totals": best["calc"].get("totals") or [],
"optimizeKeys": optimize_keys,
"dynamicNorms": dynamic.get("dynamicNorms") or None,
"normsMethod": dynamic.get("normsMethod"),
"normsMeta": dynamic.get("normsMeta"),
"alternatives": alternatives,
"searchStats": {
"evaluations": evaluations,
"tripletsEvaluated": triplets_evaluated,
"durationMs": duration_ms,
"prefilterApplied": prefilter_applied,
"prefilterK": req.prefilter_k,
"scoreEngine": "fast",
"optimizer": "slsqp",
"normsMethod": dynamic.get("normsMethod"),
},
}
def list_formulate_components() -> list[dict[str, Any]]:
rows = (
Component.query.filter_by(is_active=True, is_deleted=False)
.order_by(Component.name)
.all()
)
out: list[dict[str, Any]] = []
for comp in rows:
v = validate_component(comp.id)
feed_group = _classify_feed_group(comp)
out.append(
{
"id": comp.id,
"name": comp.name,
"type": comp.type,
"feedGroup": feed_group,
"eligible": v["eligible"],
"missing": v["missing"],
"warnings": v["warnings"],
"dryMatterPct": v["dryMatterPct"],
"hasPrice": v["hasPrice"],
"price": v["price"],
"mainFeedDmGPerKg": v.get("mainFeedDmGPerKg"),
"isMainFeed": v.get("isMainFeed", False),
}
)
return out
@@ -0,0 +1,112 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from scipy.optimize import minimize
from app.modules.zootech.wesp_bridge_models import Component
ShareTuple = tuple[float, float, float]
ScoreFn = Callable[[ShareTuple], float]
@dataclass
class OptimizeSharesResult:
shares: ShareTuple
score: float
evaluations: int
def _normalize_shares(s1: float, s2: float, min_share: float) -> ShareTuple | None:
s3 = 1.0 - s1 - s2
ms = max(min_share, 0.0)
if s1 < ms - 1e-9 or s2 < ms - 1e-9 or s3 < ms - 1e-9:
return None
if abs(s1 + s2 + s3 - 1.0) > 1e-6:
return None
return (round(s1, 8), round(s2, 8), round(s3, 8))
def _start_points(min_share: float, grid_step: float) -> list[tuple[float, float]]:
"""Multi-start seeds: simplex center, corners, and a few grid_step hints."""
ms = max(min_share, 0.0)
max_pair = max(1.0 - 2 * ms, ms)
center = round((1.0 - ms) / 3.0, 6)
points: list[tuple[float, float]] = [
(center, center),
(ms, ms),
(max_pair, ms),
(ms, max_pair),
(max_pair, max_pair),
]
step = max(grid_step, 0.1)
if ms <= step <= max_pair:
points.append((step, ms))
points.append((ms, step))
deduped: list[tuple[float, float]] = []
seen: set[tuple[float, float]] = set()
for s1, s2 in points:
if s1 + s2 > 1.0 - ms + 1e-9:
continue
key = (round(s1, 6), round(s2, 6))
if key in seen:
continue
seen.add(key)
deduped.append(key)
return deduped
def optimize_shares_for_triplet(
triplet: tuple[Component, Component, Component],
score_fn: ScoreFn,
*,
min_share: float,
grid_step: float = 0.1,
) -> OptimizeSharesResult | None:
del triplet
ms = max(min_share, 0.0)
max_s1 = max(1.0 - 2 * ms, ms)
evaluations = 0
def objective(x: Any) -> float:
nonlocal evaluations
evaluations += 1
shares = _normalize_shares(float(x[0]), float(x[1]), ms)
if shares is None:
return 1e18
return score_fn(shares)
bounds = [(ms, max_s1), (ms, max_s1)]
constraints = [{"type": "ineq", "fun": lambda x: 1.0 - ms - float(x[0]) - float(x[1])}]
best_score: float | None = None
best_shares: ShareTuple | None = None
for s1, s2 in _start_points(ms, grid_step):
if s1 + s2 > 1.0 - ms + 1e-9:
continue
try:
res = minimize(
objective,
[s1, s2],
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-8, "maxiter": 40},
)
except Exception:
continue
if not res.success and res.fun >= 1e17:
continue
shares = _normalize_shares(float(res.x[0]), float(res.x[1]), ms)
if shares is None:
continue
score = float(res.fun)
if best_score is None or score < best_score:
best_score = score
best_shares = shares
if best_shares is None or best_score is None:
return None
return OptimizeSharesResult(shares=best_shares, score=best_score, evaluations=evaluations)
@@ -0,0 +1,371 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.modules.zootech.lab.calc.derived import apply_content_derived
from app.modules.zootech.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
from app.modules.zootech.wesp_bridge_models import Component
def resolve_score_closure(optimize_keys: list[str]) -> frozenset[str]:
"""Collect base + derived indicator keys needed to score optimize_keys."""
needed: set[str] = set(optimize_keys)
changed = True
while changed:
changed = False
for key in list(needed):
defn = indicator_by_key(key)
if defn is None:
continue
derived = defn.get("derived")
if derived == "alias":
dep = defn.get("alias_of")
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived in ("pct_of_dm", "g_per_kg_dm", "nel_per_kg_dm"):
dep = defn.get("from_key")
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived in ("rnb", "bra_rnb"):
for dep in ("crude_protein", "usp"):
if dep not in needed:
needed.add(dep)
changed = True
elif derived == "ratio":
for dep in (defn.get("ratio_num"), defn.get("ratio_den")):
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived == "dm_pct_bw":
if "dry_matter" not in needed:
needed.add("dry_matter")
changed = True
return frozenset(needed)
def _indicator_content(
defn: dict[str, Any],
active: list[dict[str, Any]],
total_kg: float,
*,
heads_per_trip: int,
) -> float | None:
key = defn.get("key")
if defn.get("aggregation") == "weighted_avg":
return weighted_average(
active,
total_kg,
defn["nutrient_keys"],
indicator_key=key,
)
return daily_intake_total(
active,
defn["nutrient_keys"],
heads_per_trip=heads_per_trip,
indicator_key=key,
)
def compute_score_indicators(
active: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
*,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
) -> list[dict[str, Any]]:
closure = resolve_score_closure(optimize_keys)
content_by_key: dict[str, float | None] = {}
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or defn.get("derived"):
continue
content_by_key[key] = _indicator_content(
defn, active, total_kg, heads_per_trip=heads_per_trip
)
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or not defn.get("derived"):
continue
content_by_key[key] = apply_content_derived(
content_by_key,
defn,
total_kg=total_kg,
heads_per_trip=heads_per_trip,
profile_mass_kg=profile_mass_kg,
)
rows: list[dict[str, Any]] = []
for key in optimize_keys:
if key not in closure:
continue
defn = indicator_by_key(key)
if defn is None:
continue
content = content_by_key.get(key)
bounds = norms.get(key, {})
min_v = bounds.get("min")
max_v = bounds.get("max")
diff = norm_diff(content, min_v, max_v)
if content is None and min_v is None and max_v is None:
continue
rows.append(
{
"key": key,
"label": defn["label"],
"unit": defn["unit"],
"min": min_v,
"max": max_v,
"content": content,
"diff": diff,
}
)
return rows
def violation_score(
indicators: list[dict[str, Any]],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
) -> float:
by_key = {row.get("key"): row for row in indicators if row.get("key")}
total = 0.0
counted = 0
for key in optimize_keys:
bounds = norms.get(key) or {}
if bounds.get("min") is None and bounds.get("max") is None:
continue
row = by_key.get(key)
if row is None:
continue
diff = row.get("diff")
if diff is None:
diff = norm_diff(row.get("content"), bounds.get("min"), bounds.get("max"))
if diff is None:
continue
total += float(diff) ** 2
counted += 1
return total if counted else 0.0
def _violation_from_content(
content_by_key: dict[str, float | None],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
) -> float:
total = 0.0
counted = 0
for key in optimize_keys:
bounds = norms.get(key) or {}
if bounds.get("min") is None and bounds.get("max") is None:
continue
content = content_by_key.get(key)
diff = norm_diff(content, bounds.get("min"), bounds.get("max"))
if diff is None:
continue
total += float(diff) ** 2
counted += 1
return total if counted else 0.0
@dataclass
class TripletScoreModel:
triplet: tuple[Component, Component, Component]
nutrient_cache: dict[str, dict[str, float]]
herd_scale: float
heads: int
cost_weight: float
profile_mass_kg: float | None
optimize_keys: list[str]
norms: dict[str, dict[str, float | None]]
closure: frozenset[str]
intake_unit: dict[str, tuple[float | None, float | None, float | None]]
weighted_vals: dict[str, tuple[float | None, float | None, float | None]]
prices: tuple[float, float, float]
def build_triplet_score_model(
triplet: tuple[Component, Component, Component],
*,
nutrient_cache: dict[str, dict[str, float]],
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
herd_scale: float,
heads: int,
cost_weight: float,
profile_mass_kg: float | None,
) -> TripletScoreModel:
closure = resolve_score_closure(optimize_keys)
kg_per_share = herd_scale / max(heads, 1)
intake_unit: dict[str, tuple[float | None, float | None, float | None]] = {}
weighted_vals: dict[str, tuple[float | None, float | None, float | None]] = {}
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or defn.get("derived"):
continue
vals: list[float | None] = []
for comp in triplet:
nutrients = nutrient_cache.get(comp.id, {})
vals.append(
get_nutrient_value(
comp.dry_matter,
nutrients,
defn["nutrient_keys"],
indicator_key=key,
)
)
tup = (vals[0], vals[1], vals[2])
if defn.get("aggregation") == "weighted_avg":
weighted_vals[key] = tup
else:
intake_unit[key] = tuple(v * kg_per_share if v is not None else None for v in vals)
prices = tuple(
float(comp.price) if comp.price is not None else 0.0 for comp in triplet
)
return TripletScoreModel(
triplet=triplet,
nutrient_cache=nutrient_cache,
herd_scale=herd_scale,
heads=heads,
cost_weight=cost_weight,
profile_mass_kg=profile_mass_kg,
optimize_keys=optimize_keys,
norms=norms,
closure=closure,
intake_unit=intake_unit,
weighted_vals=weighted_vals,
prices=prices,
)
def _content_from_shares(
model: TripletScoreModel,
shares: tuple[float, float, float],
) -> dict[str, float | None]:
content_by_key: dict[str, float | None] = {}
total_kg = model.herd_scale
for key, coeffs in model.intake_unit.items():
parts = [
shares[i] * coeffs[i]
for i in range(3)
if coeffs[i] is not None
]
content_by_key[key] = sum(parts) if parts else None
for key, vals in model.weighted_vals.items():
num = 0.0
den = 0.0
for i in range(3):
if vals[i] is None:
continue
num += shares[i] * vals[i]
den += shares[i]
content_by_key[key] = (num / den) if den > 0 else None
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in model.closure or not defn.get("derived"):
continue
content_by_key[key] = apply_content_derived(
content_by_key,
defn,
total_kg=total_kg,
heads_per_trip=model.heads,
profile_mass_kg=model.profile_mass_kg,
)
return content_by_key
def score_model_shares(
model: TripletScoreModel,
shares: tuple[float, float, float],
) -> tuple[float, float, float]:
"""Return (violation, cost_head, score) without building line dicts."""
content = _content_from_shares(model, shares)
violation = _violation_from_content(content, model.optimize_keys, model.norms)
cost_total = sum(shares[i] * model.prices[i] * model.herd_scale for i in range(3))
cost_head = cost_total / max(model.heads, 1)
score = violation * model.cost_weight + cost_head
return violation, cost_head, score
def build_calc_lines(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
herd_scale: float,
nutrient_cache: dict[str, dict[str, float]],
) -> list[dict[str, Any]]:
lines: list[dict[str, Any]] = []
for i, comp in enumerate(triplet):
daily_kg = shares[i] * herd_scale
lines.append(
{
"component_id": comp.id,
"ingredient_name": comp.name,
"daily_kg": daily_kg,
"in_ration": True,
"in_compound": False,
"dry_matter": comp.dry_matter,
"price_per_kg": comp.price,
"nutrients": nutrient_cache.get(comp.id, {}),
}
)
return lines
def cost_per_head(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
herd_scale: float,
heads: int,
) -> float:
total = 0.0
any_cost = False
for i, comp in enumerate(triplet):
kg = shares[i] * herd_scale
price = comp.price
if kg is None or price is None:
continue
total += float(kg) * float(price)
any_cost = True
if not any_cost:
return 0.0
return total / max(heads, 1)
def score_triplet_shares(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
*,
nutrient_cache: dict[str, dict[str, float]],
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
herd_scale: float,
heads: int,
cost_weight: float,
profile_mass_kg: float | None,
) -> tuple[float, float, float, list[dict[str, Any]]]:
"""Return (violation, cost_head, score, lines)."""
model = build_triplet_score_model(
triplet,
nutrient_cache=nutrient_cache,
norms=norms,
optimize_keys=optimize_keys,
herd_scale=herd_scale,
heads=heads,
cost_weight=cost_weight,
profile_mass_kg=profile_mass_kg,
)
violation, cost_head, score = score_model_shares(model, shares)
lines = build_calc_lines(triplet, shares, herd_scale, nutrient_cache)
return violation, cost_head, score, lines
@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.calc.gfe_policies import default_om_digestibility_pct
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
from app.modules.zootech.lab.services.component_nutrients import derive_context_for_component, nutrients_full_dict, nutrients_is_empty
from app.modules.zootech.wesp_bridge_models import Component
_REQUIRED_EAV_KEYS = ("Сыр. Протеин", "Сырая клетч", "Сырой жир")
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
def main_feed_dm_g_per_kg(component_id: str | None) -> float | None:
if not component_id:
return None
full = nutrients_full_dict(component_id)
val = read_from_mapping(full, _MAIN_FEED_KEYS)
return float(val) if val is not None else None
def validate_component(component_id: str) -> dict[str, Any]:
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is None:
return {
"id": component_id,
"name": None,
"eligible": False,
"missing": ["component_not_found"],
"warnings": [],
"dryMatterPct": None,
"hasPrice": False,
"price": None,
}
missing: list[str] = []
warnings: list[str] = []
dry_matter_pct = comp.dry_matter
if dry_matter_pct is None or float(dry_matter_pct) <= 0:
missing.append("dry_matter")
full = nutrients_full_dict(component_id)
if nutrients_is_empty(component_id):
missing.append("nutrients_empty")
else:
for key in _REQUIRED_EAV_KEYS:
if read_from_mapping(full, (key,)) is None:
missing.append(key)
has_price = comp.price is not None and float(comp.price) >= 0
if not has_price:
warnings.append("price_missing")
main_feed_dm = read_from_mapping(full, _MAIN_FEED_KEYS)
if main_feed_dm is None:
warnings.append("main_feed_unset")
feed_group = classify_feed_group(comp)
omd = read_from_mapping(full, _OMD_KEYS)
if feed_group in ("rough", "succulent") and omd is None:
warnings.append("omd_missing")
elif omd is None and comp.dry_matter and float(comp.dry_matter) > 0:
ctx = derive_context_for_component(component_id, full)
warnings.append(
f"omd_defaulted:{default_om_digestibility_pct(ctx):.0f}"
)
return {
"id": comp.id,
"name": comp.name,
"eligible": len(missing) == 0,
"missing": missing,
"warnings": warnings,
"dryMatterPct": dry_matter_pct,
"hasPrice": has_price,
"price": comp.price,
"mainFeedDmGPerKg": main_feed_dm,
"isMainFeed": main_feed_dm is not None and float(main_feed_dm) > 0,
"feedGroup": feed_group,
}
def validate_components(component_ids: list[str]) -> list[dict[str, Any]]:
return [validate_component(cid) for cid in component_ids]
@@ -0,0 +1,94 @@
"""Динамические нормы по уравнениям GfE (Германия)."""
from __future__ import annotations
from typing import Any
USP_DYNAMIC_KEY = "usp"
NEL_DYNAMIC_KEY = "nel"
# GfE 2001 Milchkühe — Erhaltung + Milch (Standardmilch / FCM)
NEL_MAINTENANCE_COEFF = 0.293 # MJ NEL / (kg LM)^0,75 / Tag
NEL_PER_KG_MILK_MJ = 3.3 # MJ NEL / kg Milch (FCM)
def gfe_usp_min_g(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> float | None:
"""
Минимальная суточная потребность в усвояемом протеине (уСП / nXP), г/сут.
GfE: 0,09 × (масса^0,75) × 6,25 + удой × 85 г.
"""
if mass_kg is None or mass_kg <= 0:
return None
maintenance = 0.09 * (mass_kg**0.75) * 6.25
milk = max(float(milk_yield_kg or 0), 0.0) * 85.0
return maintenance + milk
def gfe_nel_min_mj(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> float | None:
"""
Минимальная суточная потребность в ЧЭЛ (NEL), МДж/сут.
GfE 2001: 0,293 × LM^0,75 + удой × 3,3 (MJ NEL на кг молока).
"""
if mass_kg is None or mass_kg <= 0:
return None
maintenance = NEL_MAINTENANCE_COEFF * (mass_kg**0.75)
milk = max(float(milk_yield_kg or 0), 0.0) * NEL_PER_KG_MILK_MJ
return maintenance + milk
def preview_dynamic_norms(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> dict[str, Any]:
"""Расчётные min по GfE для UI (без учёта сохранённых норм)."""
_, dynamic = apply_dynamic_norms(
{},
mass_kg=mass_kg,
milk_yield_kg=milk_yield_kg,
)
return dynamic
def apply_dynamic_norms(
stored: dict[str, dict[str, float | None]],
*,
mass_kg: float | None,
milk_yield_kg: float | None,
ration_type: str | None = None,
force_dynamic: bool = False,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""
Заполняет нормы по GfE, если в БД min не задан.
force_dynamic=True — пересчитать min уСП/ЧЭЛ по массе и удою даже при нормах в БД
(автоготовка с явными mass_kg / milk_yield_kg).
Возвращает (resolved_norms, dynamic_meta).
"""
del ration_type
resolved: dict[str, dict[str, float | None]] = {
key: {"min": bounds.get("min"), "max": bounds.get("max")}
for key, bounds in stored.items()
}
dynamic: dict[str, Any] = {}
for key, compute, formula in (
(USP_DYNAMIC_KEY, gfe_usp_min_g, "0.09×масса^0.75×6.25 + удой×85"),
(NEL_DYNAMIC_KEY, gfe_nel_min_mj, "0.293×масса^0.75 + удой×3.3"),
):
bounds = resolved.get(key, {"min": None, "max": None})
if force_dynamic or bounds.get("min") is None:
computed = compute(mass_kg, milk_yield_kg)
if computed is not None:
entry = dict(bounds)
entry["min"] = computed
resolved[key] = entry
dynamic[key] = {"min": computed, "formula": formula}
return resolved, dynamic
@@ -0,0 +1,84 @@
"""WESP-политики расчёта на базе GfE 2001 (отличия от zootech Excel — осознанные)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
FeedGroup = Literal["rough", "succulent", "concentrate", "other", "unknown"]
# --- GfE 2001 константы (формулы не меняем) ---
NEL_Q_COEFF = 0.004
NEL_Q_REF_PCT = 57.0
NEL_BASE = 0.6
GE_CP = 0.0239
GE_FAT = 0.0398
GE_FIBER = 0.0201
GE_NFE = 0.0175
ME_FAT = 0.0312
ME_FIBER = 0.0136
ME_OR_RESIDUE = 0.0147
ME_CP = 0.00234
USP_FAT_THRESHOLD_G_PER_KG_DM = 70.0 # 7% СЖ/кг СВ
# DCAB (Na+K)-(Cl+S), мэкв при минералах в г/кг СВ
DCAB_NA = 43.5
DCAB_K = 25.6
DCAB_CL = 28.2
DCAB_S = 62.4
# Дефолты переваримости при пустых коэфф. (Excel legacy, кроме ОВ)
DEFAULT_CP_DIGEST_PCT = 86.0
DEFAULT_FAT_DIGEST_PCT = 75.0
DEFAULT_FIBER_DIGEST_PCT = 86.0
DEFAULT_NFE_DIGEST_PCT = 94.0
DEFAULT_INSOLUBLE_PROTEIN_PCT = 15.0
DEFAULT_PROTEIN_FRACTION_PCT = 18.9
# WESP: дефолт ВРХ орг. вещ. при пустом поле — по классу корма
DEFAULT_OMD_ROUGH_PCT = 65.0
DEFAULT_OMD_SUCCULENT_PCT = 72.0
DEFAULT_OMD_CONCENTRATE_PCT = 91.0
@dataclass(frozen=True)
class DeriveContext:
feed_group: FeedGroup = "unknown"
is_main_feed: bool = False
@classmethod
def from_cells(cls, *, main_feed_g: float = 0.0, feed_group: FeedGroup = "unknown") -> DeriveContext:
return cls(
feed_group=feed_group,
is_main_feed=main_feed_g > 0,
)
@classmethod
def infer_from_cells(cls, cells: dict[str, float], feed_group: FeedGroup = "unknown") -> DeriveContext:
main_feed = cells.get("E", 0.0)
return cls.from_cells(main_feed_g=main_feed, feed_group=feed_group)
def default_om_digestibility_pct(ctx: DeriveContext | None) -> float:
"""Дефолт переваримости ОВ (%) при отсутствии ВРХ Орг Вещ."""
if ctx is None:
return DEFAULT_OMD_CONCENTRATE_PCT
if ctx.is_main_feed or ctx.feed_group == "rough":
return DEFAULT_OMD_ROUGH_PCT
if ctx.feed_group == "succulent":
return DEFAULT_OMD_SUCCULENT_PCT
return DEFAULT_OMD_CONCENTRATE_PCT
def default_digestibility_coefficients() -> dict[str, float]:
return {
"cp": DEFAULT_CP_DIGEST_PCT,
"fat": DEFAULT_FAT_DIGEST_PCT,
"fiber": DEFAULT_FIBER_DIGEST_PCT,
"nfe": DEFAULT_NFE_DIGEST_PCT,
"insoluble_protein": DEFAULT_INSOLUBLE_PROTEIN_PCT,
"protein_fraction": DEFAULT_PROTEIN_FRACTION_PCT,
}
@@ -0,0 +1,187 @@
"""Каталог показателей zootech «База сырья» (109 колонок)."""
from __future__ import annotations
from openpyxl.utils import column_index_from_string, get_column_letter
# Заголовки row 3 в xlsx_extracted/data_csv_cleaned/База сырья.csv
INGREDIENT_HEADERS: tuple[str, ...] = (
"",
"Наименование",
"Цена 1 кг",
"СВ",
"Осн.Корм",
"Сыр. Протеин",
"уСП",
"БРА",
"ЧЭЛ- КРС",
"ОЭ-КРС",
"Сырая клетч",
"Структур клетч",
"Сырой жир",
"НДК",
"КДК",
"NFC",
"Ca",
"P",
"Mg",
"Fe",
"Zn",
"Cu",
"Co",
"Mn",
"Se",
"J",
"Na",
"K",
"CL",
"S",
"DCAB Форм",
"Сахар и Крохм",
"Нераств Крохм",
"Сахар",
"Крахмал",
"Нераств крахмал",
"Вит А",
"Вит D",
"Вит Е",
"Вит В1",
"Вит В2",
"Вит В6",
"Вит В12",
"Пант Кальц",
"Никот Ки-та",
"Фол ки-та",
"Холин",
"Биотин",
"Сырая зола",
"БЕР",
"Лизин",
"Метионин",
"Треонин",
"Триптофан",
"Изолейцин",
"Лейцин",
"Валин",
"Каротин",
"b -Каротин",
"Линолевая к-та",
"Линоленовая ки-та",
"Масляная ки-та",
"Арахидоновая ки-та",
"Полиэновая ки-та",
"Мочевина",
"СВ Основной корм",
"ВРХ Орг Вещ",
"Перевар Орг Вещ",
"КРС Протеин",
"Переварим Протеин",
"КРС Сырой жир",
"Переварим Сырой жир",
"КРС Сырая клетч",
"Переварим сырая клетч",
"КРС БЭВ",
"Переварим БЭВ",
"ВЕ",
"OЭ КРС форм",
"ЧЭЛ - КРС Форм",
"НДК Общ",
"НДК Осн. Корм",
"КДК общ",
"% нераствор протеин",
"Нерастворим прот",
"СЖ/кг СВ",
"НСП/кг СВ",
"СП/кг СВ",
"пОВ/кг СВ",
"пСЖ/кг СВ",
"уСП<7%",
"уСП>7%",
"уСП/кг СВ формул",
"уСП в ОР",
"уСП формул",
"БРА",
"Нераств крохмал",
"доля крохмала",
"Доля белка",
"% перев в кишках",
"OEB",
"Синтез Мдж",
"Промеж рез 1",
"Промеж рез 2",
"Метаб ОЕТ",
"Метаб лизин",
"Метабол Треон",
"Метабол Лейцин",
"Метабол Изолейц",
"Метабол Валин",
)
HEADER_TO_LETTER: dict[str, str] = {
header: get_column_letter(i + 1) for i, header in enumerate(INGREDIENT_HEADERS)
}
LETTER_TO_HEADER: dict[str, str] = {
get_column_letter(i + 1): header for i, header in enumerate(INGREDIENT_HEADERS)
}
# Колонки с формулами в шаблонной строке 6 (native port, не Excel runtime).
DERIVED_LETTERS: frozenset[str] = frozenset(
{
"AE",
"AF",
"AG",
"AX",
"BN",
"BP",
"BR",
"BT",
"BV",
"BX",
"BY",
"BZ",
"CA",
"CF",
"CG",
"CH",
"CI",
"CJ",
"CK",
"CL",
"CM",
"CN",
"CP",
"CQ",
"CX",
"CY",
"CZ",
"DA",
"DB",
"DC",
"DD",
"DE",
}
)
# Отображаемые поля G–J синхронизируются с расчётными CP/CQ/CA/BZ.
DISPLAY_SYNC: tuple[tuple[str, str], ...] = (
("G", "CP"), # уСП
("H", "CQ"), # RNB (legacy заголовок «БРА», дубль CQ)
("I", "CA"), # ЧЭЛ- КРС
("J", "BZ"), # ОЭ-КРС
)
DERIVED_HEADERS: frozenset[str] = frozenset(
LETTER_TO_HEADER[letter] for letter in DERIVED_LETTERS
) | frozenset(LETTER_TO_HEADER[g] for g, _ in DISPLAY_SYNC)
INPUT_HEADERS: frozenset[str] = frozenset(INGREDIENT_HEADERS) - DERIVED_HEADERS - frozenset(
("", "Наименование", "Цена 1 кг")
)
# Дублирующий заголовок «БРА»/RNB (H и CQ) — в derive используем CQ.
DUPLICATE_HEADERS: frozenset[str] = frozenset({"БРА"})
def letter_index(letter: str) -> int:
return column_index_from_string(letter) - 1
@@ -0,0 +1,216 @@
"""Native derive формул zootech «База сырья» — WESP GfE 2001 engine."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.gfe_policies import (
DCAB_CL,
DCAB_K,
DCAB_NA,
DCAB_S,
DEFAULT_CP_DIGEST_PCT,
DEFAULT_FAT_DIGEST_PCT,
DEFAULT_FIBER_DIGEST_PCT,
DEFAULT_INSOLUBLE_PROTEIN_PCT,
DEFAULT_NFE_DIGEST_PCT,
DEFAULT_PROTEIN_FRACTION_PCT,
DeriveContext,
GE_CP,
GE_FAT,
GE_FIBER,
GE_NFE,
ME_CP,
ME_FAT,
ME_FIBER,
ME_OR_RESIDUE,
NEL_BASE,
NEL_Q_COEFF,
NEL_Q_REF_PCT,
USP_FAT_THRESHOLD_G_PER_KG_DM,
default_om_digestibility_pct,
)
from app.modules.zootech.lab.calc.ingredient_catalog import (
DISPLAY_SYNC,
HEADER_TO_LETTER,
INGREDIENT_HEADERS,
LETTER_TO_HEADER,
)
def _parse_num(value: Any) -> float | None:
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
return None
return None if n != n else n
def _normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def _v(cells: dict[str, float], letter: str, default: float = 0.0) -> float:
return cells.get(letter, default)
def _if_pos(test: float, when_true, when_false: float = 0.0) -> float:
"""Excel IF(test>0, …) — ветка when_true не вычисляется при test<=0."""
if test > 0:
return when_true() if callable(when_true) else when_true
return when_false
def dict_to_cells(data: dict[str, Any] | None) -> dict[str, float]:
"""Словарь {заголовок: значение} → {буква колонки: значение}."""
cells: dict[str, float] = {}
if not data:
return cells
norm_index = {_normalize_key(h): h for h in INGREDIENT_HEADERS}
for raw_key, raw_val in data.items():
n = _parse_num(raw_val)
if n is None:
continue
nk = _normalize_key(str(raw_key))
header = norm_index.get(nk)
if header is None:
continue
letter = HEADER_TO_LETTER.get(header)
if letter:
cells[letter] = n
return cells
def cells_to_dict(cells: dict[str, float]) -> dict[str, float]:
out: dict[str, float] = {}
for letter, value in cells.items():
header = LETTER_TO_HEADER.get(letter)
if header and header not in ("", "Наименование", "Цена 1 кг"):
out[header] = value
return out
def derive_cells(
cells: dict[str, float],
*,
context: DeriveContext | None = None,
) -> dict[str, float]:
"""Пересчёт derived-колонок по цепочке формул row 6 «База сырья»."""
c = dict(cells)
ctx = context or DeriveContext.infer_from_cells(c)
omd_default = default_om_digestibility_pct(ctx)
c["AE"] = DCAB_NA * _v(c, "AA") + DCAB_K * _v(c, "AB") - DCAB_CL * _v(c, "AC") - DCAB_S * _v(c, "AD")
c["AG"] = _v(c, "AJ") * _v(c, "AI") / 100.0
c["AF"] = _v(c, "AI") - c["AG"] + _v(c, "AH")
c["AX"] = _v(c, "D") - _v(c, "F") - _v(c, "K") - _v(c, "M") - _v(c, "AW")
c["BN"] = _if_pos(_v(c, "E"), lambda: _v(c, "D") / _v(c, "E") * 1000.0)
c["BP"] = _if_pos(
_v(c, "BO"),
lambda: (_v(c, "D") - _v(c, "AW")) * _v(c, "BO") / 100.0,
(_v(c, "D") - _v(c, "AW")) * omd_default / 100.0,
)
c["BR"] = _if_pos(
_v(c, "BQ"), lambda: _v(c, "F") * _v(c, "BQ") / 100.0, _v(c, "F") * DEFAULT_CP_DIGEST_PCT / 100.0
)
c["BT"] = _if_pos(
_v(c, "BS"), lambda: _v(c, "M") * _v(c, "BS") / 100.0, _v(c, "M") * DEFAULT_FAT_DIGEST_PCT / 100.0
)
c["BV"] = _if_pos(
_v(c, "BU"), lambda: _v(c, "K") * _v(c, "BU") / 100.0, _v(c, "K") * DEFAULT_FIBER_DIGEST_PCT / 100.0
)
c["BX"] = _if_pos(
_v(c, "BW"), lambda: c["AX"] * _v(c, "BW") / 100.0, c["AX"] * DEFAULT_NFE_DIGEST_PCT / 100.0
)
c["BY"] = GE_CP * _v(c, "F") + GE_FAT * _v(c, "M") + GE_FIBER * _v(c, "K") + GE_NFE * c["AX"]
c["BZ"] = (
ME_FAT * c["BT"]
+ ME_FIBER * c["BV"]
+ ME_OR_RESIDUE * (c["BP"] - c["BT"] - c["BV"])
+ ME_CP * _v(c, "F")
)
c["CA"] = _if_pos(
c["BY"],
lambda: (NEL_BASE * (1.0 + NEL_Q_COEFF * (c["BZ"] / c["BY"] * 100.0 - NEL_Q_REF_PCT)) * c["BZ"]),
)
c["CF"] = _if_pos(
_v(c, "CE"),
lambda: _v(c, "F") * _v(c, "CE") / 100.0,
_v(c, "F") * DEFAULT_INSOLUBLE_PROTEIN_PCT / 100.0,
)
c["CG"] = _if_pos(_v(c, "D"), lambda: _v(c, "M") * 1000.0 / _v(c, "D"))
c["CH"] = _if_pos(_v(c, "D"), lambda: c["CF"] * 1000.0 / _v(c, "D"))
c["CI"] = _if_pos(_v(c, "D"), lambda: _v(c, "F") * 1000.0 / _v(c, "D"))
c["CJ"] = _if_pos(_v(c, "D"), lambda: c["BP"] / _v(c, "D"))
c["CK"] = _if_pos(_v(c, "D"), lambda: c["BT"] / _v(c, "D"))
c["CL"] = _if_pos(c["CI"], lambda: (187.7 - 115.4 * c["CH"] / c["CI"]) * c["CJ"] + 1.03 * c["CH"])
c["CM"] = _if_pos(c["CI"], lambda: (196.1 - 127.5 * c["CH"] / c["CI"]) * (c["CJ"] - c["CK"]) + 1.03 * c["CH"])
co = _v(c, "CO")
if c["CG"] < USP_FAT_THRESHOLD_G_PER_KG_DM + 0.01:
c["CN"] = c["CL"]
elif c["CG"] > USP_FAT_THRESHOLD_G_PER_KG_DM:
c["CN"] = c["CM"]
else:
c["CN"] = 0.0
if co < 1.01:
c["CP"] = c["CN"] * _v(c, "D") / 1000.0
elif co > 1.0:
c["CP"] = co
else:
c["CP"] = 0.0
c["CQ"] = (_v(c, "F") - c["CP"]) / 6.25
c["CX"] = _if_pos(
_v(c, "CT"),
lambda: _v(c, "F") * _v(c, "CT") / 100.0,
_v(c, "F") * DEFAULT_PROTEIN_FRACTION_PCT / 100.0,
)
nel = c["CA"]
c["CY"] = nel * _v(c, "CW")
f_val = _v(c, "F")
bo = _v(c, "BO")
if f_val == 0.0:
c["CZ"] = 0.0
c["DA"] = 0.0
c["DB"] = 0.0
c["DC"] = 0.0
c["DD"] = 0.0
c["DE"] = 0.0
else:
c["DA"] = c["CX"] * (_v(c, "AY") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.071 * 0.8
c["CZ"] = _if_pos(
bo,
lambda: c["CX"] * (_v(c, "AZ") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.018 * 0.8,
)
c["DB"] = _if_pos(
bo,
lambda: c["CX"] * (_v(c, "BA") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.044 * 0.8,
)
c["DC"] = _if_pos(
_v(c, "BD"),
lambda: c["CX"] * (_v(c, "BD") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.063 * 0.8,
)
c["DD"] = _if_pos(
_v(c, "BC"),
lambda: c["CX"] * (_v(c, "BC") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.049 * 0.8,
)
c["DE"] = _if_pos(
_v(c, "BE"),
lambda: c["CX"] * (_v(c, "BE") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.048 * 0.8,
)
for display, source in DISPLAY_SYNC:
c[display] = c[source]
return c
def derive_ingredient_nutrients(
data: dict[str, Any] | None,
*,
context: DeriveContext | None = None,
) -> dict[str, float]:
"""Полный набор показателей: входные + пересчитанные derived."""
cells = dict_to_cells(data)
return cells_to_dict(derive_cells(cells, context=context))
@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.constants import NORM_COLUMN_ALIASES, RATION_QUALITY_INDICATORS
def merge_norms_from_profile(profile_data: Any, ration_type: str) -> dict[str, dict[str, float | None]]:
del ration_type
if not profile_data or not isinstance(profile_data, dict):
return {}
indicators = profile_data.get("indicators")
if isinstance(indicators, dict):
out: dict[str, dict[str, float | None]] = {}
for key, bounds in indicators.items():
if not isinstance(bounds, dict):
continue
out[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
return out
out = {}
for defn in RATION_QUALITY_INDICATORS:
key = defn["key"]
aliases = NORM_COLUMN_ALIASES.get(key)
if not aliases:
continue
for alias in aliases:
entry = profile_data.get(alias)
if isinstance(entry, dict):
out[key] = {
"min": parse_num(entry.get("min")),
"max": parse_num(entry.get("max")),
}
break
return out
@@ -0,0 +1,86 @@
"""Производные min/max норм из базовых показателей RACION."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
def _norm_min(bounds: dict[str, float | None] | None) -> float | None:
if not bounds:
return None
v = bounds.get("min")
return float(v) if v is not None else None
def _apply_derived_min(
norms: dict[str, dict[str, float | None]],
defn: dict[str, Any],
*,
mass_kg: float | None = None,
) -> float | None:
derived = defn.get("derived")
key = defn["key"]
if derived == "alias":
src = norms.get(defn.get("alias_of") or "")
if src and src.get("min") is not None:
return src["min"]
return None
if derived == "pct_of_dm":
src = _norm_min(norms.get(defn.get("from_key") or ""))
dm = _norm_min(norms.get("dry_matter"))
if src is None or not dm or dm <= 0:
return None
return src / dm * 100.0
if derived == "g_per_kg_dm":
src = _norm_min(norms.get(defn.get("from_key") or ""))
dm = _norm_min(norms.get("dry_matter"))
if src is None or not dm or dm <= 0:
return None
return src / (dm / 1000.0)
if derived == "nel_per_kg_dm":
dm = _norm_min(norms.get("dry_matter"))
nel = _norm_min(norms.get("nel"))
if not dm or dm <= 0 or nel is None:
return None
return nel / (dm / 1000.0)
if derived == "ratio":
num = _norm_min(norms.get(defn.get("ratio_num") or ""))
den = _norm_min(norms.get(defn.get("ratio_den") or ""))
if num is None or den is None or den == 0:
return None
return num / den
if derived == "dm_pct_bw":
dm = _norm_min(norms.get("dry_matter"))
if dm is None or not mass_kg or mass_kg <= 0:
return None
return (dm / 1000.0 / mass_kg) * 100.0
if derived == "ration_pct_bw":
return None
if derived in ("rnb", "bra_rnb"):
return _norm_min(norms.get(key))
return None
def apply_derived_norms(
norms: dict[str, dict[str, float | None]],
*,
mass_kg: float | None = None,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""Дополняет norms производными min; max не трогает."""
out = {k: dict(v) for k, v in norms.items()}
dynamic: dict[str, Any] = {}
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
key = defn["key"]
if _norm_min(out.get(key)) is not None:
continue
val = _apply_derived_min(out, defn, mass_kg=mass_kg)
if val is None:
continue
rounded = round(val, 3)
out[key] = {"min": rounded, "max": out.get(key, {}).get("max")}
dynamic[key] = {"min": rounded, "derived": defn.get("derived")}
return out, dynamic
@@ -0,0 +1,192 @@
"""Роутер методик суточных норм: WESP / Москва / Петербург."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from app.modules.zootech.lab.calc.gfe_norms import apply_dynamic_norms
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams, resolve_moscow_dairy_norms
from app.modules.zootech.lab.calc.racion.piter import PiterDairyParams, PiterPrepError, resolve_piter_dairy_norms
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
NormsMethod = Literal["wesp", "racion_moscow", "racion_piter"]
VALID_NORMS_METHODS: frozenset[str] = frozenset({"wesp", "racion_moscow", "racion_piter"})
@dataclass
class NormsParams:
milk_fat_pct: float | None = None
lactation_no: int | None = None
lactation_stage: int | None = None
body_condition: int | None = None
housing_system: int | None = None
konc_oe_sv: float | None = None
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> NormsParams:
if not raw:
return cls()
return cls(
milk_fat_pct=_flt(raw.get("milkFatPct", raw.get("milk_fat_pct"))),
lactation_no=_int(raw.get("lactationNo", raw.get("lactation_no"))),
lactation_stage=_int(raw.get("lactationStage", raw.get("lactation_stage"))),
body_condition=_int(raw.get("bodyCondition", raw.get("body_condition"))),
housing_system=_int(raw.get("housingSystem", raw.get("housing_system"))),
konc_oe_sv=_flt(raw.get("koncOeSv", raw.get("konc_oe_sv"))),
)
def _flt(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _int(v: Any) -> int | None:
if v is None or v == "":
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def normalize_norms_method(method: str | None) -> NormsMethod:
m = (method or "wesp").strip().lower()
if m not in VALID_NORMS_METHODS:
return "wesp"
return m # type: ignore[return-value]
@dataclass
class NormsResolveRequest:
method: NormsMethod = "wesp"
stored: dict[str, dict[str, float | None]] = field(default_factory=dict)
mass_kg: float | None = None
milk_yield_kg: float | None = None
ration_type: str | None = None
force_dynamic: bool = False
params: NormsParams = field(default_factory=NormsParams)
def merge_hybrid_norms(
racion_resolved: dict[str, dict[str, float | None]],
stored: dict[str, dict[str, float | None]],
*,
dynamic_meta: dict[str, Any] | None = None,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""
RACION + derived имеют приоритет по min.
stored дополняет ключи без RACION-min; max всегда из stored, если задан.
"""
out: dict[str, dict[str, float | None]] = {}
coverage: dict[str, list[str]] = {
"racion": [],
"derived": [],
"fallback": [],
"missing": [],
}
dynamic = dynamic_meta or {}
all_keys = {d["key"] for d in RATION_ALL_INDICATORS}
all_keys.update(racion_resolved.keys())
all_keys.update(stored.keys())
for key in sorted(all_keys):
rac = racion_resolved.get(key) or {}
st = stored.get(key) or {}
rac_min = rac.get("min")
st_min = st.get("min")
st_max = st.get("max")
source: str | None = None
min_v: float | None = None
if rac_min is not None:
min_v = rac_min
src = (dynamic.get(key) or {}).get("source")
source = "derived" if src == "derived" else "racion"
elif st_min is not None:
min_v = st_min
source = "fallback"
max_v = st_max if st_max is not None else rac.get("max")
if min_v is not None or max_v is not None:
out[key] = {"min": min_v, "max": max_v}
if source == "racion":
coverage["racion"].append(key)
elif source == "derived":
coverage["derived"].append(key)
elif source == "fallback":
coverage["fallback"].append(key)
elif key in {d["key"] for d in RATION_ALL_INDICATORS}:
coverage["missing"].append(key)
coverage["withMin"] = len([k for k, b in out.items() if b.get("min") is not None])
coverage["total"] = len(RATION_ALL_INDICATORS)
return out, coverage
def _require_dairy_params(req: NormsResolveRequest) -> tuple[float, float]:
if req.mass_kg is None or req.mass_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите живую массу, кг")
if req.milk_yield_kg is None or req.milk_yield_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите суточный удой, кг")
return float(req.mass_kg), float(req.milk_yield_kg)
def _moscow_params(req: NormsResolveRequest) -> MoscowDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
return MoscowDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
)
def _piter_params(req: NormsResolveRequest) -> PiterDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
konc = p.konc_oe_sv
if konc is None or konc <= 0:
raise ValueError("Для методики Петербург укажите концентрацию ОЭ/СВ, МДж/кг СВ")
return PiterDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
konc_oe_sv=float(konc),
)
def resolve_norms(req: NormsResolveRequest) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
method = normalize_norms_method(req.method)
if method == "wesp":
resolved, dynamic = apply_dynamic_norms(
req.stored,
mass_kg=req.mass_kg,
milk_yield_kg=req.milk_yield_kg,
ration_type=req.ration_type,
force_dynamic=req.force_dynamic,
)
return resolved, {"normsMethod": "wesp", "dynamicNorms": dynamic}
try:
if method == "racion_moscow":
racion, pack = resolve_moscow_dairy_norms(_moscow_params(req))
else:
racion, pack = resolve_piter_dairy_norms(_piter_params(req))
except PiterPrepError as exc:
raise ValueError(str(exc)) from exc
resolved, coverage = merge_hybrid_norms(racion, req.stored, dynamic_meta=pack.get("dynamic"))
return resolved, {"normsMethod": method, "coverage": coverage, **pack}
@@ -0,0 +1,135 @@
from __future__ import annotations
from typing import Any, Mapping
from app.modules.zootech.lab.nutrient_schema import resolve_sv_g_per_kg
def normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def parse_num(value: Any) -> float | None:
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
return None
return None if n != n else n
def get_nutrient_value(
dry_matter: float | None,
nutrients: Mapping[str, Any] | None,
nutrient_keys: list[str],
*,
indicator_key: str | None = None,
) -> float | None:
"""Только точное совпадение ключа (заголовок или indicator_key slug)."""
nutrients = nutrients or {}
if indicator_key:
for k, v in nutrients.items():
if normalize_key(str(k)) == normalize_key(indicator_key):
n = parse_num(v)
if n is not None:
return n
for search in nutrient_keys:
target = normalize_key(search)
for k, v in nutrients.items():
if normalize_key(str(k)) != target:
continue
n = parse_num(v)
if n is not None:
return n
if any(normalize_key(k) == "св" for k in nutrient_keys):
return resolve_sv_g_per_kg(nutrients, dry_matter)
return None
def weighted_average(
lines: list[dict[str, Any]],
total_kg: float,
nutrient_keys: list[str],
*,
indicator_key: str | None = None,
) -> float | None:
if total_kg <= 0:
return None
total = 0.0
weight = 0.0
for line in lines:
kg = float(line.get("daily_kg") or 0)
if kg <= 0:
continue
v = get_nutrient_value(
line.get("dry_matter"),
line.get("nutrients"),
nutrient_keys,
indicator_key=indicator_key,
)
if v is None:
continue
total += kg * v
weight += kg
if weight <= 0:
return None
return total / weight
def daily_intake_total(
lines: list[dict[str, Any]],
nutrient_keys: list[str],
*,
heads_per_trip: int = 1,
indicator_key: str | None = None,
) -> float | None:
"""Суточная доза на голову (г или МДж): Σ (кг/день/гол × г/кг). Как Excel Рацион КРС."""
heads = max(int(heads_per_trip or 1), 1)
total = 0.0
any_value = False
for line in lines:
herd_kg = float(line.get("daily_kg") or 0)
if herd_kg <= 0:
continue
kg = herd_kg / heads
v = get_nutrient_value(
line.get("dry_matter"),
line.get("nutrients"),
nutrient_keys,
indicator_key=indicator_key,
)
if v is None:
continue
total += kg * v
any_value = True
return total if any_value else None
def rnb(
crude_protein: float | None,
usp: float | None,
) -> float | None:
"""RNB (ruminal nitrogen balance, GfE): (сырой протеин − уСП) / 6,25."""
if crude_protein is None or usp is None:
return None
return (crude_protein - usp) / 6.25
def bra_rnb(crude_protein: float | None, usp: float | None) -> float | None:
"""Deprecated alias for :func:`rnb`."""
return rnb(crude_protein, usp)
def norm_diff(
content: float | None,
min_val: float | None,
max_val: float | None,
) -> float | None:
if content is None:
return None
if min_val is not None and content < min_val:
return content - min_val
if max_val is not None and content > max_val:
return content - max_val
return 0.0
@@ -0,0 +1 @@
"""Российские методики суточных норм (Москва / Петербург)."""
@@ -0,0 +1,25 @@
"""Линейная интерполяция (аналог FRAC в методичке)."""
from __future__ import annotations
def frac(numerator: float, denominator: float) -> float:
if denominator == 0:
return 0.0
return numerator / denominator
def lerp(x: float, x1: float, n1: float, x2: float, n2: float) -> float:
"""Norma = n1 + frac(n2 - n1, x2 - x1) * (x - x1)."""
if x2 == x1:
return n1
return n1 + frac(n2 - n1, x2 - x1) * (x - x1)
def popr_index(udoy: float, boundaries: list[float]) -> int:
"""1-based индекс столбца POPR_K по суточному удою."""
idx = 1
for i, bound in enumerate(boundaries, start=1):
if udoy >= bound:
idx = i + 1
return min(idx, len(boundaries) + 1)
@@ -0,0 +1,107 @@
"""Методика Москва — лактирующие коровы (NORM_1_1_CALC)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.modules.zootech.lab.calc.racion.interp import popr_index
from app.modules.zootech.lab.calc.norms_derived import apply_derived_norms
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.modules.zootech.lab.calc.racion.tables import load_moskwa_lactir
@dataclass(frozen=True)
class MoscowDairyParams:
mass_kg: float
milk_yield_kg: float
milk_fat_pct: float = 4.0
lactation_no: int = 2
body_condition: int = 1
housing_system: int = 1
def _row_lookup(rows: list[dict], npitv: int) -> dict | None:
for row in rows:
if row["npitv"] == npitv and row["pom"] == 1:
return row
return None
def _popr_value(row: dict, udoy: float, boundaries: list[float]) -> tuple[float | None, float | None]:
popr = row.get("popr_k") or []
if not popr:
return None, None
idx = popr_index(udoy, boundaries) - 1
idx = max(0, min(idx, len(popr) - 1))
p_k = popr[idx]
koef = float(row.get("koef") or 0)
return p_k, koef
def compute_moscow_norm(npitv: int, params: MoscowDairyParams) -> float | None:
data = load_moskwa_lactir()
boundaries = data.get("udoy_boundaries") or []
row = _row_lookup(data.get("rows") or [], npitv)
if row is None:
return None
mass = params.mass_kg
udoy = params.milk_yield_kg
jir = params.milk_fat_pct
wmassa = mass * 1.02 if params.body_condition > 1 else mass
p_k, koef = _popr_value(row, udoy, boundaries)
if p_k is None:
return None
norma: float | None = None
if npitv == 1:
temp = 0.005 if udoy <= 22 else 0.0025
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 148
elif npitv == 2:
temp = 0.09 if udoy <= 22 else 0.065
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 15
elif npitv == 3:
temp = 0.017 if udoy <= 22 else 0.015
norma = temp * (wmassa - 500) + p_k * udoy
elif 4 <= npitv <= 24:
norma = p_k * udoy + (wmassa - 500) * koef
if npitv == 10:
norma *= 0.393
else:
return None
if norma is None:
return None
if params.lactation_no == 1:
norma *= 0.95
elif params.lactation_no == 3:
norma *= 1.05
if params.housing_system == 2:
norma *= 1.1
return round(norma, 3)
def resolve_moscow_dairy_norms(params: MoscowDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
resolved: dict[str, dict[str, float | None]] = {}
dynamic: dict[str, Any] = {}
for npitv in DAIRY_LACTIR_NPITV:
value = compute_moscow_norm(npitv, params)
if value is None:
continue
key = NPITV_TO_INDICATOR.get(npitv)
if not key:
continue
resolved[key] = {"min": value, "max": None}
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_moscow", "source": "racion"}
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
for k, v in derived_dyn.items():
dynamic[k] = {**v, "method": "racion_moscow", "source": "derived"}
meta = {
"method": "racion_moscow",
"massKg": params.mass_kg,
"milkYieldKg": params.milk_yield_kg,
"milkFatPct": params.milk_fat_pct,
}
return resolved, {"meta": meta, "dynamic": dynamic}
@@ -0,0 +1,42 @@
"""NPitV (справочник питательных веществ) → ключи показателей WESP."""
from __future__ import annotations
# NORMY_MOSKWA_LACTIR / NORM_1_1_CALC: NPitV 124 (pom=1)
NPITV_TO_INDICATOR: dict[int, str] = {
1: "feed_units",
2: "oe",
3: "dry_matter",
4: "crude_protein",
5: "digestible_protein",
6: "crude_fat",
7: "nel",
8: "rnb",
9: "usp",
10: "sodium",
11: "magnesium",
12: "starch",
13: "potassium",
14: "calcium",
15: "phosphorus",
16: "iron",
17: "copper",
18: "zinc",
19: "manganese",
20: "cobalt",
21: "iodine",
22: "carotene",
23: "vitamin_d",
24: "vitamin_e",
}
DAIRY_LACTIR_NPITV: tuple[int, ...] = tuple(range(1, 25))
# Обратная совместимость
DAIRY_CORE_NPITV: tuple[int, ...] = (2, 3, 4, 5, 7, 8, 9, 10, 12, 14, 15)
INDICATOR_TO_NPITV: dict[str, int] = {v: k for k, v in NPITV_TO_INDICATOR.items()}
def indicator_for_npitv(npitv: int) -> str | None:
return NPITV_TO_INDICATOR.get(npitv)
@@ -0,0 +1,193 @@
"""Методика Петербург — лактирующие коровы (NORM_1_2_CALC)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.modules.zootech.lab.calc.norms_derived import apply_derived_norms
from app.modules.zootech.lab.calc.racion.interp import lerp
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.modules.zootech.lab.calc.racion.piter_prep import PiterPrepError, PiterPrepResult, prepare_piter_calc
from app.modules.zootech.lab.calc.racion.tables import load_piter_lactir
@dataclass(frozen=True)
class PiterDairyParams(MoscowDairyParams):
konc_oe_sv: float = 10.3
def _konc_bracket(konc: float, konc_values: list[float]) -> tuple[float, float]:
sorted_k = sorted(set(konc_values))
positive = [k for k in sorted_k if k > 0]
if not positive:
return konc, konc
if konc <= positive[0]:
return positive[0], positive[min(1, len(positive) - 1)]
for i in range(len(positive) - 1):
if positive[i] <= konc <= positive[i + 1]:
return positive[i], positive[i + 1]
return positive[-2], positive[-1]
def _udoy_bracket(udoy_jir: float, udoys: list[float]) -> tuple[float, float]:
sorted_u = sorted(set(udoys))
if len(sorted_u) < 2:
return sorted_u[0], sorted_u[0]
if udoy_jir <= sorted_u[0]:
return sorted_u[0], sorted_u[1]
for i in range(len(sorted_u) - 1):
if sorted_u[i] <= udoy_jir < sorted_u[i + 1]:
return sorted_u[i], sorted_u[i + 1]
return sorted_u[-2], sorted_u[-1]
def _norm_at_mass(
entry_normy: list[float],
mass_ind: int,
wmassa: float,
m_a: float,
m_b: float,
) -> float | None:
i = mass_ind - 1
if i < 0 or i + 1 >= len(entry_normy):
return None
n_a, n_b = entry_normy[i], entry_normy[i + 1]
if n_a <= 0 or n_b <= 0:
return None
return lerp(wmassa, m_a, n_a, m_b, n_b)
def _entries_for(data: dict, npitv: int) -> list[dict]:
npv = 4 if npitv == 5 else npitv
return [e for e in data.get("entries") or [] if e["npitv"] == npv]
def _compute_with_konc(
entries: list[dict],
npitv: int,
params: PiterDairyParams,
prep: PiterPrepResult,
konc_pred: float,
konc_sled: float,
) -> float | None:
by_konc = {k: [e for e in entries if abs(e["konc"] - k) < 1e-6] for k in (konc_pred, konc_sled)}
def _at_konc(konc: float) -> float | None:
rows = by_konc.get(konc) or []
if not rows:
return None
udoys = [e["udoy"] for e in rows]
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
if ud_pred == ud_sled:
return None
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
if not e_pred or not e_sled:
return None
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
if n01 is None or n02 is None:
return None
return lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
norma1 = _at_konc(konc_pred)
norma2 = _at_konc(konc_sled)
if norma1 is None or norma2 is None or konc_pred == konc_sled:
return None
norma = lerp(params.konc_oe_sv, konc_pred, norma1, konc_sled, norma2)
if npitv == 5:
norma *= 0.65
return round(norma, 3)
def _compute_konc_independent(
entries: list[dict],
params: PiterDairyParams,
prep: PiterPrepResult,
) -> float | None:
rows = [e for e in entries if e["konc"] == -10]
if not rows:
return None
udoys = [e["udoy"] for e in rows]
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
if ud_pred == ud_sled:
return None
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
if not e_pred or not e_sled:
return None
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
if n01 is None or n02 is None:
return None
norma = lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
if params.housing_system == 2:
norma *= 1.1
return round(norma, 3)
def compute_piter_norm(npitv: int, params: PiterDairyParams, prep: PiterPrepResult | None = None) -> float | None:
data = load_piter_lactir()
entries = _entries_for(data, npitv)
if not entries:
return None
if prep is None:
prep = prepare_piter_calc(
mass_kg=params.mass_kg,
milk_yield_kg=params.milk_yield_kg,
milk_fat_pct=params.milk_fat_pct,
konc_oe_sv=params.konc_oe_sv,
body_condition=params.body_condition,
)
konc_vals = sorted({e["konc"] for e in entries if e["konc"] > 0})
if konc_vals:
min_k = min(konc_vals)
if min_k > 0:
konc_pred, konc_sled = _konc_bracket(params.konc_oe_sv, konc_vals)
return _compute_with_konc(entries, npitv, params, prep, konc_pred, konc_sled)
return _compute_konc_independent(entries, params, prep)
def resolve_piter_dairy_norms(params: PiterDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
prep = prepare_piter_calc(
mass_kg=params.mass_kg,
milk_yield_kg=params.milk_yield_kg,
milk_fat_pct=params.milk_fat_pct,
konc_oe_sv=params.konc_oe_sv,
body_condition=params.body_condition,
)
resolved: dict[str, dict[str, float | None]] = {}
dynamic: dict[str, Any] = {}
for npitv in DAIRY_LACTIR_NPITV:
value = compute_piter_norm(npitv, params, prep=prep)
if value is None:
continue
key = NPITV_TO_INDICATOR.get(npitv)
if not key:
continue
resolved[key] = {"min": value, "max": None}
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_piter", "source": "racion"}
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
for k, v in derived_dyn.items():
dynamic[k] = {**v, "method": "racion_piter", "source": "derived"}
meta = {
"method": "racion_piter",
"massKg": params.mass_kg,
"milkYieldKg": params.milk_yield_kg,
"koncOeSv": params.konc_oe_sv,
"prep": {
"massInd": prep.mass_ind,
"mA": prep.m_a,
"mB": prep.m_b,
"koncPred": prep.konc_pred,
"koncSled": prep.konc_sled,
},
}
return resolved, {"meta": meta, "dynamic": dynamic}
__all__ = ["PiterDairyParams", "PiterPrepError", "compute_piter_norm", "resolve_piter_dairy_norms"]
@@ -0,0 +1,202 @@
"""Подготовка параметров NORM_1_2_PREP (интервалы массы и концентрации)."""
from __future__ import annotations
from dataclasses import dataclass
from app.modules.zootech.lab.calc.racion.interp import lerp
from app.modules.zootech.lab.calc.racion.tables import load_normy_info
@dataclass(frozen=True)
class PiterPrepResult:
mass_ind: int
konc_pred: float
konc_sled: float
m_a: float
m_b: float
udoy_jir: float
wmassa: float
class PiterPrepError(ValueError):
def __init__(self, code: int, message: str, *, info: str | None = None) -> None:
super().__init__(message)
self.code = code
self.info = info
def _info_values(nperem: int) -> list[float]:
data = load_normy_info()
for row in data.get("rows") or []:
if row.get("nperem") == nperem:
return list(row.get("znachenie") or [])
return []
def _fl_from_str(values: list[float], index: int) -> float | None:
"""1-based index как FlFromStr в RACION."""
i = index - 1
if i < 0 or i >= len(values):
return None
return float(values[i])
def _wmassa(mass_kg: float, body_condition: int) -> float:
if body_condition > 1:
return mass_kg * 1.02
return mass_kg
def prepare_piter_calc(
*,
mass_kg: float,
milk_yield_kg: float,
milk_fat_pct: float,
konc_oe_sv: float,
body_condition: int = 1,
) -> PiterPrepResult:
"""Порт NORM_1_2_PREP: интервалы для NORM_1_2_CALC."""
if milk_yield_kg <= 0:
raise PiterPrepError(-11, "Укажите суточный удой, кг")
if milk_fat_pct <= 0:
raise PiterPrepError(-12, "Укажите жирность молока, %")
if mass_kg <= 0:
raise PiterPrepError(-14, "Укажите живую массу, кг")
if konc_oe_sv <= 0:
raise PiterPrepError(-15, "Укажите концентрацию ОЭ/СВ, МДж/кг СВ")
wmassa = _wmassa(mass_kg, body_condition)
udoy_jir = milk_yield_kg * milk_fat_pct * 0.25
kol_konc_vals = _info_values(7)
kol_mass_vals = _info_values(7)
if len(kol_konc_vals) < 2 or len(kol_mass_vals) < 2:
raise PiterPrepError(-1, "Справочник NORMY_INFO (NPerem=7) не задан")
kol_konc = int(kol_konc_vals[0])
kol_mass = int(kol_mass_vals[1])
if kol_konc < 2 or kol_mass < 2:
raise PiterPrepError(-1, "Некорректные размеры таблицы концентраций/масс")
masses = _info_values(6) or _info_values(14)
koncss = _info_values(3)
if not masses or not koncss or len(masses) < 2 or len(koncss) < 2:
raise PiterPrepError(-1, "Справочник NORMY_INFO: массы или концентрации не заданы")
# Интервал концентрации
konc_ind = 1
for i in range(2, kol_konc):
v = _fl_from_str(koncss, i)
if v is not None and konc_oe_sv >= v:
konc_ind = i
konc_pred = _fl_from_str(koncss, konc_ind)
konc_sled = _fl_from_str(koncss, konc_ind + 1)
if konc_pred is None or konc_sled is None or konc_pred < 0 or konc_sled < 0 or konc_pred == konc_sled:
raise PiterPrepError(-1, "Не удалось определить интервал концентрации")
# Интервал массы
mass_ind = 1
for i in range(2, kol_mass):
v = _fl_from_str(masses, i)
if v is not None and wmassa >= v:
mass_ind = i
m_a = _fl_from_str(masses, mass_ind)
m_b = _fl_from_str(masses, mass_ind + 1)
if m_a is None or m_b is None or m_a < 0 or m_b < 0 or m_a == m_b:
raise PiterPrepError(-1, "Не удалось определить интервал массы")
# Проверка удоя (NPerem=4,5)
udoy_str_4 = _info_values(4)
udoy_str_5 = _info_values(5)
udoy_min = _fl_from_str(udoy_str_4, 1) if udoy_str_4 else None
udoy_max = _fl_from_str(udoy_str_5, kol_konc) if udoy_str_5 else None
if udoy_min is not None and udoy_max is not None:
if udoy_jir < udoy_min or udoy_jir > udoy_max:
jir_str = _info_values(2)
gr_udoy1 = max(udoy_min * 4 / milk_fat_pct, udoy_min)
gr_udoy2 = min(udoy_max * 4 / milk_fat_pct, udoy_max)
gr_jir1 = udoy_min * 4 / milk_yield_kg
gr_jir2 = udoy_max * 4 / milk_yield_kg
if jir_str:
if len(jir_str) >= 1:
gr_jir1 = max(gr_jir1, jir_str[0])
if len(jir_str) >= 2:
gr_jir2 = min(gr_jir2, jir_str[1])
code = -4 if udoy_jir < udoy_min else -5
info = f"{gr_udoy1:.4f};{gr_udoy2:.4f};{gr_jir1:.3f};{gr_jir2:.3f};"
raise PiterPrepError(code, "Удой вне допустимого диапазона для жирности", info=info)
# Допустимый диапазон концентрации для udoy_jir
i = 1
if udoy_jir >= (_fl_from_str(udoy_str_4, 1) or 0):
while i < kol_konc - 1:
nxt = _fl_from_str(udoy_str_4, i + 1)
if nxt is None or nxt > udoy_jir:
break
i += 1
else:
while i < kol_konc - 1:
nxt = _fl_from_str(udoy_str_4, i + 1)
cur = _fl_from_str(udoy_str_4, i)
if nxt is None or cur is None or nxt != cur:
break
i += 1
ud_a = _fl_from_str(udoy_str_4, i)
ud_b = _fl_from_str(udoy_str_4, i + 1)
konc_a = _fl_from_str(koncss, i)
konc_b = _fl_from_str(koncss, i + 1)
if ud_a is not None and ud_b is not None and konc_a is not None and konc_b is not None:
if ud_a == ud_b:
konc_max = konc_b
else:
konc_max = lerp(udoy_jir, ud_a, konc_a, ud_b, konc_b)
i = kol_konc
if udoy_jir <= (_fl_from_str(udoy_str_5, kol_konc) or udoy_jir):
while i > 2:
prev = _fl_from_str(udoy_str_5, i - 1)
if prev is None or prev < udoy_jir:
break
i -= 1
else:
while i > 2:
prev = _fl_from_str(udoy_str_5, i - 1)
last = _fl_from_str(udoy_str_5, kol_konc)
if prev is None or last is None or prev != last:
break
i -= 1
ud_a2 = _fl_from_str(udoy_str_5, i - 1)
ud_b2 = _fl_from_str(udoy_str_5, i)
konc_a2 = _fl_from_str(koncss, i - 1)
konc_b2 = _fl_from_str(koncss, i)
if ud_a2 is not None and ud_b2 is not None and konc_a2 is not None and konc_b2 is not None:
if ud_a2 == ud_b2:
konc_min = konc_b2
else:
konc_min = lerp(udoy_jir, ud_a2, konc_a2, ud_b2, konc_b2)
konc_lo = _fl_from_str(koncss, 1) or konc_min
konc_hi = _fl_from_str(koncss, kol_konc) or konc_max
if konc_min < konc_lo:
konc_min = konc_lo
if konc_max > konc_hi:
konc_max = konc_hi
if round(konc_oe_sv, 1) < round(konc_min, 1) or round(konc_oe_sv, 1) > round(konc_max, 1):
code = -2 if round(konc_oe_sv, 1) < round(konc_min, 1) else -3
info = f"{round(konc_min, 1)};{round(konc_max, 1)};"
raise PiterPrepError(
code,
f"Концентрация ОЭ/СВ вне допустимого диапазона ({round(konc_min, 1)}{round(konc_max, 1)})",
info=info,
)
return PiterPrepResult(
mass_ind=mass_ind,
konc_pred=float(konc_pred),
konc_sled=float(konc_sled),
m_a=float(m_a),
m_b=float(m_b),
udoy_jir=udoy_jir,
wmassa=wmassa,
)
@@ -0,0 +1,75 @@
"""Загрузка справочников норм RACION (БД → JSON fallback)."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
_SEED_DIR = Path(__file__).resolve().parents[4] / "data" / "seed" / "racion"
def _read_json(name: str) -> dict:
path = _SEED_DIR / name
if not path.exists():
raise FileNotFoundError(f"Справочник не найден: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def _load_from_db(loader_name: str) -> dict | None:
try:
from flask import has_app_context
if not has_app_context():
return None
from app.modules.zootech.lab.services.racion_reference import (
load_moskwa_lactir_from_db,
load_normy_info_from_db,
load_piter_lactir_from_db,
)
loaders = {
"moskwa": load_moskwa_lactir_from_db,
"piter": load_piter_lactir_from_db,
"info": load_normy_info_from_db,
}
fn = loaders.get(loader_name)
if fn is None:
return None
data = fn()
return data if data else None
except Exception:
return None
@lru_cache(maxsize=1)
def load_moskwa_lactir() -> dict:
data = _load_from_db("moskwa")
if data:
return data
return _read_json("moskwa_lactir.json")
@lru_cache(maxsize=1)
def load_piter_lactir() -> dict:
data = _load_from_db("piter")
if data:
return data
return _read_json("piter_lactir.json")
@lru_cache(maxsize=1)
def load_normy_info() -> dict:
data = _load_from_db("info")
if data:
return data
try:
return _read_json("normy_info.json")
except FileNotFoundError:
return {"rows": [], "mass_kg_values": [400, 450, 500, 550, 600, 650, 700, 750]}
def clear_tables_cache() -> None:
load_moskwa_lactir.cache_clear()
load_piter_lactir.cache_clear()
load_normy_info.cache_clear()
@@ -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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.write_audit import write_audit
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.loaders.execution_loader import load_execution
from app.modules.zootech.lab.loaders.ration_loader import load_ration
from app.modules.zootech.wesp_bridge_models import Component, Ingredient, Recipe
from app.modules.zootech.wesp_bridge_models import default_uuid
from app.modules.zootech.wesp_bridge_models 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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.constants import RATION_QUALITY_INDICATORS
from app.modules.zootech.lab.models import (
LabAnimalProfile,
LabProfileNorm,
LabRationCalcIndicator,
LabRationLine,
LabRecipeRation,
)
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
from app.modules.zootech.lab.services.component_nutrients import nutrients_full_dict
from app.modules.zootech.wesp_bridge_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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.models import LabRecipeRation
from app.modules.zootech.wesp_bridge_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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.ingredient_catalog import INGREDIENT_HEADERS
from app.modules.zootech.lab.calc.ingredient_derive import derive_ingredient_nutrients
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.norm_catalog import norm_header, norm_title_to_key
from app.modules.zootech.lab.seed_paths import norms_dir, nutrients_dir
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload
from app.modules.zootech.wesp_bridge_models import default_uuid
from app.modules.zootech.wesp_bridge_models 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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.engine import calculate_ration
from app.modules.zootech.lab.commands.write_audit import write_audit, write_calculation_run
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.commands.seed_demo_component_nutrients import (
ensure_component_nutrients_if_empty,
supplement_component_nutrients_if_sparse,
)
from app.modules.zootech.lab.loaders.ration_loader import load_ration, ration_to_calc_lines
from app.modules.zootech.lab.models import LabAnimalProfile, LabRecipeRation
from app.modules.zootech.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,343 @@
"""Стартовые nutrients для компонентов без EAV (демо-шаблоны, не лабораторный анализ).
При старте и пересчёте рациона заполняет только пустые `lab_component_nutrient_value`.
Маркер в data/ — журнал последнего прогона, не блокирует новые компоненты.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
from app.modules.zootech.lab.services.component_nutrients import nutrients_full_dict, nutrients_is_empty, save_component_nutrients
from app.modules.zootech.wesp_bridge_models 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(__file__).resolve().parents[4] / "data" / _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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.loaders.execution_loader import load_execution
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
from app.modules.zootech.wesp_bridge_models import Component
from app.modules.zootech.wesp_bridge_models 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,358 @@
"""Тестовые рецепты LAB: полная матрица показателей + рацион «в нормах»."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.recalculate import recalculate_ration
from app.modules.zootech.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
from app.modules.zootech.wesp_bridge_models import Component, Ingredient, Recipe
from app.modules.zootech.wesp_bridge_models 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:
return Path(__file__).resolve().parents[4] / "data" / _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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.import_seed import _parse_min_max_groups, parse_norm_profile_row
from app.modules.zootech.lab.seed_paths import norms_dir
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload
from app.modules.zootech.wesp_bridge_models 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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.write_audit import write_audit
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.loaders.execution_loader import load_execution
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
from app.modules.zootech.wesp_bridge_models import Component
from app.modules.zootech.wesp_bridge_models 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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.calc.norms_resolver import normalize_norms_method
from app.modules.zootech.lab.services.norms_params import save_norms_params
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload, sync_racion_norms_to_profile
from app.modules.zootech.wesp_bridge_models 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.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
from app.modules.zootech.wesp_bridge_models import Recipe
from app.modules.zootech.wesp_bridge_models 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),
)
@@ -0,0 +1,28 @@
"""Lab ration calc constants (ported from tab @neoton/shared)."""
from __future__ import annotations
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, RATION_QUALITY_INDICATORS
RATION_TOTAL_KEYS = {
"DAIRY": [
{"key": "total_kg", "label": "Итого кг/день"},
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
],
"BEEF": [
{"key": "total_kg", "label": "Итого кг/день"},
{"key": "ration_kg", "label": "Сумма «из рациона», кг"},
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
{"key": "cost_total", "label": "Стоимость рациона (сумма)"},
],
}
NORM_COLUMN_ALIASES = {
"dry_matter": ["Сухое Вещество", "Сухое вещество"],
"crude_protein": ["Сыр. Протеин"],
"usp": ["уСП"],
"oe": ["ОЭ-КРС", " ОЭ-КРС"],
"nel": ["ЧЭЛ- КРС", " ЧЭЛ- КРС"],
}
DIFF_TOLERANCE_KG = 0.05
@@ -0,0 +1,28 @@
from __future__ import annotations
import time
from functools import wraps
from typing import Any, Callable, TypeVar
from sqlalchemy.exc import OperationalError
F = TypeVar("F", bound=Callable[..., Any])
def retry_locked(fn: F, *, attempts: int = 3, delay: float = 0.15) -> F:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
last_exc: Exception | None = None
for i in range(attempts):
try:
return fn(*args, **kwargs)
except OperationalError as exc:
if "locked" not in str(exc).lower():
raise
last_exc = exc
time.sleep(delay * (i + 1))
if last_exc:
raise last_exc
return None
return wrapper # type: ignore[return-value]
@@ -0,0 +1,3 @@
from .ration import ExecutionSnapshot, RationSnapshot
__all__ = ["RationSnapshot", "ExecutionSnapshot"]
@@ -0,0 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class RationLineSnapshot:
id: str | None
component_id: str | None
ingredient_name: str | None
row_index: int
daily_kg: float | None
in_ration: bool
in_compound: bool
dry_matter: float | None
price_per_kg: float | None
nutrients: dict[str, Any]
@dataclass(frozen=True)
class RationSnapshot:
recipe_id: str
recipe_name: str
ration_type: str
heads_per_trip: int
animal_profile_id: str | None
params: dict[str, Any]
lines: tuple[RationLineSnapshot, ...] = field(default_factory=tuple)
norms: dict[str, dict[str, float | None]] = field(default_factory=dict)
ration_results: dict[str, Any] = field(default_factory=dict)
compound_results: dict[str, Any] = field(default_factory=dict)
calculated_at: str | None = None
exists: bool = True
norms_profile_key: str | None = None
legacy_norms_remapped: bool = False
@dataclass(frozen=True)
class ExecutionLineSnapshot:
ingredient_id: str
component_id: str | None
name: str
weight_per_head: float
daily_kg_total: float
@dataclass(frozen=True)
class ExecutionSnapshot:
recipe_id: str
heads_per_trip: int
lines: tuple[ExecutionLineSnapshot, ...] = field(default_factory=tuple)
@@ -0,0 +1 @@
"""Offline ETL from tab reference PostgreSQL."""
@@ -0,0 +1,88 @@
"""AgroStar PDF (печатная форма анализа) → AgrostarParseResult."""
from __future__ import annotations
import re
from typing import Any
from app.modules.zootech.lab.etl.agrostar_tabular import (
parse_tabular_triplet_lines,
rows_to_label_values,
sample_from_agro_fields,
tabular_rows_to_agro_fields,
)
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult
def _pdf_text(data: bytes) -> str:
try:
import fitz # pymupdf
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
parts = [doc[i].get_text() for i in range(doc.page_count)]
doc.close()
return "\n".join(parts)
def _line_after(lines: list[str], marker: str) -> str:
for i, line in enumerate(lines):
if line == marker and i + 1 < len(lines):
return lines[i + 1]
return ""
def _is_agrostar_pdf(text: str) -> bool:
t = text or ""
return "ИНФОРМАЦИЯ ОБ ОБРАЗЦЕ" in t or "РЕЗУЛЬТАТЫ АНАЛИЗА" in t
def parse_agrostar_pdf(data: bytes) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="АгроСтар")
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not _is_agrostar_pdf(text):
result.errors.append("Не похоже на отчёт AgroStar (PDF)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
sample_no = _line_after(lines, "Образец №")
farm_name = _line_after(lines, "Клиент")
date_printed = _line_after(lines, "Дата анализа")
desc = _line_after(lines, "Описание")
label_parts: list[str] = []
if "Образец" in lines:
idx = lines.index("Образец")
if idx + 1 < len(lines) and lines[idx + 1] != "Описание":
label_parts.append(lines[idx + 1])
if idx + 2 < len(lines) and lines[idx + 2] not in ("Описание", "Клиент"):
label_parts.append(lines[idx + 2])
triplets = parse_tabular_triplet_lines(lines)
agro_fields = tabular_rows_to_agro_fields(rows_to_label_values(triplets))
if not agro_fields:
result.errors.append("В PDF не найдены показатели анализа")
return result
sample = sample_from_agro_fields(
sample_no=sample_no,
farm_name=farm_name,
date_printed=date_printed,
desc_1=desc or " ".join(label_parts),
agro_fields=agro_fields,
)
result.samples.append(sample)
return result
def looks_like_agrostar_pdf(data: bytes) -> bool:
try:
text = _pdf_text(data)
except RuntimeError:
return False
return _is_agrostar_pdf(text)
@@ -0,0 +1,129 @@
"""AgroStar табличные форматы (PDF, xlsx) → те же AgrostarSample, что и XML."""
from __future__ import annotations
import re
from typing import Any
from app.modules.zootech.lab.etl.agrostar_xml_import import (
AgrostarParseResult,
AgrostarSample,
_parse_float,
_pct_dm_to_g_per_kg_sv,
finalize_agrostar_sample,
)
# Подпись в отчёте → ключ AgroStar XML (дальше — общий _AGROSTAR_MAP)
_TABULAR_LABEL_TO_AGRO: dict[str, str] = {
"Сухое вещество (DM)": "DM",
"Сырой протеин (Crude Protein)": "CP",
"КДК (ADF)": "ADF",
"НДК (aNDF)": "NDF",
"НДК по орг. веществу (aNDFom)": "aNDFom",
"Сырой жир (Fat EE)": "Fat_EE",
"Сырая зола (Ash)": "Ash",
"Кальций (Ca)": "Ca",
"Фосфор (P)": "P",
"Магний (Mg)": "Mg",
"Калий (K)": "K",
"Сера (S)": "S",
"Хлор (Cl)": "Cl",
"Водорастворимый сахар (WSC)": "Sugar_WSC",
"Cпирторастворимый сахар (ESC)": "Sugar_ESC",
"Крахмал (Starch)": "Starch",
"Безволокнистые углеводы (NFC)": "NFC",
"Переваримые питательные вещества (TDN)": "TDN",
"НДК протеин (ND-ICP)": "NDICP_CP",
"Лизин": "Lys",
"Метионин": "Met",
"Лейцин": "Leu",
"Изолейцин": "Ile",
"Валин": "Val",
"Усваиваемость НДК 30 ч (NDFD30)": "NDFDom_IV_30hr",
}
_SECTION_HEADERS = frozenset(
{
"Протеин:",
"Клетчатка:",
"Сахара и крахмал:",
"Жир и жирные кислоты:",
"Минералы:",
"Энергия:",
}
)
def map_tabular_label(label: str) -> str | None:
text = (label or "").strip()
if not text or text in _SECTION_HEADERS:
return None
if text in _TABULAR_LABEL_TO_AGRO:
return _TABULAR_LABEL_TO_AGRO[text]
lowered = text.lower()
for key, ag_key in sorted(_TABULAR_LABEL_TO_AGRO.items(), key=lambda item: len(item[0]), reverse=True):
if key.lower() in lowered:
return ag_key
return None
def tabular_rows_to_agro_fields(rows: list[tuple[str, str]]) -> dict[str, float]:
"""[(label, raw_value), ...] → raw_fields AgroStar."""
out: dict[str, float] = {}
for label, raw in rows:
ag_key = map_tabular_label(label)
if not ag_key:
continue
val = _parse_float(raw)
if val is not None:
out[ag_key] = val
return out
def sample_from_agro_fields(
*,
sample_no: str,
farm_name: str,
farm_id: str = "",
date_printed: str = "",
feed_type: str = "Mixed haylage",
desc_1: str = "",
desc_2: str = "",
desc_3: str = "",
agro_fields: dict[str, float],
) -> AgrostarSample:
sample = AgrostarSample(
sample_no=sample_no,
farm_name=farm_name,
farm_id=farm_id,
date_printed=date_printed,
feed_type=feed_type,
desc_1=desc_1,
desc_2=desc_2,
desc_3=desc_3,
dry_matter_pct=agro_fields.get("DM"),
)
sample.raw_fields = {k: v for k, v in agro_fields.items() if k != "DM"}
return finalize_agrostar_sample(sample)
def parse_tabular_triplet_lines(lines: list[str]) -> list[tuple[str, str, str]]:
"""Строки PDF: name, unit (%DM|%CP|%), value."""
rows: list[tuple[str, str, str]] = []
i = 0
units = frozenset({"%DM", "%CP", "%"})
while i < len(lines):
line = lines[i]
if line in units and i >= 1:
name = lines[i - 1]
val = lines[i + 1] if i + 1 < len(lines) else ""
if val and (val[0].isdigit() or val.startswith("<")):
rows.append((name, line, val))
i += 2
continue
i += 1
return rows
def rows_to_label_values(triplets: list[tuple[str, str, str]]) -> list[tuple[str, str]]:
return [(name, val) for name, _unit, val in triplets]
@@ -0,0 +1,72 @@
"""AgroStar xlsx (сводка проб) → AgrostarParseResult."""
from __future__ import annotations
import io
from typing import Any
from openpyxl import load_workbook
from app.modules.zootech.lab.etl.agrostar_tabular import map_tabular_label, sample_from_agro_fields, tabular_rows_to_agro_fields
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult, _parse_float
def parse_agrostar_xlsx(data: bytes) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="АгроСтар")
try:
wb = load_workbook(io.BytesIO(data), data_only=True, read_only=False)
except Exception as exc: # noqa: BLE001
result.errors.append(f"Не удалось открыть xlsx: {exc}")
return result
ws = wb.active
if ws.max_row < 4 or ws.max_column < 2:
result.errors.append("Пустой или неузнаваемый xlsx")
return result
sample_nos: list[str] = []
farms: list[str] = []
labels: list[str] = []
for col in range(2, ws.max_column + 1):
no = ws.cell(1, col).value
if no is None:
continue
sample_nos.append(str(no).strip())
farms.append(str(ws.cell(2, col).value or "").strip())
labels.append(str(ws.cell(3, col).value or "").strip())
if not sample_nos:
result.errors.append("В xlsx нет колонок с пробами")
return result
row_values: list[tuple[str, list[str]]] = []
for row in range(4, ws.max_row + 1):
label = ws.cell(row, 1).value
if label is None:
continue
label_str = str(label).strip()
if not label_str or not map_tabular_label(label_str):
continue
vals = []
for col in range(2, 2 + len(sample_nos)):
raw = ws.cell(row, col).value
vals.append("" if raw is None else str(raw))
row_values.append((label_str, vals))
for idx, sample_no in enumerate(sample_nos):
pairs = [(label, vals[idx]) for label, vals in row_values if idx < len(vals)]
agro_fields = tabular_rows_to_agro_fields(pairs)
if "DM" not in agro_fields:
result.errors.append(f"Проба {sample_no}: нет СВ")
continue
sample = sample_from_agro_fields(
sample_no=sample_no,
farm_name=farms[idx] if idx < len(farms) else "",
desc_1=labels[idx] if idx < len(labels) else sample_no,
agro_fields=agro_fields,
)
result.samples.append(sample)
if not result.samples and not result.errors:
result.errors.append("Не удалось разобрать пробы из xlsx")
return result
@@ -0,0 +1,787 @@
"""Импорт лабораторных анализов AgroStar (Standard_XML_Data) → zootech nutrients WESP."""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
from app.modules.zootech.wesp_bridge_models import Component
# AgroStar Name → (WESP заголовок «База сырья», kind)
# pct_dm: %DM → г/кг СВ (×10); pct: процент как есть; extra: дублировать в доп. ключи
_AGROSTAR_MAP: tuple[tuple[str, str, str], ...] = (
("CP", "Сыр. Протеин", "pct_dm"),
("NDF", "Сырая клетч", "pct_dm"),
("ADF", "КДК", "pct_dm"),
("Fat_EE", "Сырой жир", "pct_dm"),
("Ash", "Сырая зола", "pct_dm"),
("Ca", "Ca", "pct_dm"),
("P", "P", "pct_dm"),
("Mg", "Mg", "pct_dm"),
("K", "K", "pct_dm"),
("S", "S", "pct_dm"),
("Cl", "CL", "pct_dm"),
("Starch", "Крахмал", "pct_dm"),
("Sugar_WSC", "Сахар", "pct_dm"),
("NFC", "NFC", "pct_dm"),
("Lys", "Лизин", "pct_dm"),
("Met", "Метионин", "pct_dm"),
("Leu", "Лейцин", "pct_dm"),
("Ile", "Изолейцин", "pct_dm"),
("Val", "Валин", "pct_dm"),
("NDICP_CP", "% нераствор протеин", "pct"),
("TDN", "ВРХ Орг Вещ", "pct"),
)
_AGROSTAR_MAPPED_FIELDS = frozenset(key for key, _, _ in _AGROSTAR_MAP) | frozenset({"DM", "Sugar_ESC", "aNDFom"})
_UNSUPPORTED_AGROSTAR: dict[str, str] = {
"NDFDom_IV_30hr": "нет поля для переваримости NDF",
"NDFDom_IV_12hr": "нет поля для переваримости NDF",
"NDFDom_IV_120hr": "нет поля для переваримости NDF",
"NDFDom_IV_240hr": "нет поля для переваримости NDF",
}
# Подписи AgroStar-полей, которые не пишем в WESP (для блока «Не попадёт»)
_AGROSTAR_FIELD_RU: dict[str, str] = {
"Moisture": "Влажность",
"pH": "pH",
"SP_CP": "Растворимый протеин",
"SP": "Растворимый протеин (абс.)",
"ADICP": "КДК-протеин (AD-ICP)",
"ADICP_CP": "КДК-протеин, %CP",
"NDICP": "НДК-протеин (абс.)",
"NH3CPE": "Небелковый азот (NH₃-CP)",
"Lignin": "Лигнин",
"Lignin_NDF": "Лигнин, %NDF",
"TFA": "Жирные кислоты, всего",
"RFV": "RFV (относит. корм. ценность)",
"RFQ": "RFQ (относит. качество)",
"uNDFom_IV_12hr": "Нерасщепляемая NDF 12 ч",
"uNDFom_IV_30hr": "Нерасщепляемая NDF 30 ч",
"uNDFom_IV_120hr": "Нерасщепляемая NDF 120 ч",
"uNDFom_IV_240hr": "Нерасщепляемая NDF 240 ч",
"NDFDom_IV_12hr": "Переваримость NDF 12 ч",
"NDFDom_IV_30hr": "Переваримость NDF 30 ч",
"NDFDom_IV_120hr": "Переваримость NDF 120 ч",
"NDFDom_IV_240hr": "Переваримость NDF 240 ч",
"NELMcallb": "NEL (Mcalf/lb)",
"NELMcalkg": "NEL (Mcal/kg)",
"NELMJkg": "NEL (MJ/kg)",
"NEMMcallb": "NEM (Mcal/lb)",
"NEMMcalkg": "NEM (Mcal/kg)",
"NEMMJkg": "NEM (MJ/kg)",
"NEGMcallb": "NEG (Mcal/lb)",
"NEGMcalkg": "NEG (Mcal/kg)",
"NEGMJkg": "NEG (MJ/kg)",
"MilktonH": "Молоко/тонна (Holstein)",
"Horse_DE_Mcallb": "DE лошади (Mcal/lb)",
"Acetic": "Уксусная кислота",
"Propionic": "Пропионовая кислота",
"Butyric": "Масляная кислота",
"Lactic": "Молочная кислота",
"Total_acid": "Органические кислоты, всего",
"C160": "C16:0",
"C180": "C18:0",
"C181": "C18:1",
"C182": "C18:2",
"C183": "C18:3",
"C160_TFA": "C16:0, %TFA",
"C180_TFA": "C18:0, %TFA",
"C181_TFA": "C18:1, %TFA",
"C182_TFA": "C18:2, %TFA",
"C183_TFA": "C18:3, %TFA",
"His": "Гистидин",
"TAA": "Сумма аминокислот",
"Lys_CP": "Лизин, %CP",
"Met_CP": "Метионин, %CP",
"Leu_CP": "Лейцин, %CP",
"Ile_CP": "Изолейцин, %CP",
"Val_CP": "Валин, %CP",
"His_CP": "Гистидин, %CP",
"TAA_CP": "Сумма аминокислот, %CP",
}
_UNSUPPORTED_GROUP_ORDER: tuple[str, ...] = (
"переваримость NDF",
"энергия",
"кислоты силоса",
"жирные кислоты",
"дробный протеин",
"аминокислоты (%CP)",
"клетчатка и RF",
"качество силоса",
"прочее",
)
def _unsupported_group_key(ag_key: str) -> str:
if ag_key in {"Moisture", "pH"}:
return "качество силоса"
if ag_key.startswith(("NDFDom", "uNDFom")):
return "переваримость NDF"
if ag_key.startswith(("NEL", "NEM", "NEG", "Milk", "Horse")):
return "энергия"
if ag_key in {"Acetic", "Propionic", "Butyric", "Lactic", "Total_acid"}:
return "кислоты силоса"
if ag_key.startswith("C1") or ag_key == "TFA":
return "жирные кислоты"
if ag_key in {"SP_CP", "SP", "ADICP", "ADICP_CP", "NDICP", "NH3CPE"}:
return "дробный протеин"
if ag_key.endswith("_CP") or ag_key in {"His", "TAA"}:
return "аминокислоты (%CP)"
if ag_key.startswith(("Lignin", "RF")):
return "клетчатка и RF"
return "прочее"
def _summarize_unsupported(
unsupported: list[dict[str, str]],
*,
nutrient_count: int,
total_in_report: int,
) -> tuple[list[dict[str, int | str]], str]:
counts: dict[str, int] = {}
for row in unsupported:
group = _unsupported_group_key(row["agroKey"])
counts[group] = counts.get(group, 0) + 1
groups: list[dict[str, int | str]] = []
for label in _UNSUPPORTED_GROUP_ORDER:
count = counts.pop(label, 0)
if count:
groups.append({"label": label, "count": count})
for label, count in sorted(counts.items()):
groups.append({"label": label, "count": count})
skip = len(unsupported)
if skip == 0:
return groups, ""
parts = [f"{g['label']} ({g['count']})" for g in groups]
group_text = ", ".join(parts)
stored = nutrient_count + 0 # nutrients without DM line in count; DM stored separately
return (
groups,
f"В отчёте {total_in_report} показателей — запишем СВ и {stored} в карточку реагента. "
f"Ещё {skip} останутся только в AgroStar: {group_text}.",
)
# WESP nutrient key → AgroStar raw field(s), первый найденный — для колонки «В документе»
_PREVIEW_AGRO_SOURCE: dict[str, tuple[str, ...]] = {
"Сыр. Протеин": ("CP",),
"% нераствор протеин": ("NDICP_CP",),
"Сырая клетч": ("NDF",),
"КДК": ("ADF",),
"Структур. клетч": ("aNDFom", "ADF"),
"Сырой жир": ("Fat_EE",),
"Сырая зола": ("Ash",),
"NFC": ("NFC",),
"Сахар": ("Sugar_WSC", "Sugar_ESC"),
"Крахмал": ("Starch",),
"Ca": ("Ca",),
"P": ("P",),
"Mg": ("Mg",),
"K": ("K",),
"S": ("S",),
"CL": ("Cl",),
"ВРХ Орг Вещ": ("TDN",),
"Лизин": ("Lys",),
"Метионин": ("Met",),
"Лейцин": ("Leu",),
"Изолейцин": ("Ile",),
"Валин": ("Val",),
}
_AGRO_SOURCE_UNIT: dict[str, str] = {
"CP": "%DM",
"NDF": "%DM",
"ADF": "%DM",
"aNDFom": "%DM",
"Fat_EE": "%DM",
"Ash": "%DM",
"Ca": "%DM",
"P": "%DM",
"Mg": "%DM",
"K": "%DM",
"S": "%DM",
"Cl": "%DM",
"Starch": "%DM",
"Sugar_WSC": "%DM",
"Sugar_ESC": "%DM",
"NFC": "%DM",
"TDN": "%DM",
"Lys": "%DM",
"Met": "%DM",
"Leu": "%DM",
"Ile": "%DM",
"Val": "%DM",
"NDICP_CP": "%CP",
}
_META_FIELDS = frozenset({"Sample_No", "Name", "Farm_ID", "Lot_name", "Date_Printed", "Type", "Desc_1", "Desc_2", "Desc_3", "Product_code"})
_FEED_TYPE_RU: dict[str, str] = {
"Mixed haylage": "Смешанный сенаж",
"Haylage": "Сенаж",
"Corn silage": "Кукурузный силос",
"Grass silage": "Травяной силос",
}
_AGROSTAR_FEED_TO_COMPONENT_TYPE: dict[str, str] = {
"Mixed haylage": "Сочные корма",
"Haylage": "Сочные корма",
"Corn silage": "Сочные корма",
"Grass silage": "Сочные корма",
}
_FEED_GROUP_TO_COMPONENT_TYPE: dict[str, str] = {
"rough": "Грубые корма",
"succulent": "Сочные корма",
"concentrate": "Концентрированные",
"other": "Добавки",
}
_WARNING_RU: dict[str, str] = {
"dm_missing": "В файле нет сухого вещества (DM)",
"omd_missing": "Нет данных для ВРХ — при расчёте подставится дефолт WESP",
"omd_from_tdn": "ВРХ орг. вещ. оценили по TDN, не прямой OMD из лаборатории",
}
# Порядок показа в «понюхали»: (ключ WESP, подпись, единица)
_PREVIEW_WRITE_ORDER: tuple[tuple[str, str, str], ...] = (
("__dry_matter__", "Сухое вещество (в карточку компонента)", "%"),
("Сыр. Протеин", "Сырой протеин", "г/кг СВ"),
("% нераствор протеин", "Нерастворимый протеин, %", "%"),
("Сырая клетч", "Сырая клетчатка (NDF)", "г/кг СВ"),
("КДК", "Кислая детергентная клетчатка (ADF)", "г/кг СВ"),
("Структур. клетч", "Структурная клетчатка", "г/кг СВ"),
("Сырой жир", "Сырой жир", "г/кг СВ"),
("Сырая зола", "Сырая зола", "г/кг СВ"),
("NFC", "Безволокнистые углеводы (NFC)", "г/кг СВ"),
("Сахар", "Сахар (WSC/ESC)", "г/кг СВ"),
("Крахмал", "Крахмал", "г/кг СВ"),
("Ca", "Кальций", "г/кг СВ"),
("P", "Фосфор", "г/кг СВ"),
("Mg", "Магний", "г/кг СВ"),
("K", "Калий", "г/кг СВ"),
("S", "Сера", "г/кг СВ"),
("CL", "Хлор", "г/кг СВ"),
("ВРХ Орг Вещ", "Переваримость ОВ (из TDN)", "%"),
("Лизин", "Лизин", "г/кг СВ"),
("Метионин", "Метионин", "г/кг СВ"),
("Лейцин", "Лейцин", "г/кг СВ"),
("Изолейцин", "Изолейцин", "г/кг СВ"),
("Валин", "Валин", "г/кг СВ"),
)
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
text = str(value).strip().replace(",", ".")
if not text or text.startswith("<"):
return None
try:
n = float(text)
except ValueError:
return None
return None if n != n else n
def _normalize_label(value: str) -> str:
return re.sub(r"\s+", " ", (value or "").strip().lower())
def _pct_dm_to_g_per_kg_sv(pct_dm: float) -> float:
return round(pct_dm * 10.0, 4)
@dataclass
class AgrostarSample:
sample_no: str
farm_name: str
farm_id: str
date_printed: str
feed_type: str
desc_1: str
desc_2: str
desc_3: str
dry_matter_pct: float | None
raw_fields: dict[str, float] = field(default_factory=dict)
nutrients: dict[str, float] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
@property
def label(self) -> str:
parts = [p for p in (self.desc_1, self.desc_2, self.desc_3) if p]
return parts[0] if parts else self.sample_no
def to_api_dict(self) -> dict[str, Any]:
return {
"sampleNo": self.sample_no,
"farmName": self.farm_name,
"farmId": self.farm_id,
"datePrinted": self.date_printed,
"feedType": self.feed_type,
"desc1": self.desc_1,
"desc2": self.desc_2,
"desc3": self.desc_3,
"label": self.label,
"dryMatterPct": self.dry_matter_pct,
"nutrients": self.nutrients,
"warnings": self.warnings,
"rawFieldCount": len(self.raw_fields),
}
@dataclass
class AgrostarParseResult:
lab_name: str
samples: list[AgrostarSample] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"labName": self.lab_name,
"samples": [s.to_api_dict() for s in self.samples],
"errors": self.errors,
"sampleCount": len(self.samples),
}
@dataclass
class ComponentMatch:
component_id: str
name: str
score: float
def to_api_dict(self) -> dict[str, Any]:
return {"componentId": self.component_id, "name": self.name, "score": round(self.score, 3)}
@dataclass
class ApplyAssignmentResult:
sample_no: str
component_id: str | None
component_name: str | None
dry_matter_pct: float | None
nutrient_count: int
warnings: list[str]
skipped: bool = False
reason: str | None = None
def to_api_dict(self) -> dict[str, Any]:
return {
"sampleNo": self.sample_no,
"componentId": self.component_id,
"componentName": self.component_name,
"dryMatterPct": self.dry_matter_pct,
"nutrientCount": self.nutrient_count,
"warnings": self.warnings,
"skipped": self.skipped,
"reason": self.reason,
}
@dataclass
class ApplyResult:
applied: int = 0
skipped: int = 0
results: list[ApplyAssignmentResult] = field(default_factory=list)
dry_run: bool = False
def to_api_dict(self) -> dict[str, Any]:
return {
"applied": self.applied,
"skipped": self.skipped,
"dryRun": self.dry_run,
"results": [r.to_api_dict() for r in self.results],
}
def _apply_nutrients_from_raw(sample: AgrostarSample) -> AgrostarSample:
nutrients: dict[str, float] = {}
for ag_key, wesp_key, kind in _AGROSTAR_MAP:
val = sample.raw_fields.get(ag_key)
if val is None:
continue
if kind == "pct_dm":
nutrients[wesp_key] = _pct_dm_to_g_per_kg_sv(val)
elif kind == "pct":
nutrients[wesp_key] = round(val, 4)
if "Sugar_ESC" in sample.raw_fields and "Sugar_WSC" not in sample.raw_fields:
esc = sample.raw_fields["Sugar_ESC"]
nutrients["Сахар"] = _pct_dm_to_g_per_kg_sv(esc)
struct_raw = sample.raw_fields.get("aNDFom")
if struct_raw is None:
struct_raw = sample.raw_fields.get("ADF")
if struct_raw is not None:
nutrients["Структур. клетч"] = _pct_dm_to_g_per_kg_sv(struct_raw)
if "Структур. клетч" in nutrients:
nutrients["Структур клетч"] = nutrients["Структур. клетч"]
sample.nutrients = nutrients
if sample.dry_matter_pct is None:
sample.warnings.append("dm_missing")
if "ВРХ Орг Вещ" not in nutrients and "TDN" not in sample.raw_fields:
sample.warnings.append("omd_missing")
elif "TDN" in sample.raw_fields:
sample.warnings.append("omd_from_tdn")
ndfd30 = sample.raw_fields.get("NDFDom_IV_30hr")
if ndfd30 is not None:
sample.warnings.append(f"ndfd30={ndfd30}")
return sample
def finalize_agrostar_sample(sample: AgrostarSample) -> AgrostarSample:
"""Общий финал: raw_fields → nutrients + warnings (XML/PDF/xlsx)."""
return _apply_nutrients_from_raw(sample)
def _read_sample_block(block: ET.Element) -> AgrostarSample:
fields: dict[str, str] = {}
for child in block:
name = child.get("Name") or child.tag
fields[name] = child.get("Value") or ""
sample = AgrostarSample(
sample_no=fields.get("Sample_No", ""),
farm_name=fields.get("Name", ""),
farm_id=fields.get("Farm_ID", ""),
date_printed=fields.get("Date_Printed", ""),
feed_type=fields.get("Type", ""),
desc_1=fields.get("Desc_1", ""),
desc_2=fields.get("Desc_2", ""),
desc_3=fields.get("Desc_3", ""),
dry_matter_pct=_parse_float(fields.get("DM")),
)
for key, raw in fields.items():
if key in _META_FIELDS or key == "DM":
continue
val = _parse_float(raw)
if val is not None:
sample.raw_fields[key] = val
return finalize_agrostar_sample(sample)
def parse_agrostar_xml(text: str) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="")
if not (text or "").strip():
result.errors.append("Пустой файл")
return result
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
result.errors.append(f"Некорректный XML: {exc}")
return result
if root.tag != "Standard_XML_Data":
result.errors.append(f"Ожидался Standard_XML_Data, получен {root.tag}")
for child in root:
tag = child.tag
if tag == "Lab_Name":
result.lab_name = child.get("Value") or ""
elif tag == "Sample_Data":
try:
result.samples.append(_read_sample_block(child))
except Exception as exc: # noqa: BLE001 — собрать все пробы
result.errors.append(f"Ошибка пробы: {exc}")
if not result.samples and not result.errors:
result.errors.append("В файле нет блоков Sample_Data")
return result
def _match_score(label: str, component_name: str) -> float:
a = _normalize_label(label)
b = _normalize_label(component_name)
if not a or not b:
return 0.0
if a in b or b in a:
return 0.95
# ключевые токены: яма, номер, силос
tokens_a = set(re.findall(r"[a-zа-яё0-9]+", a, re.I))
tokens_b = set(re.findall(r"[a-zа-яё0-9]+", b, re.I))
if tokens_a and tokens_b:
overlap = len(tokens_a & tokens_b) / max(len(tokens_a), len(tokens_b))
if overlap >= 0.4:
return 0.5 + overlap * 0.4
return SequenceMatcher(None, a, b).ratio()
def suggest_component_matches(
label: str,
*,
limit: int = 8,
min_score: float = 0.25,
) -> list[ComponentMatch]:
components = (
Component.query.filter(Component.is_deleted.is_(False), Component.is_active.is_(True))
.order_by(Component.name)
.all()
)
scored: list[ComponentMatch] = []
for comp in components:
score = _match_score(label, comp.name or "")
if score >= min_score:
scored.append(ComponentMatch(comp.id, comp.name or "", score))
scored.sort(key=lambda m: (-m.score, m.name.lower()))
return scored[:limit]
def _fmt_preview_num(value: float) -> str:
rounded = round(value, 2)
if abs(rounded - round(rounded)) < 0.01:
return f"{round(rounded):g}"
text = f"{rounded:.2f}".rstrip("0").rstrip(".")
return text or "0"
def _preview_source_for_key(sample: AgrostarSample, wesp_key: str) -> tuple[float | None, str]:
for ag_key in _PREVIEW_AGRO_SOURCE.get(wesp_key, ()):
val = sample.raw_fields.get(ag_key)
if val is not None:
return val, _AGRO_SOURCE_UNIT.get(ag_key, "%DM")
return None, ""
def suggest_canonical_feed_type(sample: AgrostarSample) -> str:
"""Канонический component.type для POST /api/components."""
mapped = _AGROSTAR_FEED_TO_COMPONENT_TYPE.get(sample.feed_type)
if mapped:
return mapped
stub = Component(name=sample.label, type="")
group = classify_feed_group(stub)
return _FEED_GROUP_TO_COMPONENT_TYPE.get(group, "Сочные корма")
def _warning_to_text(code: str) -> str:
if code in _WARNING_RU:
return _WARNING_RU[code]
if code.startswith("ndfd30="):
val = code.split("=", 1)[1]
return _UNSUPPORTED_AGROSTAR.get("NDFDom_IV_30hr", f"ndfd30={val}")
return code
def build_storage_report(sample: AgrostarSample) -> dict[str, Any]:
"""Сводка: что сохранится в WESP и что из AgroStar не мапится."""
unsupported: list[dict[str, str]] = []
for ag_key, val in sample.raw_fields.items():
if ag_key in _AGROSTAR_MAPPED_FIELDS:
continue
label = _AGROSTAR_FIELD_RU.get(ag_key, ag_key)
reason = _UNSUPPORTED_AGROSTAR.get(ag_key, "нет поля в WESP")
unsupported.append(
{
"agroKey": ag_key,
"label": label,
"value": _fmt_preview_num(val),
"reason": reason,
}
)
unsupported.sort(key=lambda row: row["label"].lower())
nutrient_keys = set(sample.nutrients)
stored_count = len(nutrient_keys) + (1 if sample.dry_matter_pct is not None else 0)
total_in_report = len(sample.raw_fields) + (1 if sample.dry_matter_pct is not None else 0)
groups, unsupported_message = _summarize_unsupported(
unsupported,
nutrient_count=len(nutrient_keys),
total_in_report=total_in_report,
)
return {
"storedCount": stored_count,
"nutrientCount": len(nutrient_keys),
"unsupportedCount": len(unsupported),
"unsupportedGroups": groups,
"unsupportedMessage": unsupported_message,
"unsupported": unsupported,
}
def build_sample_preview(sample: AgrostarSample) -> dict[str, Any]:
"""Человекочитаемый разбор пробы для UI «Сначала понюхаем»."""
feed_ru = _FEED_TYPE_RU.get(sample.feed_type, sample.feed_type or "")
desc_parts = [p for p in (sample.desc_1, sample.desc_2, sample.desc_3) if p]
description = " · ".join(desc_parts) if desc_parts else sample.label
meta: list[dict[str, str]] = [
{"label": "№ пробы", "value": sample.sample_no or ""},
{"label": "Хозяйство", "value": sample.farm_name or ""},
{"label": "Код хозяйства", "value": sample.farm_id or ""},
{"label": "Дата отчёта", "value": sample.date_printed or ""},
{"label": "Тип корма", "value": feed_ru},
{"label": "Описание", "value": description},
]
will_write: list[dict[str, str]] = []
if sample.dry_matter_pct is not None:
will_write.append(
{
"label": "Сухое вещество (поле компонента)",
"value": _fmt_preview_num(sample.dry_matter_pct),
"unit": "%",
"sourceValue": _fmt_preview_num(sample.dry_matter_pct),
"sourceUnit": "%",
}
)
for key, label, unit in _PREVIEW_WRITE_ORDER:
if key == "__dry_matter__":
continue
val = sample.nutrients.get(key)
if val is None:
continue
row: dict[str, str] = {"label": label, "value": _fmt_preview_num(val), "unit": unit}
source_val, source_unit = _preview_source_for_key(sample, key)
if source_val is not None:
row["sourceValue"] = _fmt_preview_num(source_val)
row["sourceUnit"] = source_unit
will_write.append(row)
notes = [_warning_to_text(w) for w in sample.warnings]
storage = build_storage_report(sample)
return {
"title": sample.label,
"feedTypeRu": feed_ru,
"suggestedName": sample.label,
"suggestedType": suggest_canonical_feed_type(sample),
"meta": meta,
"willWrite": will_write,
"notes": notes,
"recognizedCount": len(will_write),
"storage": storage,
}
def enrich_parse_with_matches(parse: AgrostarParseResult) -> dict[str, Any]:
body = parse.to_api_dict()
for i, sample in enumerate(parse.samples):
matches = suggest_component_matches(sample.label)
body["samples"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
body["samples"][i]["preview"] = build_sample_preview(sample)
return body
def apply_agrostar_import(
assignments: list[dict[str, Any]],
*,
user_id: str = "agrostar-import",
dry_run: bool = False,
) -> ApplyResult:
"""assignments: [{sampleNo, componentId}] — данные проб из предыдущего parse или повторный parse."""
result = ApplyResult(dry_run=dry_run)
by_sample: dict[str, dict[str, Any]] = {
str(a.get("sampleNo") or a.get("sample_no") or ""): a for a in assignments
}
for sample_no, row in by_sample.items():
if not sample_no:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no="",
component_id=None,
component_name=None,
dry_matter_pct=None,
nutrient_count=0,
warnings=[],
skipped=True,
reason="missing_sample_no",
)
)
continue
component_id = row.get("componentId") or row.get("component_id")
nutrients = row.get("nutrients") or {}
dry_matter = row.get("dryMatterPct") or row.get("dry_matter_pct")
if not component_id:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=None,
component_name=None,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=list(row.get("warnings") or []),
skipped=True,
reason="no_component",
)
)
continue
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is None:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=component_id,
component_name=None,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=[],
skipped=True,
reason="component_not_found",
)
)
continue
warnings = list(row.get("warnings") or [])
if dry_run:
result.applied += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=comp.id,
component_name=comp.name,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=warnings,
)
)
continue
if dry_matter is not None:
try:
comp.dry_matter = float(dry_matter)
except (TypeError, ValueError):
warnings.append("invalid_dry_matter")
save_component_nutrients(comp.id, nutrients, user_id=user_id)
comp.updated_by = user_id
db.session.commit()
result.applied += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=comp.id,
component_name=comp.name,
dry_matter_pct=comp.dry_matter,
nutrient_count=len(nutrients),
warnings=warnings,
)
)
return result
@@ -0,0 +1,243 @@
"""Единый роутер импорта lab: XML / PDF / xlsx → preview API."""
from __future__ import annotations
import re
from typing import Any
from app.modules.zootech.lab.etl.agrostar_pdf_import import looks_like_agrostar_pdf, parse_agrostar_pdf
from app.modules.zootech.lab.etl.agrostar_xlsx_import import parse_agrostar_xlsx
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult, enrich_parse_with_matches, parse_agrostar_xml
from app.modules.zootech.lab.etl.plinor_pdf_import import (
enrich_plinor_composition,
is_plinor_sos,
is_plinor_zoo,
parse_plinor_sos,
parse_plinor_zoo,
)
SOURCE_LABELS: dict[str, str] = {
"agrostar_xml": "AgroStar XML",
"agrostar_pdf": "AgroStar PDF",
"agrostar_xlsx": "AgroStar Excel",
"plinor_sos": "ПЛИНОР — состав рациона",
"plinor_zoo": "ПЛИНОР — показатели рациона",
}
_PDF_MAGIC = b"%PDF"
_XLSX_MAGIC = b"PK\x03\x04"
_XML_MARKERS = (b"Standard_XML_Data", b"<?xml", b"<Standard_XML_Data")
def _filename_hint_plinor_zoo(name: str) -> bool:
n = (name or "").lower()
return "зоо" in n or re.search(r"(^|[^a-z])zoo([^a-z]|$)", n) is not None
def _filename_hint_plinor_sos(name: str) -> bool:
n = (name or "").lower()
return "сос" in n or re.search(r"(^|[^a-z])sos([^a-z]|$)", n) is not None
def _looks_like_xml_agrostar(data: bytes) -> bool:
head = data[:16384]
return any(marker in head for marker in _XML_MARKERS)
def _looks_like_xlsx(data: bytes) -> bool:
return data.startswith(_XLSX_MAGIC)
def _looks_like_pdf(data: bytes) -> bool:
return data.startswith(_PDF_MAGIC)
def _pdf_import_available() -> bool:
try:
import fitz # noqa: F401
except ImportError:
return False
return True
def detect_source_format(filename: str, data: bytes) -> str:
"""Определение формата по содержимому и имени (расширение — запасной вариант)."""
name = (filename or "").lower()
if _looks_like_pdf(data):
return _detect_pdf_format(data, name)
if _looks_like_xlsx(data):
return "agrostar_xlsx"
if _looks_like_xml_agrostar(data):
return "agrostar_xml"
if name.endswith(".xml"):
return "agrostar_xml"
if name.endswith(".xlsx") or name.endswith(".xls"):
return "agrostar_xlsx"
if name.endswith(".pdf"):
return _detect_pdf_format(data, name)
return "unknown"
def _detect_pdf_format(data: bytes, name: str) -> str:
head = data[:8192]
if b"Standard_XML_Data" in head:
return "agrostar_xml"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
pdf_text = ""
if _pdf_import_available():
try:
pdf_text = _peek_pdf_text(data)
except Exception: # noqa: BLE001
pdf_text = ""
if pdf_text:
if is_plinor_zoo(pdf_text):
return "plinor_zoo"
if is_plinor_sos(pdf_text):
return "plinor_sos"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
if _filename_hint_plinor_zoo(name):
return "plinor_zoo"
if _filename_hint_plinor_sos(name):
return "plinor_sos"
return "unknown_pdf"
def _peek_pdf_text(data: bytes) -> str:
try:
import fitz
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
text = doc[0].get_text() if doc.page_count else ""
doc.close()
return text
def parse_lab_import(filename: str, data: bytes) -> dict[str, Any]:
"""Разбор файла → единый JSON для /lab комбайна."""
if not data:
return {"error": True, "message": "Пустой файл", "kind": "error"}
fmt = detect_source_format(filename, data)
source_label = SOURCE_LABELS.get(fmt, "Неизвестный формат")
if fmt == "agrostar_xml":
text = _decode_text(data)
parsed = parse_agrostar_xml(text)
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "agrostar_pdf":
parsed = parse_agrostar_pdf(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "agrostar_xlsx":
parsed = parse_agrostar_xlsx(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "plinor_sos":
parsed = parse_plinor_sos(data)
if parsed.errors and not parsed.feed_lines:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = enrich_plinor_composition(parsed.to_api_dict())
body["kind"] = "ration_composition"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "plinor_zoo":
parsed = parse_plinor_zoo(data)
if parsed.errors and not parsed.indicators:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = parsed.to_api_dict()
body["kind"] = "ration_indicators"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "unknown_pdf" and _looks_like_pdf(data) and not _pdf_import_available():
return {
"error": True,
"message": "Для PDF нужен pymupdf — выполните: pip install pymupdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
return {
"error": True,
"message": "Формат не распознан. Поддерживаются: AgroStar xml/pdf/xlsx, ПЛИНОР сос/зоо pdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
def _lab_samples_response(
parsed: AgrostarParseResult,
fmt: str,
source_label: str,
filename: str,
) -> dict[str, Any]:
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
def _decode_text(data: bytes) -> str:
for encoding in ("utf-8", "utf-8-sig", "cp1251"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")
@@ -0,0 +1,215 @@
"""ПЛИНОР PDF (сос / зоо) → состав рациона или показатели."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from app.modules.zootech.lab.etl.agrostar_xml_import import _parse_float, suggest_component_matches
def _pdf_text(data: bytes) -> str:
try:
import fitz
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
parts = [doc[i].get_text() for i in range(doc.page_count)]
doc.close()
return "\n".join(parts)
def _meta_from_lines(lines: list[str]) -> dict[str, str]:
meta: dict[str, str] = {}
for line in lines:
for key, prefix in (
("group", "Группа:"),
("farm", "Хозяйство:"),
("region", "Район:"),
("rationDate", "Дата рациона:"),
("calcDate", "Дата расчетов:"),
):
if line.startswith(prefix):
meta[key] = line.split(":", 1)[1].strip()
if line.startswith("Суточный удой"):
m = re.search(r"(\d+(?:[,\.]\d+)?)", line)
if m:
meta["milkYieldKg"] = m.group(1).replace(",", ".")
for i, line in enumerate(lines):
if line.startswith("Цена (руб.)") and i + 1 < len(lines):
meta["totalCostRub"] = lines[i + 1].replace(",", ".")
if line == "Масса (кг)" and i + 1 < len(lines):
meta["totalMassKg"] = lines[i + 1].replace(",", ".")
return meta
@dataclass
class PlinorFeedLine:
feed_name: str
daily_kg: float
cost_rub: float
def to_api_dict(self) -> dict[str, Any]:
return {"feedName": self.feed_name, "dailyKg": self.daily_kg, "costRub": self.cost_rub}
@dataclass
class PlinorIndicatorRow:
name: str
norm: float | None
current: float | None
def to_api_dict(self) -> dict[str, Any]:
return {"name": self.name, "norm": self.norm, "current": self.current}
@dataclass
class PlinorCompositionResult:
meta: dict[str, str] = field(default_factory=dict)
feed_lines: list[PlinorFeedLine] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"meta": self.meta,
"feedLines": [line.to_api_dict() for line in self.feed_lines],
"feedCount": len(self.feed_lines),
"errors": self.errors,
}
@dataclass
class PlinorIndicatorsResult:
meta: dict[str, str] = field(default_factory=dict)
indicators: list[PlinorIndicatorRow] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"meta": self.meta,
"indicators": [row.to_api_dict() for row in self.indicators],
"indicatorCount": len(self.indicators),
"errors": self.errors,
}
def _is_plinor(text: str) -> bool:
return "ПЛИНОР" in (text or "") or 'ИАС "РАЦИОНЫ"' in (text or "")
def is_plinor_sos(text: str) -> bool:
if not _is_plinor(text):
return False
if "Состав рациона" in text:
return True
return bool(re.search(r"Таблица 1\.1(?!0)", text))
def is_plinor_zoo(text: str) -> bool:
if not _is_plinor(text):
return False
if "Зоотехнические показатели" in text:
return True
return "Таблица 1.10" in text
def parse_plinor_sos(data: bytes) -> PlinorCompositionResult:
result = PlinorCompositionResult()
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not is_plinor_sos(text):
result.errors.append("Не похоже на ПЛИНОР «Состав рациона» (табл. 1.1)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
result.meta = _meta_from_lines(lines)
feeds: list[PlinorFeedLine] = []
i = 0
while i < len(lines):
if lines[i] == "кг" and i >= 1 and i + 2 < len(lines):
name = lines[i - 1]
amt = _parse_float(lines[i + 1])
cost = _parse_float(lines[i + 2])
if (
amt is not None
and cost is not None
and name not in ("Дача", "изм.", "Корма")
and "кг" not in name.lower()
):
feeds.append(PlinorFeedLine(name, amt, cost))
i += 3
continue
i += 1
if not feeds:
result.errors.append("Не найдены строки кормов")
result.feed_lines = feeds
return result
def parse_plinor_zoo(data: bytes) -> PlinorIndicatorsResult:
result = PlinorIndicatorsResult()
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not is_plinor_zoo(text):
result.errors.append("Не похоже на ПЛИНОР «Зоотехнические показатели» (табл. 1.10)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
result.meta = _meta_from_lines(lines)
skip_prefixes = (
"Район:",
"Хозяйство:",
"Ферма:",
"Двор:",
"Подразделение:",
"Группа:",
"Суточный",
"Стадия",
"Живая",
"Система",
"Конц.",
"Кр.опт.",
"Дата",
)
skip_exact = frozenset(
{"1", "2", "3", "По польз.", "норме", "Текущий рацион", "Наименование", "Значение"}
)
rows: list[PlinorIndicatorRow] = []
i = 0
while i < len(lines):
line = lines[i]
if line in skip_exact or any(line.startswith(p) for p in skip_prefixes):
i += 1
continue
if i + 2 < len(lines) and len(line) > 8:
norm = _parse_float(lines[i + 1])
cur = _parse_float(lines[i + 2])
if norm is not None and cur is not None:
rows.append(PlinorIndicatorRow(line, norm, cur))
i += 3
continue
i += 1
if not rows:
result.errors.append("Не найдены показатели рациона")
result.indicators = rows
return result
def enrich_plinor_composition(body: dict[str, Any]) -> dict[str, Any]:
for i, line in enumerate(body.get("feedLines") or []):
name = line.get("feedName") or ""
matches = suggest_component_matches(name, limit=5)
body["feedLines"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
return body
@@ -0,0 +1,265 @@
"""Import tab reference PostgreSQL → WESP SQLite (offline admin ETL)."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import psycopg2
from psycopg2.extras import RealDictCursor
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.wesp_bridge_models import Component, Recipe, WESP_SUPPRESS_SYNC_ENQUEUE
from app.modules.zootech.lab.services.profile_norms import import_norms_from_legacy_text, save_norms_from_payload
DEFAULT_PG_URL = "postgresql://neoton:neoton_secret@localhost:5432/neoton"
@dataclass
class ImportStats:
components_enriched: int = 0
components_unmatched: int = 0
tab_components_purged: int = 0
animal_profiles: int = 0
recipe_links: int = 0
errors: list[str] = field(default_factory=list)
def _pg_url() -> str:
return os.environ.get("TAB_REFERENCE_DATABASE_URL", DEFAULT_PG_URL).strip()
def _json_text(value: Any) -> str:
if value is None:
return "{}"
if isinstance(value, str):
return value if value else "{}"
return json.dumps(value, ensure_ascii=False)
def _dt(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return None
def _fetch_all(conn, sql: str) -> list[dict[str, Any]]:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql)
return list(cur.fetchall())
def _normalize_name(name: str) -> str:
return " ".join((name or "").lower().split())
def _find_wesp_component(row: dict[str, Any]) -> Component | None:
"""Match tab feed_ingredient → existing WESP component (never create)."""
external_no = row.get("external_no")
name = (row.get("name") or "").strip()
norm = _normalize_name(name)
if external_no is not None:
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
if hit is not None:
return hit
if name:
hit = Component.query.filter(
Component.name == name, Component.is_deleted.is_(False)
).first()
if hit is not None:
return hit
if norm:
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
if _normalize_name(comp.name) == norm:
return comp
if len(name) >= 12:
prefix = name[:20].lower()
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
if prefix in (comp.name or "").lower():
return comp
return None
def _enrich_wesp_component(component: Component, row: dict[str, Any]) -> None:
"""Copy zootech nutrients from tab; WESP id/name/dry_matter/price stay canonical."""
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
raw = row.get("nutrients")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raw = {}
if not isinstance(raw, dict):
raw = {}
save_component_nutrients(component.id, raw, user_id="reference-db-import")
if row.get("external_no") is not None:
component.external_no = row.get("external_no")
component.updated_by = "tab-enrich"
def _purge_tab_imported_components(stats: ImportStats) -> None:
"""Remove components created from tab feed_ingredients (not used in WESP calc)."""
tab_ids = [
c.id
for c in Component.query.filter(
Component.created_by == "tab-import", Component.is_deleted.is_(False)
).all()
]
if not tab_ids:
return
for comp in Component.query.filter(Component.id.in_(tab_ids)).all():
comp.soft_delete("tab-import-cleanup")
stats.tab_components_purged += 1
def _import_feed_ingredients(conn, stats: ImportStats) -> None:
"""Map tab feed_ingredient → WESP component; enrich nutrients only."""
rows = _fetch_all(
conn,
"""
SELECT id, external_no, name, price_per_kg, dry_matter, nutrients, row_index
FROM feed_ingredients
ORDER BY row_index NULLS LAST, external_no NULLS LAST
""",
)
for row in rows:
component = _find_wesp_component(row)
if component is None:
stats.components_unmatched += 1
continue
_enrich_wesp_component(component, row)
stats.components_enriched += 1
def build_feed_ingredient_mapping(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> dict[str, str | None]:
st = stats or ImportStats()
mapping: dict[str, str | None] = {}
for row in rows:
tab_id = str(row.get("id") or "")
component = _find_wesp_component(row)
if component is None:
st.components_unmatched += 1
mapping[tab_id] = None
continue
_enrich_wesp_component(component, row)
mapping[tab_id] = component.id
st.components_enriched += 1
return mapping
def apply_animal_profile_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
"""Импорт профилей из списка строк (PG-формат или fixtures JSON)."""
st = stats or ImportStats()
for row in rows:
_upsert_animal_profile_row(row, st)
return st
def _upsert_animal_profile_row(row: dict[str, Any], stats: ImportStats) -> None:
profile = LabAnimalProfile.query.get(row["id"])
if profile is None:
profile = LabAnimalProfile(id=row["id"])
db.session.add(profile)
profile.profile_key = row["key"]
profile.label = row["label"]
profile.ration_type = str(row["type"])
norms_raw = row.get("norms_data")
if isinstance(norms_raw, dict):
save_norms_from_payload(profile, norms_raw)
else:
import_norms_from_legacy_text(profile, norms_raw)
profile.created_at = _dt(row.get("created_at")) or profile.created_at
profile.updated_at = _dt(row.get("updated_at")) or profile.updated_at
profile.created_by = "tab-import"
profile.updated_by = "tab-import"
stats.animal_profiles += 1
def apply_feed_ingredient_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
"""Обогащение WESP component из строк feed_ingredients (без PG)."""
st = stats or ImportStats()
for row in rows:
component = _find_wesp_component(row)
if component is None:
st.components_unmatched += 1
continue
_enrich_wesp_component(component, row)
st.components_enriched += 1
return st
def _import_animal_profiles(conn, stats: ImportStats) -> None:
rows = _fetch_all(
conn,
"""
SELECT id, key, label, type, norms_data, created_at, updated_at
FROM animal_profiles
ORDER BY key
""",
)
apply_animal_profile_rows(rows, stats)
def _recipe_by_name(name: str) -> Recipe | None:
if not name:
return None
return Recipe.query.filter(Recipe.name == name, Recipe.is_deleted.is_(False)).first()
def _link_recipes_from_tab_projects(conn, stats: ImportStats) -> None:
"""Только ration_type на recipe по имени — без staging-таблиц."""
rows = _fetch_all(
conn,
"""
SELECT name, type
FROM ration_projects
ORDER BY updated_at DESC NULLS LAST
""",
)
seen: set[str] = set()
for row in rows:
name = (row.get("name") or "").strip()
if not name or name in seen:
continue
linked = _recipe_by_name(name)
if linked is None:
continue
if row.get("type"):
linked.ration_type = str(row["type"])
seen.add(name)
stats.recipe_links += 1
def import_from_reference_db(pg_url: str | None = None) -> ImportStats:
"""Full dump from tab PostgreSQL into WESP SQLite."""
stats = ImportStats()
url = pg_url or _pg_url()
conn = psycopg2.connect(url)
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
try:
_purge_tab_imported_components(stats)
db.session.flush()
_import_feed_ingredients(conn, stats)
db.session.flush()
_import_animal_profiles(conn, stats)
_link_recipes_from_tab_projects(conn, stats)
db.session.commit()
except Exception as exc:
db.session.rollback()
stats.errors.append(str(exc))
raise
finally:
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
conn.close()
return stats
@@ -0,0 +1,169 @@
"""Все показатели рациона для расчёта и сравнения с нормами профиля."""
from __future__ import annotations
from typing import Any
def _ind(
key: str,
label: str,
unit: str,
nutrient_keys: list[str] | None = None,
*,
aggregation: str = "daily_total",
derived: str | None = None,
**extra: Any,
) -> dict[str, Any]:
row: dict[str, Any] = {
"key": key,
"label": label,
"unit": unit,
"nutrient_keys": nutrient_keys or [],
"aggregation": aggregation,
}
if derived:
row["derived"] = derived
row.update(extra)
return row
# Порядок: базовые → расширенные zootech → производные
RATION_ALL_INDICATORS: list[dict[str, Any]] = [
_ind("dry_matter", "Сухое вещество", "г", ["СВ"]),
_ind("dm_main", "СВ — основной корм", "г", ["Осн.Корм", "СВ Основной корм"]),
_ind("oe", "ОЭ — КРС / Дойн", "MJ", ["ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм"]),
_ind("nel", "ЧЭЛ — КРС / Дойн", "MJ", ["ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм"]),
_ind("nel_per_kg_dm", "ЧЭЛ/кг СВ", "MJ", derived="nel_per_kg_dm"),
_ind("crude_protein", "Сырой протеин", "г", ["Сыр. Протеин"]),
_ind("rup_per_kg_dm", "Нер. СП / кг СВ", "г", ["Нер. СП / кг СВ"], aggregation="weighted_avg"),
_ind("insoluble_protein", "Нерастворимый протеин", "г", ["Нераствор протеин ", "Нерастворим прот ", "Нерастворим прот"]),
_ind("insoluble_protein_pct", "% нераств. протеин", "%", ["% нераств. протеин", "% нераствор протеин"], aggregation="weighted_avg"),
_ind("usp", "уСП", "г", ["уСП", "уСП формул"]),
_ind("usp_pct_dm", "% уСП/кг СВ", "%", ["% уСП/кг СВ", "% уСП/кг СР"], aggregation="weighted_avg"),
_ind("usp_per_kg_dm", "уСП/кг СВ", "г", derived="g_per_kg_dm", from_key="usp"),
_ind("usp_in_om", "уСП в ОВ", "г", ["уСП в ОР", "уСП в ОВ"]),
_ind("usp_low_fat", "уСП <7% СЖ", "г", ["уСП<7%"], aggregation="weighted_avg"),
_ind("usp_high_fat", "уСП >7% СЖ", "г", ["уСП>7%"], aggregation="weighted_avg"),
_ind("ndf", "Сырая клетчатка", "г", ["Сырая клетч", "Сырая клетчатка", "НДК"]),
_ind("ndf_total", "НДК общ", "г", ["НДК Общ", "НДК"]),
_ind("ndf_main_feed", "НДК осн. корм", "г", ["НДК Осн. Корм"]),
_ind("adf_total", "КДК общ", "г", ["КДК общ", "КДК"]),
_ind("structural_fiber", "Структур. клетчатка", "г", ["Структур клетч", "Структур. клетчатка"]),
_ind("structural_fiber_pct", "% стр. клетчатки", "%", ["% стр Сыр. Клетчатк.", "% стр Сыр. Клетч."], aggregation="weighted_avg"),
_ind("crude_fat", "Сырой жир", "г", ["Сырой жир"]),
_ind("rnb", "RNB", "г", ["RNB", "БРА", " БРА "], derived="rnb"),
_ind("calcium", "Ca", "г", ["Ca"]),
_ind("phosphorus", "P", "г", ["P"]),
_ind("magnesium", "Mg", "г", ["Mg"]),
_ind("sodium", "Na", "г", ["Na"]),
_ind("potassium", "K", "г", ["K"]),
_ind("dcab", "DCAB", "мэкв", ["DCAB Форм", "DCAB"]),
_ind("sugar_starch", "Сахар и крахмал", "г", ["Сахар и Крохм"]),
_ind("sugar_digestible_starch", "Сахар и усв. крахмал", "г", ["Сахар и усв. крох"]),
_ind("insoluble_starch", "Нераств. крахмал", "г", ["Нераств. Крохмал", "Нераств Крохм", "Нераств крахмал"]),
_ind("insoluble_starch_alt", "Нераств. крахмал (2)", "г", ["Нераств крохмал"]),
_ind("insoluble_starch_pct", "% нераств. крахмала", "%", ["% Нераств крохм"], aggregation="weighted_avg"),
_ind("sugar", "Сахар", "г", ["Сахар"]),
_ind("starch", "Крахмал", "г", ["Крахмал"]),
_ind("starch_pct_dm", "% крахмала СВ", "%", ["% Крохмал. СВ", "доля крохмала"], aggregation="weighted_avg"),
_ind("carotene", "Каротин", "мг", ["Каротин"]),
_ind("beta_carotene", "β-каротин", "мг", ["b -Каротин"]),
_ind("linoleic_acid", "Линолевая к-та", "г", ["Линолевая к-та"]),
_ind("linolenic_acid", "Линоленовая к-та", "г", ["Линоленовая ки-та"]),
_ind("butyric_acid", "Масляная к-та", "г", ["Масляная ки-та"]),
_ind("arachidonic_acid", "Арахидоновая к-та", "г", ["Арахидоновая ки-та"]),
_ind("polyenoic_acid", "Полиэновая к-та", "г", ["Полиэновая ки-та"]),
_ind("urea", "Мочевина", "г", ["Мочевина"]),
_ind("crude_ash", "Сырая зола", "г", ["Сырая зола"]),
_ind("nfe", "БЭВ", "г", ["БЕР", "БЭВ"]),
_ind("tdn_cattle", "КРС орг. вещество", "г", ["КРС Орг Вещ", "ВРХ Орг Вещ"]),
_ind("digestible_organic_matter", "Перевар. орг. вещество", "г", ["Перевар Орг Вещ", "Переварим Орг Вещ"]),
_ind("cp_cattle", "КРС протеин", "г", ["КРС Протеин"]),
_ind("digestible_protein", "Перевар. протеин", "г", ["Перевар Протеин", "Переварим Протеин"]),
_ind("fat_cattle", "КРС сырой жир", "г", ["КРС Сырой жир", "КРС Сирий жир", "КРС Сирой жир"]),
_ind("digestible_fat", "Перевар. сырой жир", "г", ["Перевар Сырой жир", "Перевар Сирой жир", "Переварим Сырой жир"]),
_ind("fiber_cattle", "КРС сырая клетчатка", "г", ["КРС Сырая клетч"]),
_ind("digestible_fiber", "Перевар. сырая клетчатка", "г", ["Перевар сыр клетч", "Переварим сырая клетч"]),
_ind("nfe_cattle", "КРС БЭВ", "г", ["КРС БЭВ"]),
_ind("digestible_nfe", "Перевар. БЭВ", "г", ["Перевар БЭВ", "Переварим БЭВ"]),
_ind("feed_value", "ВЕ", "", ["ВЕ"], aggregation="weighted_avg"),
_ind("fat_per_kg_dm", "СЖ/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_fat"),
_ind("nsp_per_kg_dm", "НСП/кг СВ", "г", ["НСП/кг СВ"], aggregation="weighted_avg"),
_ind("cp_per_kg_dm", "СП/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_protein"),
_ind("dom_per_kg_dm", "пОВ/кг СВ", "г", derived="g_per_kg_dm", from_key="digestible_organic_matter"),
_ind(
"digestible_fat_per_kg_dm",
"пСЖ/кг СВ",
"г",
derived="g_per_kg_dm",
from_key="digestible_fat",
),
_ind("fiber_pct_per_kg_dm", "%СК/кг СВ", "%", derived="pct_of_dm", from_key="ndf"),
_ind("fat_pct_per_kg_dm", "%-СЖ/кг СВ", "%", derived="pct_of_dm", from_key="crude_fat"),
_ind("usp_pct_per_kg_dm", "%-уСП / кг СВ", "%", derived="pct_of_dm", from_key="usp"),
_ind("ca_pct_per_kg_dm", "%-Ca / кг СВ", "%", derived="pct_of_dm", from_key="calcium"),
_ind("p_pct_per_kg_dm", "%-P / кг СВ", "%", derived="pct_of_dm", from_key="phosphorus"),
_ind("k_pct_per_kg_dm", "%-К / кг СВ", "%", derived="pct_of_dm", from_key="potassium"),
_ind("na_pct_per_kg_dm", "%-Na / кг СВ", "%", derived="pct_of_dm", from_key="sodium"),
_ind("mg_pct_per_kg_dm", "%-Mg / кг СВ", "%", derived="pct_of_dm", from_key="magnesium"),
_ind("ndf_pct_dm", "НДК % от СВ", "%", derived="pct_of_dm", from_key="ndf"),
_ind("ndf_main_pct_dm", "НДК-ОК % от СВ", "%", derived="pct_of_dm", from_key="ndf_main_feed"),
_ind("adf_pct_dm", "КДК % от СВ", "%", derived="pct_of_dm", from_key="adf_total"),
_ind("nfc_pct_dm", "НКВ % от СВ", "%", derived="pct_of_dm", from_key="nfe"),
_ind("sw_per_kg_dm", "SW / кг СВ", "", ["SW / кг СВ ()"], aggregation="weighted_avg"),
_ind("metab_oet", "Метаб. ОЕТ", "г", ["Метаб ОЕТ", "OEB"]),
_ind("metab_lysine", "Метаб. лизин", "г", ["Метаб лизин", "Лизин"]),
_ind("metab_threonine", "Метаб. треонин", "г", ["Метабол Треон", "Треонин"]),
_ind("metab_leucine", "Метаб. лейцин", "г", ["Метаб Лейц", "Метабол Лейцин", "Лейцин"]),
_ind("metab_isoleucine", "Метаб. изолейцин", "г", ["Метаб Изол", "Метабол Изолейц", "Изолейцин"]),
_ind("metab_valine", "Метаб. валин", "г", ["Метаб Вал", "Метабол Валин", "Валин"]),
_ind("vitamin_a", "Витамин А", "МЕ", ["Вит А"]),
_ind("vitamin_d", "Витамин D", "МЕ", ["Вит D"]),
_ind("vitamin_e", "Витамин Е", "мг", ["Вит Е"]),
_ind("vitamin_b1", "Витамин В1", "мг", ["Вит В1"]),
_ind("vitamin_b2", "Витамин В2", "мг", ["Вит В2"]),
_ind("vitamin_b6", "Витамин В6", "мг", ["Вит В6"]),
_ind("vitamin_b12", "Витамин В12", "мкг", ["Вит В12"]),
_ind("calcium_pantothenate", "Пантотенат кальция", "мг", ["Пант Кальц", "Пантеонат Кальция"]),
_ind("niacin", "Никотиновая к-та", "мг", ["Никот Ки-та", "Никотиновая кислота"]),
_ind("folic_acid", "Фолиевая к-та", "мг", ["Фол ки-та", "Фолиева кислота"]),
_ind("choline", "Холин", "мг", ["Холин"]),
_ind("biotin", "Биотин", "мкг", ["Биотин"]),
_ind("iron", "Fe", "мг", ["Fe"]),
_ind("zinc", "Zn", "мг", ["Zn"]),
_ind("copper", "Cu", "мг", ["Cu"]),
_ind("cobalt", "Co", "мг", ["Co"]),
_ind("manganese", "Mn", "мг", ["Mn"]),
_ind("selenium", "Se", "мг", ["Se"]),
_ind("iodine", "J", "мг", ["J"]),
_ind("oet", "OET", "", ["OEB", "OET"]),
_ind("synthesis_mj", "Синтез", "МДж", ["Синтез Мдж"]),
_ind("ca_p_ratio", "Са:P", "", derived="ratio", ratio_num="calcium", ratio_den="phosphorus"),
_ind("k_na_ratio", "K:Na", "", derived="ratio", ratio_num="potassium", ratio_den="sodium"),
_ind("ration_dm_pct_bw", "Рацион % СВ от веса", "%", derived="dm_pct_bw"),
_ind("ration_pct_bw", "Рацион % от веса", "%", derived="ration_pct_bw"),
# Дубликаты ключей норм (укр. заголовки) — тот же расчёт
_ind("ndf_pct_dm_uk", "НДК % від СВ", "%", derived="alias", alias_of="ndf_pct_dm"),
_ind("ndf_main_pct_dm_uk", "НДК-ОК % від СВ", "%", derived="alias", alias_of="ndf_main_pct_dm"),
_ind("adf_pct_dm_uk", "КДК % від СВ", "%", derived="alias", alias_of="adf_pct_dm"),
_ind("nfc_pct_dm_uk", "НКВ % від СВ", "%", derived="alias", alias_of="nfc_pct_dm"),
]
# Обратная совместимость
RATION_QUALITY_INDICATORS = RATION_ALL_INDICATORS
_INDICATOR_BY_KEY = {d["key"]: d for d in RATION_ALL_INDICATORS}
_LABEL_TO_KEY = {d["label"]: d["key"] for d in RATION_ALL_INDICATORS}
def indicator_by_key(key: str) -> dict[str, Any] | None:
return _INDICATOR_BY_KEY.get(key)
def label_to_indicator_key(label: str) -> str | None:
return _LABEL_TO_KEY.get(label)
def calc_indicator_keys() -> frozenset[str]:
return frozenset(_INDICATOR_BY_KEY)
@@ -0,0 +1,5 @@
from .execution_loader import load_execution
from .profile_loader import list_animal_profiles, load_animal_profile
from .ration_loader import load_ration
__all__ = ["load_ration", "load_execution", "list_animal_profiles", "load_animal_profile"]
@@ -0,0 +1,17 @@
from __future__ import annotations
import json
from typing import Any
def parse_json_text(value: Any, default: Any = None) -> Any:
if default is None:
default = {}
if value is None or value == "":
return default
if isinstance(value, dict):
return value
try:
return json.loads(value)
except (TypeError, json.JSONDecodeError):
return default
@@ -0,0 +1,29 @@
from __future__ import annotations
from app.modules.zootech.lab.dto.ration import ExecutionLineSnapshot, ExecutionSnapshot
from app.modules.zootech.wesp_bridge_models import Ingredient, Recipe
def load_execution(recipe_id: str) -> ExecutionSnapshot:
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 1)
ingredients = (
Ingredient.query.filter_by(recipe_id=recipe_id, is_deleted=False)
.order_by(Ingredient.order)
.all()
)
lines = []
for ing in ingredients:
wph = float(ing.weight_per_head or 0)
lines.append(
ExecutionLineSnapshot(
ingredient_id=ing.id,
component_id=ing.component_id,
name=ing.name,
weight_per_head=wph,
daily_kg_total=wph * heads,
)
)
return ExecutionSnapshot(recipe_id=recipe_id, heads_per_trip=heads, lines=tuple(lines))
@@ -0,0 +1,58 @@
from __future__ import annotations
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.services.profile_norms import load_norms_api
def list_animal_profiles(ration_type: str | None = None) -> list[dict]:
q = LabAnimalProfile.query.filter_by(is_deleted=False)
if ration_type:
q = q.filter_by(ration_type=ration_type.upper())
rows = q.order_by(LabAnimalProfile.profile_key).all()
return [_profile_dict(p, include_resolved=False) for p in rows]
def load_animal_profile(profile_id: str) -> dict:
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
return _profile_dict(profile, resolve_norms=True, include_resolved=True)
def _profile_dict(
profile: LabAnimalProfile,
*,
resolve_norms: bool = False,
include_resolved: bool = False,
) -> dict:
norms_api = load_norms_api(profile, resolve_dynamic=include_resolved) if resolve_norms else {}
if not resolve_norms:
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
indicators = load_norms_dict(profile.id)
norms_api = {"indicators": indicators}
from app.modules.zootech.lab.services.norms_params import norms_params_api
out: dict = {
"id": profile.id,
"profileKey": profile.profile_key,
"label": profile.label,
"rationType": profile.ration_type,
"massKg": profile.mass_kg,
"milkYieldKg": profile.milk_yield_kg,
"externalNo": profile.external_no,
"normsMethod": profile.norms_method or "wesp",
"normsParams": norms_params_api(profile),
"normsData": norms_api,
"norms": norms_api.get("indicators") or {},
"normsProfileKey": profile.profile_key,
"legacyNormsRemapped": False,
}
if include_resolved:
out["resolvedNorms"] = norms_api.get("resolvedIndicators") or {}
out["dynamicNorms"] = norms_api.get("dynamicNorms") or {}
if norms_api.get("coverage"):
out["coverage"] = norms_api["coverage"]
if norms_api.get("normsMeta"):
out["normsMeta"] = norms_api["normsMeta"]
return out
@@ -0,0 +1,125 @@
from __future__ import annotations
from app.modules.zootech.lab.calc.nutrients import norm_diff
from app.modules.zootech.lab.indicators import label_to_indicator_key
from app.modules.zootech.lab.dto.ration import RationLineSnapshot, RationSnapshot
from app.modules.zootech.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
from app.modules.zootech.lab.services.component_nutrients import nutrients_calc_dict
from app.modules.zootech.lab.services.profile_norms import resolve_norms_for_profile
from app.modules.zootech.lab.services.ration_calc_store import (
load_compound_results,
load_params,
load_ration_results,
)
from app.modules.zootech.wesp_bridge_models import Component, Recipe
def _refresh_indicator_norms(
indicators: list[dict],
norms: dict[str, dict[str, float | None]],
) -> list[dict]:
"""min/max/diff в сохранённом расчёте — по текущему профилю норм."""
if not indicators or not norms:
return indicators
out: list[dict] = []
for row in indicators:
item = dict(row)
key = item.get("key") or label_to_indicator_key(str(item.get("label") or ""))
if key and key in norms:
bounds = norms[key]
min_v = bounds.get("min")
max_v = bounds.get("max")
item["min"] = min_v
item["max"] = max_v
item["diff"] = norm_diff(item.get("content"), min_v, max_v)
out.append(item)
return out
def load_ration(recipe_id: str) -> RationSnapshot:
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, is_deleted=False).first()
ration_type = recipe.ration_type or "BEEF"
norms: dict = {}
params: dict = {}
ration_results: dict = {}
compound_results: dict = {}
calculated_at = None
animal_profile_id = None
norms_profile_key = None
if header:
animal_profile_id = header.animal_profile_id
params = load_params(recipe_id, header)
ration_results = load_ration_results(recipe_id, header)
compound_results = load_compound_results(recipe_id)
calculated_at = header.calculated_at.isoformat() if header.calculated_at else None
if header.animal_profile_id:
profile = LabAnimalProfile.query.filter_by(
id=header.animal_profile_id, is_deleted=False
).first()
if profile:
norms_profile_key = profile.profile_key
norms = resolve_norms_for_profile(profile)
if ration_results.get("indicators") and norms:
ration_results = {
**ration_results,
"indicators": _refresh_indicator_norms(ration_results["indicators"], norms),
}
lines_q = (
LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False)
.order_by(LabRationLine.row_index)
.all()
)
line_snaps = []
for line in lines_q:
comp = Component.query.get(line.component_id) if line.component_id else None
nutrients = nutrients_calc_dict(line.component_id) if line.component_id else {}
dry_matter_pct = comp.dry_matter if comp else None
line_snaps.append(
RationLineSnapshot(
id=line.id,
component_id=line.component_id,
ingredient_name=line.ingredient_name or (comp.name if comp else None),
row_index=line.row_index,
daily_kg=line.daily_kg,
in_ration=bool(line.in_ration),
in_compound=bool(line.in_compound),
dry_matter=dry_matter_pct,
price_per_kg=comp.price if comp else None,
nutrients=nutrients,
)
)
return RationSnapshot(
recipe_id=recipe_id,
recipe_name=recipe.name,
ration_type=ration_type,
heads_per_trip=int(recipe.heads_per_trip or 1),
animal_profile_id=animal_profile_id,
params=params,
lines=tuple(line_snaps),
norms=norms,
ration_results=ration_results,
compound_results=compound_results,
calculated_at=calculated_at,
exists=header is not None,
norms_profile_key=norms_profile_key,
legacy_norms_remapped=False,
)
def ration_to_calc_lines(snapshot: RationSnapshot) -> list[dict]:
return [
{
"daily_kg": line.daily_kg,
"in_ration": line.in_ration,
"in_compound": line.in_compound,
"ingredient_name": line.ingredient_name,
"price_per_kg": line.price_per_kg,
"dry_matter": line.dry_matter,
"nutrients": line.nutrients,
"component_id": line.component_id,
}
for line in snapshot.lines
]
@@ -0,0 +1,17 @@
"""Re-export WESP lab model aliases for ported lab package."""
from app.modules.zootech.wesp_bridge_models import ( # noqa: F401
LabAnimalProfile,
LabComponentNutrientValue,
LabProfileNorm,
LabRacionNormyInfo,
LabRacionNormyMoskwa,
LabRacionNormyMoskwaMeta,
LabRacionNormyPiter,
LabRacionNormyPiterMeta,
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
LabRationLine,
LabRecipeRation,
)
@@ -0,0 +1,290 @@
"""Каталог показателей норм профиля стада."""
from __future__ import annotations
import re
import unicodedata
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, calc_indicator_keys
_CALC_META = {d["key"]: d for d in RATION_ALL_INDICATORS}
# Заголовок колонки CSV «Нормы Дойн/КРС» (после нормализации) → indicator_key в lab_profile_norm
NORM_HEADER_TO_KEY: dict[str, str] = {
"Сухое Вещество": "dry_matter",
"Сыр. Протеин": "crude_protein",
"уСП": "usp",
"RNB": "rnb",
"БРА": "rnb",
"ЧЭЛ- КРС": "nel",
"ОЭ-КРС": "oe",
"Сырая клетч": "ndf",
"Сырая клетчат": "ndf",
"Сырая клетчатка": "ndf",
"Структур клетчатк": "structural_fiber",
"Сырой жир": "crude_fat",
"Сирой жир": "crude_fat",
"Ca": "calcium",
"P": "phosphorus",
"Mg": "magnesium",
"Na": "sodium",
"DCAB": "dcab",
"Сахар и Крохм": "sugar_starch",
"Нераств. Крохмал": "insoluble_starch",
"Нерозч. Крохмал": "insoluble_starch",
"Сахар": "sugar",
"Крохмал": "starch",
"Каротин": "carotene",
"b -Каротин": "beta_carotene",
"Линолевая к-та": "linoleic_acid",
"Линоленовая ки-та": "linolenic_acid",
"Масляная ки-та": "butyric_acid",
"Мочевина": "urea",
"СВ Основной корм": "dm_main",
"КРС Орг Вещ": "tdn_cattle",
"Перевар Орг Вещ": "digestible_organic_matter",
"КРС Протеин": "cp_cattle",
"Перевар Протеин": "digestible_protein",
"КРС Сирий жир": "fat_cattle",
"КРС Сирой жир": "fat_cattle",
"Перевар Сырой жир": "digestible_fat",
"Перевар Сирой жир": "digestible_fat",
"КРС Сырая клетч": "fiber_cattle",
"Перевар сыр клетч": "digestible_fiber",
"КРС БЭВ": "nfe_cattle",
"Перевар БЭВ": "digestible_nfe",
"ВЕ": "feed_value",
"НДК Общ": "ndf_total",
"НДК Осн. Корм": "ndf_main_feed",
"КДК общ": "adf_total",
"% нераств. протеин": "insoluble_protein_pct",
"Нераствор протеин": "insoluble_protein",
"СЖ/кг СВ": "fat_per_kg_dm",
"НСП/кг СВ": "nsp_per_kg_dm",
"СП/кг СВ": "cp_per_kg_dm",
"пОВ/кг СВ": "dom_per_kg_dm",
"пСЖ/кг СВ": "digestible_fat_per_kg_dm",
"уСП<7%": "usp_low_fat",
"уСП>7%": "usp_high_fat",
"уСП/кг СВ": "usp_per_kg_dm",
"уСП в ОВ": "usp_in_om",
"Нераств крохмал": "insoluble_starch_alt",
"Метаб ОЕТ": "metab_oet",
"Метаб лизин": "metab_lysine",
"Метабол Треон": "metab_threonine",
"Метаб Лейц": "metab_leucine",
"Метаб Изол": "metab_isoleucine",
"Метаб Вал": "metab_valine",
"НДК % від СВ ()": "ndf_pct_dm_uk",
"НДК % от СВ ()": "ndf_pct_dm",
"НДК-ОК % від СВ ()": "ndf_main_pct_dm_uk",
"НДК-ОК % от СВ ()": "ndf_main_pct_dm",
"КДК % від СВ ()": "adf_pct_dm_uk",
"КДК % от СВ ()": "adf_pct_dm",
"НКВ % від СВ ()": "nfc_pct_dm_uk",
"НКВ % от СВ ()": "nfc_pct_dm",
"SW / кг СВ ()": "sw_per_kg_dm",
"ЧЕЛ/кг СВ (МДж)": "nel_per_kg_dm",
"Нер. СП / кг СВ": "rup_per_kg_dm",
"%СК/кг СВ ()": "fiber_pct_per_kg_dm",
"%-СЖ/кг СВ ()": "fat_pct_per_kg_dm",
"%-уСП / кг СВ ()": "usp_pct_per_kg_dm",
"% уСП/кг СВ": "usp_pct_dm",
"%-Ca / кг СВ ()": "ca_pct_per_kg_dm",
"%-P / кг СВ ()": "p_pct_per_kg_dm",
"%-К / кг СВ ()": "k_pct_per_kg_dm",
"%-Na / кг СВ ()": "na_pct_per_kg_dm",
"%-Mg / кг СВ ()": "mg_pct_per_kg_dm",
"% стр Сыр. Клетчатк.": "structural_fiber_pct",
"% стр Сыр. Клетч.": "structural_fiber_pct",
"% Крохмал. СВ": "starch_pct_dm",
"% Нераств крохм": "insoluble_starch_pct",
"Са:P": "ca_p_ratio",
"K:Na": "k_na_ratio",
"Рацион % СВ от Веса КРС": "ration_dm_pct_bw",
"Рацион % от Веса КРС": "ration_pct_bw",
"Витамин А": "vitamin_a",
"Витамин D": "vitamin_d",
"Витамин Е": "vitamin_e",
"Витамин В1": "vitamin_b1",
"Витамин В2": "vitamin_b2",
"Витамин В6": "vitamin_b6",
"Витамин В12": "vitamin_b12",
"Пантеонат Кальция": "calcium_pantothenate",
"Пантеонат Кальцию": "calcium_pantothenate",
"Никотиновая кислота": "niacin",
"Фолиева кислота": "folic_acid",
"Фолиевач кислота": "folic_acid",
"Холин": "choline",
"Биотин": "biotin",
"Fe": "iron",
"Zn": "zinc",
"Cu": "copper",
"Co": "cobalt",
"Mn": "manganese",
"Se": "selenium",
"J": "iodine",
"K": "potassium",
"Сахар и усв. крох": "sugar_digestible_starch",
"Сырая зола": "crude_ash",
"Сірая зола": "crude_ash",
"БЭВ": "nfe",
"БЄВ": "nfe",
"Арахидоновая ки-та": "arachidonic_acid",
"Полиэновая ки-та": "polyenoic_acid",
"OET": "oet",
"Синтез Мдж": "synthesis_mj",
}
# Метаданные для UI (label/unit) — показатели вне расчёта рациона
_EXTRA_NORM_META: dict[str, dict[str, str]] = {
"beta_carotene": {"label": "β-каротин", "unit": "мг"},
"linoleic_acid": {"label": "Линолевая к-та", "unit": "г"},
"linolenic_acid": {"label": "Линоленовая к-та", "unit": "г"},
"butyric_acid": {"label": "Масляная к-та", "unit": "г"},
"urea": {"label": "Мочевина", "unit": "г"},
"tdn_cattle": {"label": "КРС орг. вещество", "unit": "г"},
"digestible_organic_matter": {"label": "Перевар. орг. вещество", "unit": "г"},
"cp_cattle": {"label": "КРС протеин", "unit": "г"},
"digestible_protein": {"label": "Перевар. протеин", "unit": "г"},
"fat_cattle": {"label": "КРС сырой жир", "unit": "г"},
"digestible_fat": {"label": "Перевар. сырой жир", "unit": "г"},
"fiber_cattle": {"label": "КРС сырая клетчатка", "unit": "г"},
"digestible_fiber": {"label": "Перевар. сырая клетчатка", "unit": "г"},
"nfe_cattle": {"label": "КРС БЭВ", "unit": "г"},
"digestible_nfe": {"label": "Перевар. БЭВ", "unit": "г"},
"feed_value": {"label": "ВЕ", "unit": ""},
"ndf_total": {"label": "НДК общ", "unit": "г"},
"ndf_main_feed": {"label": "НДК осн. корм", "unit": "г"},
"adf_total": {"label": "КДК общ", "unit": "г"},
"insoluble_protein_pct": {"label": "% нераств. протеин", "unit": "%"},
"fat_per_kg_dm": {"label": "СЖ/кг СВ", "unit": "г"},
"nsp_per_kg_dm": {"label": "НСП/кг СВ", "unit": "г"},
"cp_per_kg_dm": {"label": "СП/кг СВ", "unit": "г"},
"dom_per_kg_dm": {"label": "пОВ/кг СВ", "unit": "г"},
"digestible_fat_per_kg_dm": {"label": "пСЖ/кг СВ", "unit": "г"},
"usp_low_fat": {"label": "уСП <7% СЖ", "unit": "г"},
"usp_high_fat": {"label": "уСП >7% СЖ", "unit": "г"},
"usp_per_kg_dm": {"label": "уСП/кг СВ", "unit": "г"},
"usp_in_om": {"label": "уСП в ОВ", "unit": "г"},
"insoluble_starch_alt": {"label": "Нераств. крахмал (2)", "unit": "г"},
"metab_oet": {"label": "Метаб. ОЕТ", "unit": "г"},
"metab_lysine": {"label": "Метаб. лизин", "unit": "г"},
"metab_threonine": {"label": "Метаб. треонин", "unit": "г"},
"metab_leucine": {"label": "Метаб. лейцин", "unit": "г"},
"metab_isoleucine": {"label": "Метаб. изолейцин", "unit": "г"},
"metab_valine": {"label": "Метаб. валин", "unit": "г"},
"ndf_pct_dm": {"label": "НДК % от СВ", "unit": "%"},
"ndf_pct_dm_uk": {"label": "НДК % від СВ", "unit": "%"},
"ndf_main_pct_dm": {"label": "НДК-ОК % от СВ", "unit": "%"},
"ndf_main_pct_dm_uk": {"label": "НДК-ОК % від СВ", "unit": "%"},
"adf_pct_dm": {"label": "КДК % от СВ", "unit": "%"},
"adf_pct_dm_uk": {"label": "КДК % від СВ", "unit": "%"},
"nfc_pct_dm": {"label": "НКВ % от СВ", "unit": "%"},
"nfc_pct_dm_uk": {"label": "НКВ % від СВ", "unit": "%"},
"sw_per_kg_dm": {"label": "SW / кг СВ", "unit": ""},
"fiber_pct_per_kg_dm": {"label": "%СК/кг СВ", "unit": "%"},
"fat_pct_per_kg_dm": {"label": "%-СЖ/кг СВ", "unit": "%"},
"usp_pct_per_kg_dm": {"label": "%-уСП / кг СВ", "unit": "%"},
"ca_pct_per_kg_dm": {"label": "%-Ca / кг СВ", "unit": "%"},
"p_pct_per_kg_dm": {"label": "%-P / кг СВ", "unit": "%"},
"k_pct_per_kg_dm": {"label": "%-К / кг СВ", "unit": "%"},
"na_pct_per_kg_dm": {"label": "%-Na / кг СВ", "unit": "%"},
"mg_pct_per_kg_dm": {"label": "%-Mg / кг СВ", "unit": "%"},
"structural_fiber_pct": {"label": "% стр. клетчатки", "unit": "%"},
"starch_pct_dm": {"label": "% крахмала СВ", "unit": "%"},
"insoluble_starch_pct": {"label": "% нераств. крахмала", "unit": "%"},
"ca_p_ratio": {"label": "Са:P", "unit": ""},
"k_na_ratio": {"label": "K:Na", "unit": ""},
"ration_dm_pct_bw": {"label": "Рацион % СВ от веса", "unit": "%"},
"ration_pct_bw": {"label": "Рацион % от веса", "unit": "%"},
"vitamin_a": {"label": "Витамин А", "unit": "МЕ"},
"vitamin_d": {"label": "Витамин D", "unit": "МЕ"},
"vitamin_e": {"label": "Витамин Е", "unit": "мг"},
"vitamin_b1": {"label": "Витамин В1", "unit": "мг"},
"vitamin_b2": {"label": "Витамин В2", "unit": "мг"},
"vitamin_b6": {"label": "Витамин В6", "unit": "мг"},
"vitamin_b12": {"label": "Витамин В12", "unit": "мкг"},
"calcium_pantothenate": {"label": "Пантотенат кальция", "unit": "мг"},
"niacin": {"label": "Никотиновая к-та", "unit": "мг"},
"folic_acid": {"label": "Фолиевая к-та", "unit": "мг"},
"choline": {"label": "Холин", "unit": "мг"},
"biotin": {"label": "Биотин", "unit": "мкг"},
"iron": {"label": "Fe", "unit": "мг"},
"zinc": {"label": "Zn", "unit": "мг"},
"copper": {"label": "Cu", "unit": "мг"},
"cobalt": {"label": "Co", "unit": "мг"},
"manganese": {"label": "Mn", "unit": "мг"},
"selenium": {"label": "Se", "unit": "мг"},
"iodine": {"label": "J", "unit": "мг"},
"potassium": {"label": "K", "unit": "г"},
"sugar_digestible_starch": {"label": "Сахар и усв. крахмал", "unit": "г"},
"crude_ash": {"label": "Сырая зола", "unit": "г"},
"nfe": {"label": "БЭВ", "unit": "г"},
"arachidonic_acid": {"label": "Арахидоновая к-та", "unit": "г"},
"polyenoic_acid": {"label": "Полиэновая к-та", "unit": "г"},
"oet": {"label": "OET", "unit": ""},
"synthesis_mj": {"label": "Синтез", "unit": "МДж"},
}
_CALC_KEYS = calc_indicator_keys()
def norm_header(value: str) -> str:
return " ".join((value or "").split()).strip()
def norm_title_to_key(title: str) -> str | None:
"""Заголовок колонки CSV → indicator_key (или None)."""
key = NORM_HEADER_TO_KEY.get(norm_header(title))
if key:
return key
return _fallback_key(title)
def _fallback_key(title: str) -> str | None:
n = norm_header(title)
if not n:
return None
low = n.lower().replace("і", "и").replace("є", "е")
for header, key in NORM_HEADER_TO_KEY.items():
if header.lower() == low:
return key
slug = re.sub(r"[^a-z0-9]+", "_", unicodedata.normalize("NFKD", low).encode("ascii", "ignore").decode())
slug = slug.strip("_")[:48]
return f"norm_{slug}" if slug else None
def norm_indicator_meta(key: str) -> dict[str, str | bool]:
calc = _CALC_META.get(key)
if calc:
return {
"key": key,
"label": str(calc["label"]),
"unit": str(calc.get("unit") or ""),
"inCalc": True,
}
extra = _EXTRA_NORM_META.get(key, {})
return {
"key": key,
"label": extra.get("label", key),
"unit": extra.get("unit", ""),
"inCalc": key in _CALC_KEYS,
}
def list_norm_indicators() -> list[dict[str, str | bool]]:
keys: list[str] = []
seen: set[str] = set()
for key in NORM_HEADER_TO_KEY.values():
if key not in seen:
seen.add(key)
keys.append(key)
for key in sorted(_EXTRA_NORM_META):
if key not in seen:
keys.append(key)
return [norm_indicator_meta(k) for k in keys]
def calc_norm_keys() -> frozenset[str]:
return _CALC_KEYS
@@ -0,0 +1,28 @@
"""Канонические indicator_key для нутриентов компонента (без fuzzy-match)."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.norm_catalog import NORM_HEADER_TO_KEY
def augment_with_indicator_keys(data: dict[str, Any] | None) -> dict[str, float]:
"""Дублирует значения под slug indicator_key."""
if not data:
return {}
out: dict[str, float] = {}
for raw_key, raw_val in data.items():
try:
out[str(raw_key)] = float(raw_val)
except (TypeError, ValueError):
continue
for header, indicator_key in NORM_HEADER_TO_KEY.items():
if header in out and indicator_key not in out:
out[indicator_key] = out[header]
return out
def canonicalize_for_storage(data: dict[str, Any] | None) -> dict[str, float]:
"""При записи в EAV: slug indicator_key + исходный заголовок."""
return augment_with_indicator_keys(data)
@@ -0,0 +1,163 @@
"""Канонические поля zootech-показателей компонента (parity tab INGREDIENT_NUTRIENT_EDIT_ROWS)."""
from __future__ import annotations
from typing import Any
# column_name -> ключи в API/calc (первый — канон для UI)
NUTRIENT_FIELD_SPECS: tuple[tuple[str, tuple[str, ...]], ...] = (
("crude_protein", ("Сыр. Протеин",)),
("usp", ("уСП",)),
("rnb", ("RNB", "БРА", " БРА ")),
("nel_cattle", ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм")),
("oe_cattle", ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм")),
("ndf", ("Сырая клетчатка", "Сырая клетч")),
("structural_fiber", ("Структур. клетчатка", "Структур клетч")),
("crude_fat", ("Сырой жир",)),
)
def _normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def _parse_num(value: Any) -> float | None:
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
return None
return None if n != n else n
def dry_matter_g_per_kg(dry_matter_pct: float | None) -> float | None:
"""СВ г/кг из канонического component.dry_matter (%): 88.7% → 887 г/кг."""
dm = _parse_num(dry_matter_pct)
if dm is None:
return None
if 0 < dm <= 100:
return dm * 10.0
if dm > 100:
return dm
return None
def resolve_sv_g_per_kg(
nutrients: dict[str, Any] | None,
dry_matter_pct: float | None,
) -> float | None:
"""Deprecated alias: СВ только из component.dry_matter (%). nutrients игнорируется."""
return dry_matter_g_per_kg(dry_matter_pct)
def read_from_mapping(data: dict[str, Any] | None, keys: tuple[str, ...]) -> float | None:
if not data:
return None
for search in keys:
target = _normalize_key(search)
for k, v in data.items():
if _normalize_key(str(k)) != target:
continue
n = _parse_num(v)
if n is not None:
return n
return None
def api_dict_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
payload = data or {}
out: dict[str, float | None] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
out[col] = read_from_mapping(payload, keys)
return out
def derived_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
return api_dict_to_column_values(data)
def column_values_to_mapping(values: dict[str, float | None]) -> dict[str, float]:
out: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = values.get(col)
n = _parse_num(val)
if n is not None:
out[keys[0]] = n
return out
def mapping_to_calc_dict(data: dict[str, Any] | None) -> dict[str, float]:
if not data:
return {}
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
result: dict[str, float] = {}
for _col, keys in NUTRIENT_FIELD_SPECS:
val = read_from_mapping(data, keys)
if val is None:
continue
for key in keys:
result[key] = val
seen: set[str] = set()
for defn in RATION_ALL_INDICATORS:
for key in defn.get("nutrient_keys") or []:
if key in seen:
continue
seen.add(key)
val = read_from_mapping(data, (key,))
if val is not None:
result[key] = val
return result
def row_to_calc_dict(row: Any | None) -> dict[str, float]:
if row is None:
return {}
result: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = getattr(row, col, None)
if val is None:
continue
n = _parse_num(val)
if n is None:
continue
for key in keys:
result[key] = n
return result
def row_to_api_dict(row: Any | None) -> dict[str, float]:
"""API nutrients — канонические ключи tab."""
if row is None:
return {}
out: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = getattr(row, col, None)
n = _parse_num(val)
if n is not None:
out[keys[0]] = n
return out
def row_is_empty(row: Any | None) -> bool:
if row is None:
return True
for col, _keys in NUTRIENT_FIELD_SPECS:
if _parse_num(getattr(row, col, None)) is not None:
return False
return True
def legacy_json_to_column_values(raw: str | None) -> dict[str, float | None]:
if not raw or not str(raw).strip() or str(raw).strip() == "{}":
return {col: None for col, _ in NUTRIENT_FIELD_SPECS}
import json
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
data = {}
if not isinstance(data, dict):
data = {}
return api_dict_to_column_values(data)
@@ -0,0 +1,9 @@
"""Справочные zootech-профили (norm_*) vs пользовательские стандарты."""
from __future__ import annotations
_REFERENCE_PREFIX = "norm_"
def is_reference_profile(profile_key: str | None) -> bool:
return bool(profile_key) and profile_key.startswith(_REFERENCE_PREFIX)
@@ -0,0 +1,23 @@
"""Пути к seed-данным WESP (data/seed/)."""
from __future__ import annotations
from pathlib import Path
_API_ROOT = Path(__file__).resolve().parents[4]
_REPO_ROOT = _API_ROOT.parent.parent
def seed_dir() -> Path:
local = _API_ROOT / "data" / "seed"
if local.exists():
return local
return _REPO_ROOT / "WESP_REL" / "data" / "seed"
def norms_dir() -> Path:
return seed_dir() / "norms"
def nutrients_dir() -> Path:
return seed_dir() / "nutrients"
@@ -0,0 +1,3 @@
from .api import ration_to_api
__all__ = ["ration_to_api"]
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.dto.ration import RationSnapshot
def ration_to_api(snapshot: RationSnapshot) -> dict[str, Any]:
return {
"recipeId": snapshot.recipe_id,
"recipeName": snapshot.recipe_name,
"rationType": snapshot.ration_type,
"headsPerTrip": snapshot.heads_per_trip,
"exists": snapshot.exists,
"animalProfileId": snapshot.animal_profile_id,
"params": snapshot.params,
"norms": snapshot.norms,
"normsProfileKey": snapshot.norms_profile_key,
"legacyNormsRemapped": snapshot.legacy_norms_remapped,
"rationResults": snapshot.ration_results,
"compoundResults": snapshot.compound_results,
"calculatedAt": snapshot.calculated_at,
"lines": [
{
"id": line.id,
"componentId": line.component_id,
"ingredientName": line.ingredient_name,
"rowIndex": line.row_index,
"dailyKg": line.daily_kg,
"inRation": line.in_ration,
"inCompound": line.in_compound,
"dryMatter": line.dry_matter,
"pricePerKg": line.price_per_kg,
}
for line in snapshot.lines
],
}
def diff_to_api(diff: dict[str, Any]) -> dict[str, Any]:
return {
"hasChanges": diff.get("has_changes", False),
"lines": [
{
"componentId": row.get("component_id"),
"masterKg": row.get("master_kg"),
"executionKg": row.get("execution_kg"),
"reasons": row.get("reasons", []),
}
for row in diff.get("lines", [])
],
}
@@ -0,0 +1,249 @@
from __future__ import annotations
import uuid
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.calc.gfe_policies import DeriveContext
from app.modules.zootech.lab.calc.ingredient_catalog import DERIVED_HEADERS
from app.modules.zootech.lab.calc.ingredient_derive import derive_ingredient_nutrients
from app.modules.zootech.lab.models import LabComponentNutrientValue
from app.modules.zootech.lab.nutrient_keys import augment_with_indicator_keys, canonicalize_for_storage
from app.modules.zootech.lab.nutrient_schema import (
dry_matter_g_per_kg,
mapping_to_calc_dict,
read_from_mapping,
)
from app.modules.zootech.wesp_bridge_models import Component
_DERIVE_INPUT_KEYS = ("СВ", "Сыр. Протеин", "Сырая клетч", "Сырой жир")
_EAV_SKIP_KEYS = frozenset({"СВ"})
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
def derive_context_for_component(
component_id: str | None,
merged: dict[str, Any] | None = None,
) -> DeriveContext:
"""Контекст derive: тип корма + признак основного корма."""
main_feed = read_from_mapping(merged or {}, _MAIN_FEED_KEYS)
is_main = main_feed is not None and float(main_feed) > 0
feed_group: str = "unknown"
if component_id:
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is not None:
feed_group = classify_feed_group(comp)
return DeriveContext(feed_group=feed_group, is_main_feed=is_main) # type: ignore[arg-type]
def _component_dry_matter_pct(component_id: str | None) -> float | None:
if not component_id:
return None
comp = Component.query.get(component_id)
return comp.dry_matter if comp else None
def _inject_sv_for_derive(merged: dict[str, Any], dry_matter_pct: float | None) -> dict[str, Any]:
out = dict(merged)
sv = dry_matter_g_per_kg(dry_matter_pct)
if sv is not None:
out["СВ"] = sv
return out
def _should_derive(merged: dict[str, Any]) -> bool:
"""Derive при полном базовом вводе (СВ из component.dry_matter + 3 показателя)."""
return all(read_from_mapping(merged, (k,)) is not None for k in _DERIVE_INPUT_KEYS)
def nutrients_full_dict(component_id: str | None) -> dict[str, float]:
if not component_id:
return {}
rows = LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
return {
row.nutrient_key: float(row.value)
for row in rows
if row.value is not None and row.nutrient_key not in _EAV_SKIP_KEYS
}
def nutrients_api_dict(component_id: str | None) -> dict[str, float]:
return nutrients_full_dict(component_id)
def _fill_missing_derived(
merged: dict[str, float],
*,
component_id: str | None,
) -> dict[str, float]:
"""Добавляет derived-поля (пОВ, переваримые фракции), не перезаписывая введённые лаб. значения."""
if not _should_derive(merged):
return merged
ctx = derive_context_for_component(component_id, merged)
derived = derive_ingredient_nutrients(merged, context=ctx)
out = dict(merged)
for header in DERIVED_HEADERS:
if read_from_mapping(out, (header,)) is not None:
continue
val = read_from_mapping(derived, (header,))
if val is not None:
out[header] = float(val)
return out
def _repair_for_ration_calc(
full: dict[str, float],
dry_matter_pct: float | None,
component_id: str | None = None,
) -> dict[str, float]:
merged = _inject_sv_for_derive(full, dry_matter_pct)
oe = read_from_mapping(merged, ("ОЭ-КРС", " ОЭ-КРС"))
nel = read_from_mapping(merged, ("ЧЭЛ- КРС", " ЧЭЛ- КРС"))
energy_bad = (oe is not None and oe < 0) or (nel is not None and nel < 0)
if energy_bad:
minimal = {key: read_from_mapping(merged, (key,)) for key in _DERIVE_INPUT_KEYS}
if all(v is not None for v in minimal.values()):
ctx = derive_context_for_component(component_id, merged)
merged.update(derive_ingredient_nutrients(minimal, context=ctx))
else:
merged = _fill_missing_derived(merged, component_id=component_id)
return merged
def _calc_dict_from_full(
full: dict[str, float],
dry_matter_pct: float | None,
component_id: str | None,
) -> dict[str, float]:
repaired = _repair_for_ration_calc(full, dry_matter_pct, component_id)
return mapping_to_calc_dict(augment_with_indicator_keys(repaired))
def nutrients_calc_dict(component_id: str | None) -> dict[str, float]:
full = nutrients_full_dict(component_id)
dry_matter_pct = _component_dry_matter_pct(component_id)
return _calc_dict_from_full(full, dry_matter_pct, component_id)
def nutrients_calc_dict_batch(component_ids: list[str]) -> dict[str, dict[str, float]]:
"""Batch-load EAV + dry_matter for formulate (one SQL round-trip per table)."""
unique = list(dict.fromkeys(cid for cid in component_ids if cid))
if not unique:
return {}
eav_by_id: dict[str, dict[str, float]] = {cid: {} for cid in unique}
rows = LabComponentNutrientValue.query.filter(
LabComponentNutrientValue.component_id.in_(unique),
).all()
for row in rows:
if row.value is None or row.nutrient_key in _EAV_SKIP_KEYS:
continue
eav_by_id.setdefault(row.component_id, {})[row.nutrient_key] = float(row.value)
dm_by_id: dict[str, float | None] = {cid: None for cid in unique}
for comp in Component.query.filter(
Component.id.in_(unique),
Component.is_deleted.is_(False),
).all():
dm_by_id[comp.id] = comp.dry_matter
return {
cid: _calc_dict_from_full(eav_by_id.get(cid, {}), dm_by_id.get(cid), cid)
for cid in unique
}
def nutrients_is_empty(component_id: str | None) -> bool:
if not component_id:
return True
return (
LabComponentNutrientValue.query.filter(
LabComponentNutrientValue.component_id == component_id,
LabComponentNutrientValue.nutrient_key.notin_(_EAV_SKIP_KEYS),
).first()
is None
)
def _upsert_eav(component_id: str, nutrients: dict[str, float]) -> None:
existing = {
row.nutrient_key: row
for row in LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
}
for key in list(existing):
if key in _EAV_SKIP_KEYS:
db.session.delete(existing[key])
existing = {k: v for k, v in existing.items() if k not in _EAV_SKIP_KEYS}
for key, value in nutrients.items():
if value is None or key in _EAV_SKIP_KEYS:
continue
row = existing.get(key)
if row is None:
row = LabComponentNutrientValue(
id=str(uuid.uuid4()),
component_id=component_id,
nutrient_key=key,
value=float(value),
)
db.session.add(row)
existing[key] = row
else:
row.value = float(value)
def _prepare_payload(
component_id: str,
nutrients: dict[str, Any] | None,
*,
pin: bool = False,
) -> dict[str, float]:
dry_matter_pct = _component_dry_matter_pct(component_id)
merged = _inject_sv_for_derive(
{**nutrients_full_dict(component_id), **(nutrients or {})},
dry_matter_pct,
)
if pin:
payload = merged
elif _should_derive(merged):
ctx = derive_context_for_component(component_id, merged)
derived = derive_ingredient_nutrients(merged, context=ctx)
payload = {**merged, **derived}
else:
payload = merged
stored = canonicalize_for_storage(payload)
return {k: v for k, v in stored.items() if k not in _EAV_SKIP_KEYS}
def save_component_nutrients(
component_id: str,
nutrients: dict[str, Any] | None,
*,
user_id: str = "system",
pin: bool = False,
) -> dict[str, Any]:
"""Единственная точка записи EAV + derive. Возвращает stored dict и affectedRecipeIds."""
stored = _prepare_payload(component_id, nutrients, pin=pin)
_upsert_eav(component_id, stored)
from app.modules.zootech.lab.services.ration_recalc import find_rations_by_component
affected = find_rations_by_component(component_id)
return {"stored": stored, "affectedRecipeIds": affected, "userId": user_id}
# Backward-compatible aliases for tests and gradual migration
def upsert_from_api_dict(component_id: str, nutrients: dict[str, Any] | None) -> dict[str, float]:
result = save_component_nutrients(component_id, nutrients)
return result["stored"]
def pin_nutrient_values(component_id: str, values: dict[str, float]) -> None:
save_component_nutrients(component_id, values, pin=True)
def upsert_from_column_values(component_id: str, values: dict[str, float | None]) -> dict[str, float]:
from app.modules.zootech.lab.nutrient_schema import column_values_to_mapping
merged = nutrients_full_dict(component_id)
merged.update(column_values_to_mapping(values))
return upsert_from_api_dict(component_id, merged)
@@ -0,0 +1,52 @@
"""Сериализация параметров методики норм на профиле."""
from __future__ import annotations
import json
from typing import Any
from app.modules.zootech.lab.calc.norms_resolver import NormsParams
from app.modules.zootech.lab.models import LabAnimalProfile
def load_norms_params(profile: LabAnimalProfile) -> NormsParams:
raw = profile.norms_params_json
if not raw:
return NormsParams()
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return NormsParams()
return NormsParams.from_dict(data if isinstance(data, dict) else {})
def save_norms_params(profile: LabAnimalProfile, params: NormsParams | dict[str, Any] | None) -> None:
if params is None:
profile.norms_params_json = None
return
if isinstance(params, NormsParams):
payload = {
k: v
for k, v in (
("milkFatPct", params.milk_fat_pct),
("lactationNo", params.lactation_no),
("lactationStage", params.lactation_stage),
("bodyCondition", params.body_condition),
("housingSystem", params.housing_system),
("koncOeSv", params.konc_oe_sv),
)
if v is not None
}
else:
payload = dict(params)
profile.norms_params_json = json.dumps(payload, ensure_ascii=False) if payload else None
def norms_params_api(profile: LabAnimalProfile) -> dict[str, Any]:
if not profile.norms_params_json:
return {}
try:
data = json.loads(profile.norms_params_json)
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
@@ -0,0 +1,226 @@
from __future__ import annotations
import json
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.norms_resolver import NormsResolveRequest, normalize_norms_method, resolve_norms
from app.modules.zootech.lab.services.norms_params import load_norms_params
from app.modules.zootech.lab.calc.norms import merge_norms_from_profile
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.models import LabAnimalProfile, LabProfileNorm
from app.modules.zootech.wesp_bridge_models import default_uuid
def _parse_profile_payload(data: Any) -> dict[str, Any]:
if data is None:
return {}
if isinstance(data, str):
try:
data = json.loads(data or "{}")
except (json.JSONDecodeError, TypeError):
return {}
return data if isinstance(data, dict) else {}
def load_norms_dict(profile_id: str | None) -> dict[str, dict[str, float | None]]:
if not profile_id:
return {}
rows = (
LabProfileNorm.query.filter_by(profile_id=profile_id)
.order_by(LabProfileNorm.indicator_key)
.all()
)
return {
row.indicator_key: {
"min": parse_num(row.min_value),
"max": parse_num(row.max_value),
}
for row in rows
}
def resolve_norms_for_profile(
profile: LabAnimalProfile,
*,
method: str | None = None,
params_override: dict | None = None,
force_dynamic: bool = False,
) -> dict[str, dict[str, float | None]]:
"""Нормы для расчёта рациона по выбранной методике."""
stored = load_norms_dict(profile.id)
norms_method = normalize_norms_method(method or profile.norms_method)
params = load_norms_params(profile)
if params_override:
from app.modules.zootech.lab.calc.norms_resolver import NormsParams
base = {
"milkFatPct": params.milk_fat_pct,
"lactationNo": params.lactation_no,
"lactationStage": params.lactation_stage,
"bodyCondition": params.body_condition,
"housingSystem": params.housing_system,
"koncOeSv": params.konc_oe_sv,
}
base.update(params_override)
params = NormsParams.from_dict(base)
resolved, _ = resolve_norms(
NormsResolveRequest(
method=norms_method,
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
force_dynamic=force_dynamic,
params=params,
)
)
return resolved
def load_norms_api(profile: LabAnimalProfile, *, resolve_dynamic: bool = True) -> dict[str, Any]:
stored = load_norms_dict(profile.id)
payload: dict[str, Any] = {}
if profile.mass_kg is not None:
payload["massKg"] = profile.mass_kg
if profile.milk_yield_kg is not None:
payload["milkYieldKg"] = profile.milk_yield_kg
if profile.external_no is not None:
payload["externalNo"] = profile.external_no
if stored:
payload["indicators"] = stored
if resolve_dynamic:
resolved, meta = resolve_norms(
NormsResolveRequest(
method=normalize_norms_method(profile.norms_method),
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
params=load_norms_params(profile),
)
)
payload["resolvedIndicators"] = resolved
payload["normsMethod"] = meta.get("normsMethod", profile.norms_method or "wesp")
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
if dynamic:
payload["dynamicNorms"] = dynamic
if meta.get("meta"):
payload["normsMeta"] = meta["meta"]
if meta.get("coverage"):
payload["coverage"] = meta["coverage"]
return payload
def clear_profile_norms(profile_id: str) -> None:
LabProfileNorm.query.filter_by(profile_id=profile_id).delete(synchronize_session=False)
def save_norms_from_payload(profile: LabAnimalProfile, data: Any) -> None:
payload = _parse_profile_payload(data)
profile.mass_kg = parse_num(payload.get("massKg", payload.get("mass_kg")))
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
profile.milk_yield_kg = parse_num(payload.get("milkYieldKg", payload.get("milk_yield_kg")))
ext = payload.get("externalNo", payload.get("external_no"))
profile.external_no = int(ext) if ext is not None and str(ext).strip() != "" else None
merged = merge_norms_from_profile(payload, profile.ration_type)
skip_keys = {"massKg", "mass_kg", "externalNo", "external_no", "indicators"}
for key, bounds in payload.items():
if key in skip_keys or not isinstance(bounds, dict):
continue
if "min" in bounds or "max" in bounds:
merged[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
indicators = payload.get("indicators")
if isinstance(indicators, dict):
for key, bounds in indicators.items():
if not isinstance(bounds, dict):
continue
merged[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
clear_profile_norms(profile.id)
for indicator_key, bounds in merged.items():
min_v = bounds.get("min")
max_v = bounds.get("max")
if min_v is None and max_v is None:
continue
db.session.add(
LabProfileNorm(
id=default_uuid(),
profile_id=profile.id,
indicator_key=indicator_key,
min_value=min_v,
max_value=max_v,
)
)
def import_norms_from_legacy_text(profile: LabAnimalProfile, raw: str | None) -> None:
save_norms_from_payload(profile, raw)
def _can_sync_racion(profile: LabAnimalProfile) -> bool:
method = normalize_norms_method(profile.norms_method)
if method not in ("racion_moscow", "racion_piter"):
return False
if not profile.mass_kg or profile.mass_kg <= 0:
return False
if not profile.milk_yield_kg or profile.milk_yield_kg <= 0:
return False
if method == "racion_piter":
from app.modules.zootech.lab.services.norms_params import load_norms_params
params = load_norms_params(profile)
if params.konc_oe_sv is None or params.konc_oe_sv <= 0:
return False
return True
def sync_racion_norms_to_profile(profile: LabAnimalProfile) -> int:
"""Вычислить RACION-нормы и upsert min в lab_profile_norm (max сохраняется)."""
if not _can_sync_racion(profile):
return 0
stored = load_norms_dict(profile.id)
resolved, _ = resolve_norms(
NormsResolveRequest(
method=normalize_norms_method(profile.norms_method),
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
params=load_norms_params(profile),
)
)
existing = {
row.indicator_key: row
for row in LabProfileNorm.query.filter_by(profile_id=profile.id).all()
}
updated = 0
for key, bounds in resolved.items():
min_v = bounds.get("min")
if min_v is None:
continue
row = existing.get(key)
if row is None:
db.session.add(
LabProfileNorm(
id=default_uuid(),
profile_id=profile.id,
indicator_key=key,
min_value=min_v,
max_value=bounds.get("max"),
)
)
updated += 1
elif row.min_value != min_v:
row.min_value = min_v
if bounds.get("max") is not None:
row.max_value = bounds.get("max")
updated += 1
return updated
@@ -0,0 +1,156 @@
"""Импорт и загрузка справочников RACION из БД."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.racion.tables import clear_tables_cache
from app.modules.zootech.lab.models import (
LabRacionNormyInfo,
LabRacionNormyMoskwa,
LabRacionNormyMoskwaMeta,
LabRacionNormyPiter,
LabRacionNormyPiterMeta,
)
from app.modules.zootech.lab.seed_paths import seed_dir
from app.modules.zootech.wesp_bridge_models import default_uuid
_SEED_DIR = seed_dir() / "racion"
@dataclass
class RacionReferenceImportStats:
moskwa_rows: int = 0
piter_rows: int = 0
info_rows: int = 0
errors: list[str] = field(default_factory=list)
def _read_seed(name: str) -> dict:
path = _SEED_DIR / name
return json.loads(path.read_text(encoding="utf-8"))
def import_racion_reference(*, replace: bool = True) -> RacionReferenceImportStats:
stats = RacionReferenceImportStats()
try:
moskwa = _read_seed("moskwa_lactir.json")
piter = _read_seed("piter_lactir.json")
info = _read_seed("normy_info.json")
except (OSError, json.JSONDecodeError) as exc:
stats.errors.append(str(exc))
return stats
if replace:
LabRacionNormyMoskwa.query.delete(synchronize_session=False)
LabRacionNormyMoskwaMeta.query.delete(synchronize_session=False)
LabRacionNormyPiter.query.delete(synchronize_session=False)
LabRacionNormyPiterMeta.query.delete(synchronize_session=False)
LabRacionNormyInfo.query.delete(synchronize_session=False)
for row in moskwa.get("rows") or []:
db.session.add(
LabRacionNormyMoskwa(
id=default_uuid(),
npitv=int(row["npitv"]),
pom=int(row.get("pom") or 1),
koef=row.get("koef"),
popr_k_json=json.dumps(row.get("popr_k") or [], ensure_ascii=False),
)
)
stats.moskwa_rows += 1
db.session.add(
LabRacionNormyMoskwaMeta(
id=default_uuid(),
meta_key="udoy_boundaries",
meta_json=json.dumps(moskwa.get("udoy_boundaries") or [], ensure_ascii=False),
)
)
for entry in piter.get("entries") or []:
db.session.add(
LabRacionNormyPiter(
id=default_uuid(),
npitv=int(entry["npitv"]),
konc=float(entry["konc"]),
udoy=float(entry["udoy"]),
normy_json=json.dumps(entry.get("normy") or [], ensure_ascii=False),
)
)
stats.piter_rows += 1
db.session.add(
LabRacionNormyPiterMeta(
id=default_uuid(),
meta_key="mass_kg_values",
meta_json=json.dumps(piter.get("mass_kg_values") or [], ensure_ascii=False),
)
)
for row in info.get("rows") or []:
db.session.add(
LabRacionNormyInfo(
id=default_uuid(),
nperem=int(row["nperem"]),
znachenie_json=json.dumps(row.get("znachenie") or [], ensure_ascii=False),
)
)
stats.info_rows += 1
db.session.commit()
clear_tables_cache()
return stats
def load_moskwa_lactir_from_db() -> dict | None:
if LabRacionNormyMoskwa.query.count() == 0:
return None
rows = []
for r in LabRacionNormyMoskwa.query.order_by(LabRacionNormyMoskwa.npitv, LabRacionNormyMoskwa.pom).all():
rows.append(
{
"npitv": r.npitv,
"pom": r.pom,
"koef": r.koef,
"popr_k": json.loads(r.popr_k_json or "[]"),
}
)
meta = LabRacionNormyMoskwaMeta.query.filter_by(meta_key="udoy_boundaries").first()
boundaries = json.loads(meta.meta_json or "[]") if meta else []
return {"udoy_boundaries": boundaries, "rows": rows}
def load_piter_lactir_from_db() -> dict | None:
if LabRacionNormyPiter.query.count() == 0:
return None
entries = []
for r in LabRacionNormyPiter.query.order_by(
LabRacionNormyPiter.npitv, LabRacionNormyPiter.konc, LabRacionNormyPiter.udoy
).all():
entries.append(
{
"npitv": r.npitv,
"konc": r.konc,
"udoy": r.udoy,
"normy": json.loads(r.normy_json or "[]"),
}
)
meta = LabRacionNormyPiterMeta.query.filter_by(meta_key="mass_kg_values").first()
masses = json.loads(meta.meta_json or "[]") if meta else [400, 450, 500, 550, 600, 650, 700, 750]
return {"mass_kg_values": masses, "entries": entries}
def load_normy_info_from_db() -> dict | None:
if LabRacionNormyInfo.query.count() == 0:
return None
rows = []
for r in LabRacionNormyInfo.query.order_by(LabRacionNormyInfo.nperem).all():
rows.append({"nperem": r.nperem, "znachenie": json.loads(r.znachenie_json or "[]")})
by_nperem = {row["nperem"]: row["znachenie"] for row in rows}
mass_kg_values = by_nperem.get(14) or by_nperem.get(13) or [400, 450, 500, 550, 600, 650, 700, 750]
return {"rows": rows, "mass_kg_values": mass_kg_values}
@@ -0,0 +1,204 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.indicators import label_to_indicator_key
from app.modules.zootech.lab.models import (
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
LabRecipeRation,
)
from app.modules.zootech.wesp_bridge_models import default_uuid
_log = logging.getLogger("app.lab.calc")
def _indicator_key(row: dict[str, Any]) -> str | None:
key = row.get("key")
if key:
return str(key)
label = row.get("label")
if label:
return label_to_indicator_key(str(label))
return None
def _log_calc_errors(recipe_id: str, errors: list[str] | None) -> None:
for message in errors or []:
text = str(message or "").strip()
if text:
_log.warning("ration calc recipe=%s: %s", recipe_id, text)
def clear_calc(recipe_id: str) -> None:
for model in (
LabRationCalcTotal,
LabRationCalcIndicator,
LabRationCompoundLine,
):
model.query.filter_by(recipe_id=recipe_id).delete(synchronize_session=False)
def _save_totals(recipe_id: str, scope: str, totals: list[dict[str, Any]] | None) -> None:
for idx, row in enumerate(totals or []):
db.session.add(
LabRationCalcTotal(
id=default_uuid(),
recipe_id=recipe_id,
scope=scope,
metric_key=str(row.get("key") or f"metric_{idx}"),
label=str(row.get("label") or ""),
value=parse_num(row.get("value")),
sort_order=idx,
)
)
def _save_indicators(
recipe_id: str,
scope: str,
indicators: list[dict[str, Any]] | None,
) -> None:
for idx, row in enumerate(indicators or []):
db.session.add(
LabRationCalcIndicator(
id=default_uuid(),
recipe_id=recipe_id,
scope=scope,
indicator_key=_indicator_key(row),
label=str(row.get("label") or ""),
unit=str(row.get("unit") or ""),
min_value=parse_num(row.get("min")),
max_value=parse_num(row.get("max")),
content=parse_num(row.get("content")),
diff=parse_num(row.get("diff")),
sort_order=idx,
)
)
def _save_compound_lines(recipe_id: str, lines: list[dict[str, Any]] | None) -> None:
for idx, row in enumerate(lines or []):
db.session.add(
LabRationCompoundLine(
id=default_uuid(),
recipe_id=recipe_id,
row_index=idx,
ingredient_name=row.get("ingredient_name"),
daily_kg=parse_num(row.get("daily_kg")),
share_pct=parse_num(row.get("share_pct")),
)
)
def save_calc_result(recipe_id: str, result: dict[str, Any], header: LabRecipeRation) -> None:
clear_calc(recipe_id)
_log_calc_errors(recipe_id, result.get("errors"))
header.calc_engine = str(result.get("engine") or "native")
calculated_at = result.get("calculated_at")
if calculated_at:
try:
header.calculated_at = datetime.fromisoformat(str(calculated_at).replace("Z", "+00:00"))
except ValueError:
header.calculated_at = datetime.utcnow()
else:
header.calculated_at = datetime.utcnow()
_save_totals(recipe_id, "ration", result.get("totals"))
_save_indicators(recipe_id, "ration", result.get("indicators"))
compound = result.get("compound")
if compound:
_save_totals(recipe_id, "compound", compound.get("totals"))
_save_indicators(recipe_id, "compound", compound.get("indicators"))
_save_compound_lines(recipe_id, compound.get("lines"))
def _load_totals(recipe_id: str, scope: str) -> list[dict[str, Any]]:
rows = (
LabRationCalcTotal.query.filter_by(recipe_id=recipe_id, scope=scope)
.order_by(LabRationCalcTotal.sort_order)
.all()
)
return [
{"key": row.metric_key, "label": row.label, "value": row.value}
for row in rows
]
def _load_indicators(recipe_id: str, scope: str) -> list[dict[str, Any]]:
rows = (
LabRationCalcIndicator.query.filter_by(recipe_id=recipe_id, scope=scope)
.order_by(LabRationCalcIndicator.sort_order)
.all()
)
return [
{
"key": row.indicator_key,
"label": row.label,
"unit": row.unit,
"min": row.min_value,
"max": row.max_value,
"content": row.content,
"diff": row.diff,
}
for row in rows
]
def load_compound_results(recipe_id: str) -> dict[str, Any]:
totals = _load_totals(recipe_id, "compound")
indicators = _load_indicators(recipe_id, "compound")
lines = (
LabRationCompoundLine.query.filter_by(recipe_id=recipe_id)
.order_by(LabRationCompoundLine.row_index)
.all()
)
if not totals and not indicators and not lines:
return {}
return {
"totals": totals,
"indicators": indicators,
"lines": [
{
"ingredient_name": row.ingredient_name,
"daily_kg": row.daily_kg,
"share_pct": row.share_pct,
}
for row in lines
],
}
def load_ration_results(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]:
if header is None or header.calculated_at is None:
return {}
totals = _load_totals(recipe_id, "ration")
indicators = _load_indicators(recipe_id, "ration")
if not totals and not indicators:
return {}
compound = load_compound_results(recipe_id)
payload: dict[str, Any] = {
"calculated_at": header.calculated_at.isoformat(),
"engine": header.calc_engine or "native",
"totals": totals,
"indicators": indicators,
}
if compound:
payload["compound"] = compound
return payload
def load_params(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]:
if header is None or not header.seed_source:
return {}
if header.seed_source == "execution":
return {"seeded_from": "execution"}
if header.seed_source == "synced_from":
return {"synced_from": "execution"}
return {"source": header.seed_source}
@@ -0,0 +1,40 @@
from __future__ import annotations
from app.modules.zootech.lab.models import LabRationLine
def find_rations_by_component(component_id: str) -> list[str]:
"""Recipe IDs с не удалёнными строками рациона, использующими component."""
if not component_id:
return []
rows = (
LabRationLine.query.filter_by(component_id=component_id, is_deleted=False)
.with_entities(LabRationLine.recipe_id)
.distinct()
.all()
)
return sorted({str(r[0]) for r in rows if r[0]})
def recalculate_rations(recipe_ids: list[str], user_id: str = "system") -> dict:
from app.modules.zootech.lab.commands.recalculate import recalculate_ration
ok: list[str] = []
failed: dict[str, str] = {}
results: dict[str, dict] = {}
for recipe_id in recipe_ids:
try:
results[recipe_id] = recalculate_ration(recipe_id, user_id)
ok.append(recipe_id)
except Exception as exc:
failed[recipe_id] = str(exc)
return {"ok": ok, "failed": failed, "results": results}
def on_component_nutrients_changed(component_id: str, user_id: str = "system") -> dict:
"""Найти и пересчитать все рационы с данным компонентом (для будущего UI)."""
recipe_ids = find_rations_by_component(component_id)
if not recipe_ids:
return {"recipeIds": [], "ok": [], "failed": {}}
report = recalculate_rations(recipe_ids, user_id)
return {"recipeIds": recipe_ids, **report}

Some files were not shown because too many files have changed in this diff Show More