Интегрирован 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 @@
"""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