@@ -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
|
||||
Reference in New Issue
Block a user