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