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

244 lines
7.5 KiB
Python

"""Единый роутер импорта lab: XML / PDF / xlsx → preview API."""
from __future__ import annotations
import re
from typing import Any
from app.lab.etl.agrostar_pdf_import import looks_like_agrostar_pdf, parse_agrostar_pdf
from app.lab.etl.agrostar_xlsx_import import parse_agrostar_xlsx
from app.lab.etl.agrostar_xml_import import AgrostarParseResult, enrich_parse_with_matches, parse_agrostar_xml
from app.lab.etl.plinor_pdf_import import (
enrich_plinor_composition,
is_plinor_sos,
is_plinor_zoo,
parse_plinor_sos,
parse_plinor_zoo,
)
SOURCE_LABELS: dict[str, str] = {
"agrostar_xml": "AgroStar XML",
"agrostar_pdf": "AgroStar PDF",
"agrostar_xlsx": "AgroStar Excel",
"plinor_sos": "ПЛИНОР — состав рациона",
"plinor_zoo": "ПЛИНОР — показатели рациона",
}
_PDF_MAGIC = b"%PDF"
_XLSX_MAGIC = b"PK\x03\x04"
_XML_MARKERS = (b"Standard_XML_Data", b"<?xml", b"<Standard_XML_Data")
def _filename_hint_plinor_zoo(name: str) -> bool:
n = (name or "").lower()
return "зоо" in n or re.search(r"(^|[^a-z])zoo([^a-z]|$)", n) is not None
def _filename_hint_plinor_sos(name: str) -> bool:
n = (name or "").lower()
return "сос" in n or re.search(r"(^|[^a-z])sos([^a-z]|$)", n) is not None
def _looks_like_xml_agrostar(data: bytes) -> bool:
head = data[:16384]
return any(marker in head for marker in _XML_MARKERS)
def _looks_like_xlsx(data: bytes) -> bool:
return data.startswith(_XLSX_MAGIC)
def _looks_like_pdf(data: bytes) -> bool:
return data.startswith(_PDF_MAGIC)
def _pdf_import_available() -> bool:
try:
import fitz # noqa: F401
except ImportError:
return False
return True
def detect_source_format(filename: str, data: bytes) -> str:
"""Определение формата по содержимому и имени (расширение — запасной вариант)."""
name = (filename or "").lower()
if _looks_like_pdf(data):
return _detect_pdf_format(data, name)
if _looks_like_xlsx(data):
return "agrostar_xlsx"
if _looks_like_xml_agrostar(data):
return "agrostar_xml"
if name.endswith(".xml"):
return "agrostar_xml"
if name.endswith(".xlsx") or name.endswith(".xls"):
return "agrostar_xlsx"
if name.endswith(".pdf"):
return _detect_pdf_format(data, name)
return "unknown"
def _detect_pdf_format(data: bytes, name: str) -> str:
head = data[:8192]
if b"Standard_XML_Data" in head:
return "agrostar_xml"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
pdf_text = ""
if _pdf_import_available():
try:
pdf_text = _peek_pdf_text(data)
except Exception: # noqa: BLE001
pdf_text = ""
if pdf_text:
if is_plinor_zoo(pdf_text):
return "plinor_zoo"
if is_plinor_sos(pdf_text):
return "plinor_sos"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
if _filename_hint_plinor_zoo(name):
return "plinor_zoo"
if _filename_hint_plinor_sos(name):
return "plinor_sos"
return "unknown_pdf"
def _peek_pdf_text(data: bytes) -> str:
try:
import fitz
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
text = doc[0].get_text() if doc.page_count else ""
doc.close()
return text
def parse_lab_import(filename: str, data: bytes) -> dict[str, Any]:
"""Разбор файла → единый JSON для /lab комбайна."""
if not data:
return {"error": True, "message": "Пустой файл", "kind": "error"}
fmt = detect_source_format(filename, data)
source_label = SOURCE_LABELS.get(fmt, "Неизвестный формат")
if fmt == "agrostar_xml":
text = _decode_text(data)
parsed = parse_agrostar_xml(text)
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "agrostar_pdf":
parsed = parse_agrostar_pdf(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "agrostar_xlsx":
parsed = parse_agrostar_xlsx(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "plinor_sos":
parsed = parse_plinor_sos(data)
if parsed.errors and not parsed.feed_lines:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = enrich_plinor_composition(parsed.to_api_dict())
body["kind"] = "ration_composition"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "plinor_zoo":
parsed = parse_plinor_zoo(data)
if parsed.errors and not parsed.indicators:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = parsed.to_api_dict()
body["kind"] = "ration_indicators"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "unknown_pdf" and _looks_like_pdf(data) and not _pdf_import_available():
return {
"error": True,
"message": "Для PDF нужен pymupdf — выполните: pip install pymupdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
return {
"error": True,
"message": "Формат не распознан. Поддерживаются: AgroStar xml/pdf/xlsx, ПЛИНОР сос/зоо pdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
def _lab_samples_response(
parsed: AgrostarParseResult,
fmt: str,
source_label: str,
filename: str,
) -> dict[str, Any]:
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
def _decode_text(data: bytes) -> str:
for encoding in ("utf-8", "utf-8-sig", "cp1251"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")