Загрузить файлы в «/»

This commit is contained in:
m.milnikov 2026-06-08 11:11:45 +00:00
parent 4fedd08e8f
commit 386d5e03ea
5 changed files with 7252 additions and 126 deletions

View File

@ -0,0 +1,801 @@
{
"version": 1,
"source_xlsx": "Запрос для ИИ Общий(3).xlsx",
"by_shipping_type": {
"Автомобильная перевозка (LTL)": {
"sheet": "Авто LTL интерфейс",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "19",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество грузовых мест и вес, объем"
},
"right": {
"num": "7",
"label": "Габариты грузовых мест ДхШхВ"
}
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Штабелирвоание с другими отправками"
},
"right": {
"num": "11",
"label": "Штабелирвоание между собой"
}
},
{
"type": "pair",
"left": {
"num": "9",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "13",
"label": "MSDS"
},
"right": {
"num": "14",
"label": "DGM"
}
},
{
"type": "pair",
"left": {
"num": "15",
"label": "Название бренда"
},
"right": {
"num": "16",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "17",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "18",
"label": "Требуется ли замена документов"
}
}
]
},
"Автомобильная перевозка (FTL)": {
"sheet": "Авто FTL интерфейс",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "19",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество и типоразмер машин"
},
"right": {
"num": "7",
"label": "Количество грузовых мест и вес, объем"
}
},
{
"type": "left_12",
"num": "8",
"label": "Габариты грузовых мест ДхШхВ"
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "11",
"label": "MSDS"
},
"right": {
"num": "13",
"label": "DGM"
}
},
{
"type": "pair",
"left": {
"num": "14",
"label": "Название бренда"
},
"right": {
"num": "15",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "16",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "17",
"label": "Требуется ли замена документов"
}
}
]
},
"Морская перевозка (LCL)": {
"sheet": "Море, море+жд, жд LCL интерфейc",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "19",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество и типоразмер машин"
},
"right": {
"num": "7",
"label": "Количество грузовых мест и вес, объем"
}
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Штабелирвоание с другими отправками"
},
"right": {
"num": "11",
"label": "Штабелирвоание между собой"
}
},
{
"type": "pair",
"left": {
"num": "9",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "13",
"label": "MSDS"
},
"right": {
"num": "14",
"label": "DGM"
}
},
{
"type": "pair",
"left": {
"num": "15",
"label": "Название бренда"
},
"right": {
"num": "16",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "17",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "18",
"label": "Требуется ли замена документов"
}
}
]
},
"Железнодорожная перевозка (LCL)": {
"sheet": "Море, море+жд, жд LCL интерфейc",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "19",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество и типоразмер машин"
},
"right": {
"num": "7",
"label": "Количество грузовых мест и вес, объем"
}
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Штабелирвоание с другими отправками"
},
"right": {
"num": "11",
"label": "Штабелирвоание между собой"
}
},
{
"type": "pair",
"left": {
"num": "9",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "13",
"label": "MSDS"
},
"right": {
"num": "14",
"label": "DGM"
}
},
{
"type": "pair",
"left": {
"num": "15",
"label": "Название бренда"
},
"right": {
"num": "16",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "17",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "18",
"label": "Требуется ли замена документов"
}
}
]
},
"Мультимодальная перевозка море + ж/д (LCL)": {
"sheet": "Море, море+жд, жд LCL интерфейc",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "19",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество и типоразмер машин"
},
"right": {
"num": "7",
"label": "Количество грузовых мест и вес, объем"
}
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Штабелирвоание с другими отправками"
},
"right": {
"num": "11",
"label": "Штабелирвоание между собой"
}
},
{
"type": "pair",
"left": {
"num": "9",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "13",
"label": "MSDS"
},
"right": {
"num": "14",
"label": "DGM"
}
},
{
"type": "pair",
"left": {
"num": "15",
"label": "Название бренда"
},
"right": {
"num": "16",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "17",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "18",
"label": "Требуется ли замена документов"
}
}
]
},
"Морская перевозка (FCL)": {
"sheet": "Море, море+жд, жд FCL интерфейc",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "18",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество и типоразмер контейнеров"
},
"right": {
"num": "7",
"label": "Количество грузовых мест и вес, объем"
}
},
{
"type": "left_12",
"num": "8",
"label": "Габариты грузовых мест ДхШхВ"
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "11",
"label": "MSDS"
},
"right": {
"num": "13",
"label": "Technical Description of Goods in Railway Transport"
}
},
{
"type": "pair",
"left": {
"num": "14",
"label": "Название бренда"
},
"right": {
"num": "15",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "16",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "17",
"label": "Требуется ли замена документов"
}
},
{
"type": "pair",
"left": {
"num": "19",
"label": "Место таможенного оформления при экспорте из РФ"
},
"right": {
"num": "20",
"label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
}
},
{
"type": "left_12",
"num": "21",
"label": "Превышение габаритов дверного проема контейнеров 20DV, 40DV, 40HC"
}
]
},
"Железнодорожная перевозка (FCL)": {
"sheet": "Море, море+жд, жд FCL интерфейc",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "18",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество и типоразмер контейнеров"
},
"right": {
"num": "7",
"label": "Количество грузовых мест и вес, объем"
}
},
{
"type": "left_12",
"num": "8",
"label": "Габариты грузовых мест ДхШхВ"
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "11",
"label": "MSDS"
},
"right": {
"num": "13",
"label": "Technical Description of Goods in Railway Transport"
}
},
{
"type": "pair",
"left": {
"num": "14",
"label": "Название бренда"
},
"right": {
"num": "15",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "16",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "17",
"label": "Требуется ли замена документов"
}
},
{
"type": "pair",
"left": {
"num": "18",
"label": "Место таможенного оформления при экспорте из РФ"
},
"right": {
"num": "19",
"label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
}
},
{
"type": "left_12",
"num": "20",
"label": "Превышение габаритов дверного проема контейнеров 20DV, 40DV, 40HC"
}
]
},
"Мультимодальная перевозка море + ж/д (FCL)": {
"sheet": "Море, море+жд, жд FCL интерфейc",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "19",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество и типоразмер контейнеров"
},
"right": {
"num": "7",
"label": "Количество грузовых мест и вес, объем"
}
},
{
"type": "left_12",
"num": "8",
"label": "Габариты грузовых мест ДхШхВ"
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "12",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "11",
"label": "MSDS"
},
"right": {
"num": "13",
"label": "Technical Description of Goods in Railway Transport"
}
},
{
"type": "pair",
"left": {
"num": "14",
"label": "Название бренда"
},
"right": {
"num": "15",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "16",
"label": "Требуется ли дополнительный сервис - фитоконтроль, иное"
},
"right": {
"num": "17",
"label": "Требуется ли замена документов"
}
},
{
"type": "pair",
"left": {
"num": "18",
"label": "Место таможенного оформления при экспорте из РФ"
},
"right": {
"num": "19",
"label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
}
},
{
"type": "left_12",
"num": "20",
"label": "Превышение габаритов дверного проема контейнеров 20DV, 40DV, 40HC"
}
]
},
"Авиаперевозка": {
"sheet": "Авиа Интерфейс",
"blocks": [
{
"type": "pair",
"left": {
"num": "4",
"label": "Страна, город, адрес забора"
},
"right": {
"num": "21",
"label": "Страна, город, точный адрес доставки, Место ТО в РФ"
}
},
{
"type": "pair",
"left": {
"num": "6",
"label": "Количество грузовых мест и вес, объем"
},
"right": {
"num": "7",
"label": "Габариты грузовых мест ДхШхВ"
}
},
{
"type": "pair",
"left": {
"num": "10",
"label": "Штабелирвоание с другими отправками"
},
"right": {
"num": "11",
"label": "Штабелирвоание между собой"
}
},
{
"type": "pair",
"left": {
"num": "9",
"label": "Наличие батарейк, газов, жидкостей, аэрозолей"
},
"right": {
"num": "13",
"label": "Батарейки в составе груза / отдельно"
}
},
{
"type": "pair",
"left": {
"num": "12",
"label": "MSDS"
},
"right": {
"num": "14",
"label": "DGM"
}
},
{
"type": "pair",
"left": {
"num": "15",
"label": "Название бренда"
},
"right": {
"num": "16",
"label": "Авторизационное письмо/разрешение на вывоз этих брендов из Китая"
}
},
{
"type": "pair",
"left": {
"num": "17",
"label": "Перелет через третью страну со сменой авинаклданых"
},
"right": {
"num": "18",
"label": "Наличие экспортной лицезии у отправителя"
}
},
{
"type": "pair",
"left": {
"num": "19",
"label": "Дополнительный сервис"
},
"right": {
"num": "20",
"label": "Экспедирование в аэропорту назначения"
}
},
{
"type": "pair",
"left": {
"num": "22",
"label": "Спец требования при доставке до адреса в РФ"
},
"right": {
"num": "23",
"label": "Таможенное оформление"
}
},
{
"type": "pair",
"left": {
"num": "24",
"label": "Место таможенного оформления при экспорте из РФ"
},
"right": {
"num": "25",
"label": "Наличие Фумигации на деревянной таре при экспорте из РФ"
}
}
]
}
}
}

815
document_processor.py Normal file
View File

@ -0,0 +1,815 @@
import io
import os
import re
import csv
import tempfile
import subprocess
import logging
from typing import List
from bs4 import BeautifulSoup
from pypdf import PdfReader
from docx import Document
import openpyxl
import pdfplumber
from PIL import Image
import pytesseract
from striprtf.striprtf import rtf_to_text
logger = logging.getLogger(__name__)
class DocumentProcessor:
# =========================================================
# HELPERS
# =========================================================
@staticmethod
def _cleanup_text(text: str) -> str:
if not text:
return ""
text = text.replace("\xa0", " ")
text = text.replace("\t", " ")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
@staticmethod
def _cell_to_text(v) -> str:
if v is None:
return ""
s = str(v).strip()
return s if s else ""
# =========================================================
# HTML
# =========================================================
@staticmethod
def normalize_email_html(html_content: str) -> str:
"""
Очищает HTML письма и нормализует таблицы
"""
soup = BeautifulSoup(
html_content,
"html.parser"
)
for tag in soup(["script", "style"]):
tag.decompose()
for table in soup.find_all("table"):
table["style"] = (
"border-collapse: collapse; width: 100%;"
)
for cell in table.find_all(["td", "th"]):
cell["style"] = (
"border:1px solid #ccc;padding:6px;"
)
return str(soup)
# =========================================================
# MAIN
# =========================================================
def extract_text(
self,
content: bytes,
filename: str
) -> str:
ext = filename.lower().split(".")[-1]
try:
# =================================================
# PDF
# =================================================
if ext == "pdf":
return self._extract_pdf(content)
# =================================================
# WORD
# =================================================
elif ext in ["docx", "docm"]:
return self._extract_docx(content)
elif ext == "doc":
return self._extract_doc(content)
elif ext == "rtf":
return self._extract_rtf(content)
# =================================================
# EXCEL
# =================================================
elif ext in ["xlsx", "xlsm", "xlsxm"]:
return self._extract_excel_openpyxl(content)
elif ext == "xls":
return self._extract_excel_xls(content)
elif ext == "csv":
return self._extract_csv(content)
# =================================================
# IMAGES / OCR
# =================================================
elif ext in [
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp"
]:
return self._extract_image(content)
elif ext in ["tif", "tiff"]:
return self._extract_tiff(content)
# =================================================
# TEXT
# =================================================
elif ext == "txt":
try:
return content.decode("utf-8")
except Exception:
return content.decode(
"cp1251",
errors="ignore"
)
# =================================================
# UNKNOWN
# =================================================
logger.warning(
f"Unsupported attachment format: {ext}"
)
return ""
except Exception:
logger.exception(
f"Attachment processing failed: {filename}"
)
return ""
# =========================================================
# PDF
# =========================================================
def _extract_pdf(
self,
content: bytes
) -> str:
try:
parts = []
with pdfplumber.open(
io.BytesIO(content)
) as pdf:
for page_num, page in enumerate(
pdf.pages,
start=1
):
parts.append(
f"=== PAGE {page_num} ==="
)
# =====================================
# TEXT
# =====================================
try:
text = page.extract_text()
if text:
parts.append(text)
except Exception as e:
logger.warning(
f"PDF text extraction failed: {e}"
)
# =====================================
# TABLES
# =====================================
try:
tables = page.extract_tables()
for table_idx, table in enumerate(
tables,
start=1
):
parts.append(
f"=== TABLE {table_idx} ==="
)
for row in table:
if not row:
continue
cells = []
for cell in row:
if cell is None:
continue
txt = str(cell).strip()
txt = " ".join(
txt.split()
)
if txt:
cells.append(txt)
if cells:
parts.append(
" | ".join(cells)
)
except Exception as e:
logger.warning(
f"PDF table extraction failed: {e}"
)
text = "\n".join(parts)
return self._cleanup_text(text)
except Exception as e:
logger.error(f"PDF parse failed: {e}")
return ""
# =========================================================
# DOCX / DOCM
# =========================================================
def _extract_docx(
self,
content: bytes
) -> str:
try:
doc = Document(io.BytesIO(content))
parts = []
# =============================================
# PARAGRAPHS
# =============================================
for p in doc.paragraphs:
txt = p.text.strip()
if txt:
parts.append(txt)
# =============================================
# TABLES
# =============================================
for table_idx, table in enumerate(
doc.tables
):
parts.append(
f"=== TABLE {table_idx + 1} ==="
)
for row in table.rows:
cells = []
for cell in row.cells:
cell_parts = []
# =================================
# DIRECT CELL TEXT
# =================================
txt = "\n".join(
p.text.strip()
for p in cell.paragraphs
if p.text.strip()
)
txt = " ".join(txt.split())
if txt:
cell_parts.append(txt)
# =================================
# NESTED TABLES
# =================================
for nested_table in cell.tables:
for nested_row in nested_table.rows:
nested_vals = []
for nested_cell in nested_row.cells:
nested_txt = "\n".join(
p.text.strip()
for p in nested_cell.paragraphs
if p.text.strip()
)
nested_txt = " ".join(
nested_txt.split()
)
if nested_txt:
nested_vals.append(
nested_txt
)
if nested_vals:
cell_parts.append(
" || ".join(
nested_vals
)
)
# =================================
# FINAL CELL
# =================================
if cell_parts:
cells.append(
" <<<TABLE>>> ".join(
cell_parts
)
)
if cells:
parts.append(
" | ".join(cells)
)
text = "\n".join(parts)
return self._cleanup_text(text)
except Exception as e:
logger.error(f"DOCX parse failed: {e}")
return ""
# =========================================================
# DOC
# =========================================================
def _extract_doc(
self,
content: bytes
) -> str:
try:
with tempfile.TemporaryDirectory() as tmpdir:
input_path = os.path.join(
tmpdir,
"temp.doc"
)
with open(input_path, "wb") as f:
f.write(content)
subprocess.run(
[
"libreoffice",
"--headless",
"--convert-to",
"docx",
input_path,
"--outdir",
tmpdir,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
converted_path = os.path.join(
tmpdir,
"temp.docx"
)
if not os.path.exists(
converted_path
):
logger.error(
"DOC conversion failed"
)
return ""
with open(
converted_path,
"rb"
) as f:
converted_content = f.read()
return self._extract_docx(
converted_content
)
except Exception as e:
logger.error(f"DOC parse failed: {e}")
return ""
# =========================================================
# RTF
# =========================================================
def _extract_rtf(
self,
content: bytes
) -> str:
try:
try:
text = content.decode("utf-8")
except Exception:
text = content.decode(
"cp1251",
errors="ignore"
)
text = rtf_to_text(text)
return self._cleanup_text(text)
except Exception as e:
logger.error(f"RTF parse failed: {e}")
return ""
# =========================================================
# XLSX
# =========================================================
def _extract_excel_openpyxl(
self,
content: bytes
) -> str:
try:
wb = openpyxl.load_workbook(
io.BytesIO(content),
data_only=True
)
text = []
for sheet in wb.worksheets:
text.append(
f"=== SHEET: {sheet.title} ==="
)
# =====================================
# MERGED CELLS
# =====================================
merged_ranges = list(
sheet.merged_cells.ranges
)
if merged_ranges:
text.append(
"=== MERGED CELLS ==="
)
for r in merged_ranges:
text.append(str(r))
# =====================================
# ROWS
# =====================================
text.append("[ROWS]")
for row in sheet.iter_rows(
values_only=True
):
vals = [
self._cell_to_text(c)
for c in row
]
vals = [x for x in vals if x]
if vals:
text.append(
" | ".join(vals)
)
# =====================================
# COLUMNS
# =====================================
text.append("[COLUMNS]")
max_col = sheet.max_column or 0
max_row = sheet.max_row or 0
for c in range(1, max_col + 1):
vals = []
for r in range(1, max_row + 1):
v = self._cell_to_text(
sheet.cell(r, c).value
)
if v:
vals.append(v)
if vals:
col_letter = (
openpyxl.utils
.get_column_letter(c)
)
text.append(
f"COL {col_letter}: "
+ " || ".join(vals)
)
return self._cleanup_text(
"\n".join(text)
)
except Exception as e:
logger.error(
f"XLSX parse failed: {e}"
)
return ""
# =========================================================
# XLS
# =========================================================
def _extract_excel_xls(
self,
content: bytes
) -> str:
try:
import xlrd
workbook = xlrd.open_workbook(
file_contents=content
)
text = []
for sheet in workbook.sheets():
text.append(
f"=== SHEET: {sheet.name} ==="
)
for row_idx in range(
sheet.nrows
):
row = sheet.row_values(
row_idx
)
vals = [
self._cell_to_text(c)
for c in row
]
vals = [x for x in vals if x]
if vals:
text.append(
" | ".join(vals)
)
return self._cleanup_text(
"\n".join(text)
)
except Exception as e:
logger.error(f"XLS parse failed: {e}")
return ""
# =========================================================
# CSV
# =========================================================
def _extract_csv(
self,
content: bytes
) -> str:
try:
try:
text = content.decode("utf-8")
except Exception:
text = content.decode(
"cp1251",
errors="ignore"
)
reader = csv.reader(
io.StringIO(text)
)
rows = []
for row in reader:
vals = [
str(x).strip()
for x in row
if str(x).strip()
]
if vals:
rows.append(
" | ".join(vals)
)
return self._cleanup_text(
"\n".join(rows)
)
except Exception as e:
logger.error(f"CSV parse failed: {e}")
return ""
# =========================================================
# OCR
# =========================================================
def _ocr_image(
self,
image: Image.Image
) -> str:
try:
text = pytesseract.image_to_string(
image,
lang="rus+eng",
config="--psm 6"
)
return self._cleanup_text(text)
except Exception as e:
logger.error(f"OCR failed: {e}")
return ""
# =========================================================
# IMAGE
# =========================================================
def _extract_image(
self,
content: bytes
) -> str:
try:
image = Image.open(
io.BytesIO(content)
)
return self._ocr_image(image)
except Exception as e:
logger.error(
f"Image parse failed: {e}"
)
return ""
# =========================================================
# TIFF
# =========================================================
def _extract_tiff(
self,
content: bytes
) -> str:
try:
image = Image.open(
io.BytesIO(content)
)
texts = []
for frame in range(
getattr(image, "n_frames", 1)
):
image.seek(frame)
texts.append(
self._ocr_image(image)
)
return self._cleanup_text(
"\n".join(texts)
)
except Exception as e:
logger.error(
f"TIFF parse failed: {e}"
)
return ""

View File

@ -1,126 +1,138 @@
""" """
Однократная/повторная генерация criteria_interface_layout.json из Excel Однократная/повторная генерация criteria_interface_layout.json из Excel
«Запрос для ИИ Общий(2).xlsx» (листы с «интерфейс» в названии). «Запрос для ИИ Общий(2).xlsx» (листы с «интерфейс» в названии).
Запуск (путь к xlsx можно передать аргументом): Запуск (путь к xlsx можно передать аргументом):
py extract_criteria_interface_layout.py "C:\\Users\\...\\Запрос для ИИ Общий(2).xlsx" py extract_criteria_interface_layout.py "C:\\Users\\...\\Запрос для ИИ Общий(2).xlsx"
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import sys import sys
import openpyxl import openpyxl
SHEET_TO_SHIPPING_TYPES: dict[str, list[str]] = { SHEET_TO_SHIPPING_TYPES: dict[str, list[str]] = {
"Авто LTL интерфейс": ["Автомобильная перевозка (LTL)"], "Авто LTL интерфейс": ["Автомобильная перевозка (LTL)"],
"Авто FTL интерфейс": ["Автомобильная перевозка (FTL)"], "Авто FTL интерфейс": ["Автомобильная перевозка (FTL)"],
"Море, море+жд, жд LCL интерфейc": [ "Море, море+жд, жд LCL интерфейc": [
"Морская перевозка (LCL)", "Морская перевозка (LCL)",
"Железнодорожная перевозка (LCL)", "Железнодорожная перевозка (LCL)",
"Мультимодальная перевозка море + ж/д (LCL)", "Мультимодальная перевозка море + ж/д (LCL)",
], ],
"Море, море+жд, жд FCL интерфейc": [ "Море, море+жд, жд FCL интерфейc": [
"Морская перевозка (FCL)", "Морская перевозка (FCL)",
"Железнодорожная перевозка (FCL)", "Железнодорожная перевозка (FCL)",
"Мультимодальная перевозка море + ж/д (FCL)", "Мультимодальная перевозка море + ж/д (FCL)",
], ],
"Авиа Интерфейс": ["Авиаперевозка"], "Авиа Интерфейс": ["Авиаперевозка"],
} }
def _row_cells(ws, r: int, max_c: int = 12) -> list[tuple[int, str]]: def _row_cells(ws, r: int, max_c: int = 12) -> list[tuple[int, str]]:
out: list[tuple[int, str]] = [] out: list[tuple[int, str]] = []
for c in range(1, max_c + 1): for c in range(1, max_c + 1):
v = ws.cell(r, c).value v = ws.cell(r, c).value
if v is not None and str(v).strip(): if v is not None and str(v).strip():
out.append((c, str(v).strip())) out.append((c, str(v).strip()))
return out return out
def parse_interface_sheet(ws) -> list[dict]: def _is_num_like(s: str) -> bool:
"""Разбор листа интерфейса в последовательность визуальных блоков.""" if not isinstance(s, str):
blocks: list[dict] = [] return False
r = 1 t = s.strip()
max_r = ws.max_row or 1 if not t:
while r <= max_r: return False
cells = _row_cells(ws, r) # 1, 1., 1.0
if not cells: return t.isdigit() or t.replace(".", "", 1).isdigit()
r += 1
continue
def parse_interface_sheet(ws) -> list[dict]:
# Заголовок: 7 ячеек в колонках 1,2,3,4,5,7,9 и все значения — «номера пунктов» """Разбор листа интерфейса в последовательность визуальных блоков."""
if ( blocks: list[dict] = []
len(cells) == 7 r = 1
and {c[0] for c in cells} == {1, 2, 3, 4, 5, 7, 9} max_r = ws.max_row or 1
and all(str(c[1]).strip().isdigit() or c[1].strip().replace(".", "").isdigit() for c in cells) while r <= max_r:
): cells = _row_cells(ws, r)
nums = [c[1] for c in sorted(cells, key=lambda x: (x[0] not in (1, 2, 3, 4, 5, 7, 9), x[0]))] if not cells:
# порядок как в Excel слева направо по колонкам r += 1
nums = [c[1] for c in sorted(cells, key=lambda x: x[0])] continue
r2 = r + 1
cells2 = _row_cells(ws, r2) # Заголовок верхнего ряда:
if len(cells2) == 7 and {c[0] for c in cells2} == {1, 2, 3, 4, 5, 7, 9}: # в новых версиях файла набор колонок может отличаться, поэтому
labels = [c[1] for c in sorted(cells2, key=lambda x: x[0])] # распознаём динамически: строка с номерами + следующая строка с подписями
blocks.append({"type": "header", "nums": nums, "labels": labels}) # в тех же колонках.
r += 2 cols = [c[0] for c in cells]
continue vals = [c[1] for c in cells]
if len(cells) >= 4 and all(_is_num_like(v) for v in vals):
# Только правая пара (6 = номер, 7 = текст) — например «20 Место таможни» r2 = r + 1
if len(cells) == 2 and cells[0][0] == 6 and cells[1][0] == 7: cells2 = _row_cells(ws, r2)
blocks.append({"type": "right_67", "num": cells[0][1], "label": cells[1][1]}) map2 = {c: v for c, v in cells2}
r += 1 if all(c in map2 and str(map2[c]).strip() for c in cols):
continue labels = [str(map2[c]).strip() for c in cols]
# Если в следующей строке снова только числа — это не шапка.
# Две пары: 1-2 и 6-7 if not all(_is_num_like(v) for v in labels):
if len(cells) == 4 and {cells[i][0] for i in range(4)} == {1, 2, 6, 7}: nums = [str(v).strip() for v in vals]
blocks.append( blocks.append({"type": "header", "nums": nums, "labels": labels})
{ r += 2
"type": "pair", continue
"left": {"num": cells[0][1], "label": cells[1][1]},
"right": {"num": cells[2][1], "label": cells[3][1]}, # Только правая пара (6 = номер, 7 = текст) — например «20 Место таможни»
} if len(cells) == 2 and cells[0][0] == 6 and cells[1][0] == 7:
) blocks.append({"type": "right_67", "num": cells[0][1], "label": cells[1][1]})
r += 1 r += 1
continue continue
# Только левая пара 1-2 (один блок на строку) # Две пары: 1-2 и 6-7
if len(cells) == 2 and cells[0][0] == 1 and cells[1][0] == 2: if len(cells) == 4 and {cells[i][0] for i in range(4)} == {1, 2, 6, 7}:
blocks.append({"type": "left_12", "num": cells[0][1], "label": cells[1][1]}) blocks.append(
r += 1 {
continue "type": "pair",
"left": {"num": cells[0][1], "label": cells[1][1]},
r += 1 "right": {"num": cells[2][1], "label": cells[3][1]},
return blocks }
)
r += 1
def main() -> None: continue
xlsx = (
sys.argv[1] # Только левая пара 1-2 (один блок на строку)
if len(sys.argv) > 1 if len(cells) == 2 and cells[0][0] == 1 and cells[1][0] == 2:
else os.path.join(os.path.dirname(__file__), "Запрос для ИИ Общий(3).xlsx") blocks.append({"type": "left_12", "num": cells[0][1], "label": cells[1][1]})
) r += 1
if not os.path.isfile(xlsx): continue
print("Файл не найден:", xlsx)
sys.exit(1) r += 1
return blocks
wb = openpyxl.load_workbook(xlsx, read_only=False, data_only=True)
out: dict = {"version": 1, "source_xlsx": os.path.basename(xlsx), "by_shipping_type": {}}
def main() -> None:
for sheet_name, type_names in SHEET_TO_SHIPPING_TYPES.items(): xlsx = (
if sheet_name not in wb.sheetnames: sys.argv[1]
print("Пропуск: лист не найден:", sheet_name) if len(sys.argv) > 1
continue else os.path.join(os.path.dirname(__file__), "Запрос для ИИ Общий(3).xlsx")
blocks = parse_interface_sheet(wb[sheet_name]) )
for tn in type_names: if not os.path.isfile(xlsx):
out["by_shipping_type"][tn] = {"sheet": sheet_name, "blocks": blocks} print("Файл не найден:", xlsx)
sys.exit(1)
out_path = os.path.join(os.path.dirname(__file__), "criteria_interface_layout.json")
with open(out_path, "w", encoding="utf-8") as f: wb = openpyxl.load_workbook(xlsx, read_only=False, data_only=True)
json.dump(out, f, ensure_ascii=False, indent=2) out: dict = {"version": 1, "source_xlsx": os.path.basename(xlsx), "by_shipping_type": {}}
print("Записано:", out_path, "типов:", len(out["by_shipping_type"]))
for sheet_name, type_names in SHEET_TO_SHIPPING_TYPES.items():
if sheet_name not in wb.sheetnames:
if __name__ == "__main__": print("Пропуск: лист не найден:", sheet_name)
main() continue
blocks = parse_interface_sheet(wb[sheet_name])
for tn in type_names:
out["by_shipping_type"][tn] = {"sheet": sheet_name, "blocks": blocks}
out_path = os.path.join(os.path.dirname(__file__), "criteria_interface_layout.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2)
print("Записано:", out_path, "типов:", len(out["by_shipping_type"]))
if __name__ == "__main__":
main()

5379
rag_engine_gemini.py Normal file

File diff suppressed because it is too large Load Diff

119
shipping_calculator.py Normal file
View File

@ -0,0 +1,119 @@
import math
import re
from typing import Dict, Optional
def calculate_shipping_cost(shipment: Dict) -> Optional[Dict]:
"""
Расчёт стоимости авиаперевозки на основе параметров груза.
Возвращает словарь с ключами:
total_usd, total_rub, chargeable_weight, actual_weight, volume_weight
или None, если не хватает данных.
"""
try:
# Проверяем наличие обязательных данных
if not all(k in shipment for k in ['total_weight_kg', 'dimensions', 'cargo_value']):
return None
# Параметры груза
actual_weight = shipment.get('total_weight_kg', 0)
if not actual_weight or actual_weight <= 0:
return None
# Расчёт объёмного веса
dimensions = shipment.get('dimensions', [])
if not dimensions:
return None
total_volume_weight = 0
for dim in dimensions:
if dim.get('length_cm') and dim.get('width_cm') and dim.get('height_cm'):
# Объёмный вес = (Д×Ш×В в метрах) × 167
length_m = dim['length_cm'] / 100
width_m = dim['width_cm'] / 100
height_m = dim['height_cm'] / 100
volume = length_m * width_m * height_m
volume_weight = volume * 167
total_volume_weight += volume_weight
# Платный вес (оплачивается по большему)
chargeable_weight = max(actual_weight, total_volume_weight)
chargeable_weight = math.ceil(chargeable_weight) # округляем вверх
# Базовый тариф в зависимости от веса (USD/кг)
if chargeable_weight < 45:
base_rate = 6.5
elif chargeable_weight < 100:
base_rate = 5.5
elif chargeable_weight < 300:
base_rate = 4.5
elif chargeable_weight < 500:
base_rate = 3.8
elif chargeable_weight < 1000:
base_rate = 3.2
else:
base_rate = 2.8
# Топливный сбор и сбор за безопасность
fuel_surcharge_rate = 0.9 # USD/кг
min_fuel_surcharge = 90 # USD минимум
# Терминальные сборы
terminal_export_rate = 0.22 # USD/кг (~20 руб/кг)
terminal_export_fixed = 25 # USD фиксированных сборов (~2300 руб)
terminal_import_rate = 0.35 # EUR/кг ≈ 0.38 USD/кг по курсу 1.1
# Таможенное оформление
customs_export = 170 # USD (~15500 руб)
customs_import = 220 # EUR ≈ 242 USD
# Надбавки за особые условия
special_rate_multiplier = 1.0
# Терморежим (+15…+25 °C)
if shipment.get('special_transport_requirements') and 'термо' in str(shipment.get('special_transport_requirements', '')).lower():
special_rate_multiplier *= 1.3 # +30%
# Страхование
cargo_value_usd = 0
cargo_value_str = shipment.get('cargo_value', '0')
# Извлекаем числовое значение из строки (например, "18 500 EUR" -> 18500)
value_match = re.search(r'[\d\s]+', cargo_value_str.replace(' ', ''))
if value_match:
cargo_value_usd = float(value_match.group().replace(' ', ''))
# Если указано в EUR, конвертируем
if 'EUR' in cargo_value_str or '' in cargo_value_str:
cargo_value_usd *= 1.1 # приблизительный курс EUR/USD
insurance_cost = cargo_value_usd * 0.01 # 1% от стоимости груза
# Расчёт компонентов
base_freight = chargeable_weight * base_rate * special_rate_multiplier
fuel_surcharge = max(chargeable_weight * fuel_surcharge_rate, min_fuel_surcharge)
terminal_export = chargeable_weight * terminal_export_rate + terminal_export_fixed
terminal_import = chargeable_weight * 0.38 # конвертация EUR->USD
customs_total = customs_export + customs_import
# Дополнительные услуги
additional_services = shipment.get('additional_services', [])
additional_cost = 0
if isinstance(additional_services, list):
if 'уведомление о прибытии' in str(additional_services).lower():
additional_cost += 25 # USD
# Итог
total_usd = (base_freight + fuel_surcharge + terminal_export +
terminal_import + customs_total + additional_cost + insurance_cost)
# Конвертация в рубли для удобства (курс USD/RUB ~90)
total_rub = total_usd * 90
return {
"total_usd": round(total_usd, 2),
"total_rub": round(total_rub, 2),
"chargeable_weight": chargeable_weight,
"actual_weight": actual_weight,
"volume_weight": round(total_volume_weight, 2)
}
except Exception as e:
# Логирование ошибки, если нужно
return None