244 lines
7.5 KiB
Python
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")
|