Files
site/WESP_REL/app/lab/etl/agrostar_xml_import.py
T
2026-07-17 12:57:18 +03:00

788 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Импорт лабораторных анализов 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