Интегрирован 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
+1
View File
@@ -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.lab.etl.agrostar_tabular import (
parse_tabular_triplet_lines,
rows_to_label_values,
sample_from_agro_fields,
tabular_rows_to_agro_fields,
)
from app.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)
+129
View File
@@ -0,0 +1,129 @@
"""AgroStar табличные форматы (PDF, xlsx) → те же AgrostarSample, что и XML."""
from __future__ import annotations
import re
from typing import Any
from app.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.lab.etl.agrostar_tabular import map_tabular_label, sample_from_agro_fields, tabular_rows_to_agro_fields
from app.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
+787
View File
@@ -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 import db
from app.lab.calc.feed_groups import classify_feed_group
from app.lab.services.component_nutrients import save_component_nutrients
from app.models.component 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
+243
View File
@@ -0,0 +1,243 @@
"""Единый роутер импорта lab: XML / PDF / xlsx → preview API."""
from __future__ import annotations
import re
from typing import Any
from app.lab.etl.agrostar_pdf_import import looks_like_agrostar_pdf, parse_agrostar_pdf
from app.lab.etl.agrostar_xlsx_import import parse_agrostar_xlsx
from app.lab.etl.agrostar_xml_import import AgrostarParseResult, enrich_parse_with_matches, parse_agrostar_xml
from app.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")
+215
View File
@@ -0,0 +1,215 @@
"""ПЛИНОР PDF (сос / зоо) → состав рациона или показатели."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from app.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
+265
View File
@@ -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 import db
from app.lab.models import LabAnimalProfile
from app.models import Component, Recipe, WESP_SUPPRESS_SYNC_ENQUEUE
from app.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.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