788 lines
28 KiB
Python
788 lines
28 KiB
Python
"""Импорт лабораторных анализов 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
|