diff --git a/container_iso_reference.json b/container_iso_reference.json new file mode 100644 index 0000000..6a98bdd --- /dev/null +++ b/container_iso_reference.json @@ -0,0 +1,88 @@ +{ + "meta": { + "source": "Таблица основных типов ISO-контейнеров (внешние/внутренние размеры, дверной проём, масса, объём)", + "usage": "Сверка п.21 FCL (проходимость груза в дверной проём 20DV, 40DV, 40HC) и оценка payload/объёма." + }, + "types": { + "20DV": { + "label": "20' Standard (Dry Cube) — соответствует 20DV в критериях", + "external_mm": { "length": 6058, "width": 2438, "height": 2591 }, + "internal_mm": { "length": 5905, "width": 2350, "height": 2381 }, + "door_opening_mm": { "width": 2336, "height": 2291 }, + "max_gross_kg": 30480, + "tare_kg": 2370, + "payload_kg": 28110, + "volume_m3": 33.2 + }, + "20HC": { + "label": "20' High Cube", + "external_mm": { "length": 6058, "width": 2438, "height": 2896 }, + "internal_mm": { "length": 5905, "width": 2350, "height": 2693 }, + "door_opening_mm": { "width": 2340, "height": 2597 }, + "max_gross_kg": 30480, + "tare_kg": 2340, + "payload_kg": 28140, + "volume_m3": 37.5 + }, + "20HC_PW": { + "label": "20' High Cube Pallet Wide", + "external_mm": { "length": 6058, "width": 2484, "height": 2896 }, + "internal_mm": { "length": 5898, "width": 2426, "height": 2698 }, + "door_opening_mm": { "width": 2374, "height": 2585 }, + "max_gross_kg": 30480, + "tare_kg": 2580, + "payload_kg": 27900, + "volume_m3": 38.6 + }, + "40DV": { + "label": "40' Standard (Dry Van) — соответствует 40DV в критериях", + "external_mm": { "length": 12192, "width": 2438, "height": 2591 }, + "internal_mm": { "length": 12039, "width": 2350, "height": 2372 }, + "door_opening_mm": { "width": 2336, "height": 2291 }, + "max_gross_kg": 30480, + "tare_kg": 4000, + "payload_kg": 26480, + "volume_m3": 67.8 + }, + "40HC": { + "label": "40' High Cube — соответствует 40HC в критериях", + "external_mm": { "length": 12192, "width": 2438, "height": 2896 }, + "internal_mm": { "length": 12039, "width": 2350, "height": 2693 }, + "door_opening_mm": { "width": 2340, "height": 2597 }, + "max_gross_kg": 32500, + "tare_kg": 4200, + "payload_kg": 28300, + "volume_m3": 76.5 + }, + "40HC_PW": { + "label": "40' High Cube Pallet Wide", + "external_mm": { "length": 12192, "width": 2500, "height": 2896 }, + "internal_mm": { "length": 12039, "width": 2432, "height": 2693 }, + "door_opening_mm": { "width": 2432, "height": 2597 }, + "max_gross_kg": 35000, + "tare_kg": 4400, + "payload_kg": 30600, + "volume_m3": 79.3 + }, + "45HC_PW_A": { + "label": "45' High Cube Pallet Wide (Type A)", + "external_mm": { "length": 13716, "width": 2500, "height": 2750 }, + "internal_mm": { "length": 13513, "width": 2444, "height": 2549 }, + "door_opening_mm": { "width": 2416, "height": 2439 }, + "max_gross_kg": 34000, + "tare_kg": 4180, + "payload_kg": 29820, + "volume_m3": 85.1 + }, + "45HC_PW_B": { + "label": "45' High Cube Pallet Wide (Type B)", + "external_mm": { "length": 13716, "width": 2500, "height": 2896 }, + "internal_mm": { "length": 13513, "width": 2444, "height": 2670 }, + "door_opening_mm": { "width": 2416, "height": 2580 }, + "max_gross_kg": 34000, + "tare_kg": 5080, + "payload_kg": 28920, + "volume_m3": 89.5 + } + } +} diff --git a/container_reference.py b/container_reference.py new file mode 100644 index 0000000..bd7a45b --- /dev/null +++ b/container_reference.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""Справочник ISO-контейнеров: промпт RAG и сверка п.21 FCL (дверной проём vs габариты груза).""" +from __future__ import annotations + +import json +import os +import re +from typing import Any, Dict, List, Optional, Tuple + +_DIR = os.path.dirname(os.path.abspath(__file__)) +_JSON_PATH = os.path.join(_DIR, "container_iso_reference.json") + +_iso_cache: Optional[Dict[str, Any]] = None + + +def load_iso_reference() -> Dict[str, Any]: + global _iso_cache + if _iso_cache is not None: + return _iso_cache + try: + with open(_JSON_PATH, "r", encoding="utf-8") as f: + _iso_cache = json.load(f) + except Exception: + _iso_cache = {"types": {}} + return _iso_cache + + +def iso_reference_prompt_block() -> str: + """Компактный блок для system prompt LLM (сверка п.21 и выбора контейнера).""" + data = load_iso_reference() + types = data.get("types") or {} + if not types: + return "" + lines: List[str] = [ + "СПРАВОЧНИК ISO-КОНТЕЙНЕРОВ (мм, кг, м³) — для сверки с п.21 FCL (дверной проём) и оценки объёма/полезной нагрузки. " + "Не выдумывай числа; при расчётах используй только эти значения.", + "Ключи 20DV / 40DV / 40HC соответствуют формулировкам в критериях (проём 20DV, 40DV, 40HC).", + ] + for key in ("20DV", "20HC", "20HC_PW", "40DV", "40HC", "40HC_PW", "45HC_PW_A", "45HC_PW_B"): + t = types.get(key) + if not isinstance(t, dict): + continue + door = t.get("door_opening_mm") or {} + inn = t.get("internal_mm") or {} + dw = door.get("width") + dh = door.get("height") + il = inn.get("length") + iw = inn.get("width") + ih = inn.get("height") + vol = t.get("volume_m3") + pay = t.get("payload_kg") + lines.append( + f"- {key} ({t.get('label', '')}): дверь {dw}×{dh} мм; " + f"внутри {il}×{iw}×{ih} мм; объём ~{vol} м³; payload ~{pay} кг" + ) + return "\n".join(lines) + + +def _edges_mm_from_dim(d: Dict[str, Any]) -> Optional[Tuple[float, float, float]]: + """Длина/ширина/высота груза в мм (из см в shipment).""" + if not isinstance(d, dict): + return None + + def _num(v: Any) -> Optional[float]: + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip().replace(" ", "").replace(",", ".") + try: + return float(s) + except ValueError: + return None + + l = _num(d.get("length_cm")) + w = _num(d.get("width_cm")) + h = _num(d.get("height_cm")) + if l is None or w is None or h is None: + return None + return l * 10.0, w * 10.0, h * 10.0 + + +def _box_fits_door( + edges_mm: Tuple[float, float, float], + door_w_mm: float, + door_h_mm: float, +) -> bool: + """Два наименьших ребра груза должны укладываться в ширину/высоту проёма (сортировка).""" + s0, s1, s2 = sorted(edges_mm) + dw, dh = sorted((door_w_mm, door_h_mm)) + return s0 <= dw + 0.5 and s1 <= dh + 0.5 + + +def _codes_from_container_type(ct: str) -> List[str]: + """Из строки вида «2×40HC, 1×20DC» извлекает коды из справочника.""" + if not isinstance(ct, str) or not ct.strip(): + return ["20DV", "40DV", "40HC"] + t = ct.upper() + found: List[str] = [] + + def _add(size: str, kind: Optional[str]) -> None: + k = (kind or "DV").upper() + if size == "20": + code = "20" + ("HC" if k in ("HC", "HQ") else "DV") + elif size == "40": + code = "40" + ("HC" if k in ("HC", "HQ") else "DV") + elif size == "45": + code = "45HC_PW_B" + else: + return + if code not in found: + found.append(code) + + for m in re.finditer( + r"(\d{1,3})\s*[x×х**]\s*(20|40|45)\s*(DC|DV|GP|HC|HQ|OT|FR|RF|RH)?", + t, + re.IGNORECASE, + ): + _add(m.group(2), m.group(3)) + for m in re.finditer( + r"\b(20|40|45)\s*(DC|DV|GP|HC|HQ|OT|FR|RF|RH)\b", + t, + re.IGNORECASE, + ): + _add(m.group(1), m.group(2)) + if not found: + return ["20DV", "40DV", "40HC"] + return found + + +def door_clearance_summary(shipment: Dict[str, Any]) -> str: + """ + Текст для п.21: сверка габаритов мест с дверным проёмом 20DV/40DV/40HC из справочника. + """ + data = load_iso_reference() + types = data.get("types") or {} + dims = shipment.get("dimensions") + if not isinstance(dims, list) or not dims: + return ( + "Нет габаритов грузовых мест — проверка проходимости по справочнику дверных проёмов " + "(20DV 2336×2291 мм, 40DV 2336×2291 мм, 40HC 2340×2597 мм) невозможна." + ) + + ct_raw = (shipment.get("container_type") or "").strip() + codes = _codes_from_container_type(ct_raw) + parts: List[str] = [] + for i, d in enumerate(dims, start=1): + edges = _edges_mm_from_dim(d) + if not edges: + parts.append(f"Место {i}: нет полных Д×Ш×В в см.") + continue + place_bits: List[str] = [] + for code in codes: + spec = types.get(code) + if not isinstance(spec, dict): + continue + door = spec.get("door_opening_mm") or {} + dw = float(door.get("width") or 0) + dh = float(door.get("height") or 0) + if dw <= 0 or dh <= 0: + continue + ok = _box_fits_door(edges, dw, dh) + label = spec.get("label", code) + status = "проходит" if ok else "не проходит" + place_bits.append(f"{code} ({int(dw)}×{int(dh)} мм): {status}") + if place_bits: + parts.append(f"Место {i} (рёбра {int(min(edges))}×{int(sorted(edges)[1])}×{int(max(edges))} мм): " + "; ".join(place_bits)) + else: + parts.append(f"Место {i}: нет данных дверного проёма в справочнике для кодов {', '.join(codes)}.") + + note = ( + " Оценка: два наименьших габарита сравниваются с шириной и высотой дверного проёма (ориентация коробки)." + ) + prefix = ( + f"Справочник дверных проёмов (п.21). Контейнеры в заявке: {ct_raw or 'не указаны — проверка по 20DV/40DV/40HC'}. " + ) + return prefix + " ".join(parts) + note + diff --git a/criteria_interface_layout.json b/criteria_interface_layout.json new file mode 100644 index 0000000..f0cf032 --- /dev/null +++ b/criteria_interface_layout.json @@ -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": "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" + } + ] + }, + "Железнодорожная перевозка (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" + } + ] + }, + "Мультимодальная перевозка море + ж/д (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": "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": "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": "Наличие Фумигации на деревянной таре при экспорте из РФ" + } + } + ] + } + } +} \ No newline at end of file diff --git a/document_processor(1).py b/document_processor(1).py new file mode 100644 index 0000000..d30cfae --- /dev/null +++ b/document_processor(1).py @@ -0,0 +1,154 @@ +from pypdf import PdfReader +from docx import Document +import openpyxl +from typing import Union +import io +import logging +from bs4 import BeautifulSoup +logger = logging.getLogger(__name__) + +class DocumentProcessor: + @staticmethod + def _cell_to_text(v) -> str: + if v is None: + return "" + s = str(v).strip() + return s if s else "" + + @staticmethod + def _sheet_rows_text_openpyxl(sheet) -> list[str]: + out: list[str] = [] + for row in sheet.iter_rows(values_only=True): + vals = [DocumentProcessor._cell_to_text(c) for c in row] + vals = [x for x in vals if x] + if vals: + out.append(" | ".join(vals)) + return out + + @staticmethod + def _sheet_cols_text_openpyxl(sheet) -> list[str]: + out: list[str] = [] + max_col = sheet.max_column or 0 + max_row = sheet.max_row or 0 + for c in range(1, max_col + 1): + vals: list[str] = [] + for r in range(1, max_row + 1): + v = DocumentProcessor._cell_to_text(sheet.cell(r, c).value) + if v: + vals.append(v) + if vals: + col_letter = openpyxl.utils.get_column_letter(c) + out.append(f"COL {col_letter} TOP_DOWN: " + " || ".join(vals)) + out.append(f"COL {col_letter} BOTTOM_UP: " + " || ".join(reversed(vals))) + return out + + @staticmethod + def _sheet_rows_text_xlrd(sheet) -> list[str]: + out: list[str] = [] + for row_idx in range(sheet.nrows): + row = sheet.row_values(row_idx) + vals = [DocumentProcessor._cell_to_text(c) for c in row] + vals = [x for x in vals if x] + if vals: + out.append(" | ".join(vals)) + return out + + @staticmethod + def _sheet_cols_text_xlrd(sheet) -> list[str]: + out: list[str] = [] + for c in range(sheet.ncols): + vals: list[str] = [] + for r in range(sheet.nrows): + v = DocumentProcessor._cell_to_text(sheet.cell_value(r, c)) + if v: + vals.append(v) + if vals: + out.append(f"COL {c+1} TOP_DOWN: " + " || ".join(vals)) + out.append(f"COL {c+1} BOTTOM_UP: " + " || ".join(reversed(vals))) + return out + + 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) + def extract_text(self, content: bytes, filename: str) -> str: + ext = filename.lower().split('.')[-1] + + # Изображения не обрабатываем + if ext in ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'webp']: + return "" + + if ext == 'pdf': + return self._extract_pdf(content) + elif ext in ['docx', 'doc']: + return self._extract_docx(content) + elif ext in ['xlsx', 'xls']: + return self._extract_excel(content) + elif ext == 'txt': + return content.decode('utf-8') + else: + raise ValueError(f"Unsupported format: {ext}") + def _extract_pdf(self, content: bytes) -> str: + pdf = PdfReader(io.BytesIO(content)) + return "\n".join(page.extract_text() for page in pdf.pages) + + def _extract_docx(self, content: bytes) -> str: + doc = Document(io.BytesIO(content)) + return "\n".join(paragraph.text for paragraph in doc.paragraphs) + + def _extract_excel(self, content: bytes) -> str: + try: + # сначала пробуем openpyxl (для .xlsx) + wb = openpyxl.load_workbook(io.BytesIO(content)) + text = [] + for sheet in wb.worksheets: + text.append(f"=== SHEET: {sheet.title} ===") + # Сохраняем совместимость: построчное чтение. + rows_text = self._sheet_rows_text_openpyxl(sheet) + if rows_text: + text.append("[ROWS]") + text.extend(rows_text) + # Новый режим: чтение по колонкам (общие данные часто внизу столбца). + cols_text = self._sheet_cols_text_openpyxl(sheet) + if cols_text: + text.append("[COLUMNS]") + text.extend(cols_text) + return "\n".join(text) + except Exception as e: + # если не получилось, возможно это .xls, пробуем xlrd + try: + import xlrd + workbook = xlrd.open_workbook(file_contents=content) + text = [] + for sheet in workbook.sheets(): + text.append(f"=== SHEET: {sheet.name} ===") + rows_text = self._sheet_rows_text_xlrd(sheet) + if rows_text: + text.append("[ROWS]") + text.extend(rows_text) + cols_text = self._sheet_cols_text_xlrd(sheet) + if cols_text: + text.append("[COLUMNS]") + text.extend(cols_text) + return "\n".join(text) + except ImportError: + logger.error("xlrd not installed, cannot parse .xls files") + return "" + except Exception as e2: + logger.error(f"Failed to parse Excel with xlrd: {e2}") + return "" diff --git a/extract_criteria_interface_layout.py b/extract_criteria_interface_layout.py new file mode 100644 index 0000000..cf44a3f --- /dev/null +++ b/extract_criteria_interface_layout.py @@ -0,0 +1,126 @@ +""" +Однократная/повторная генерация criteria_interface_layout.json из Excel +«Запрос для ИИ Общий(2).xlsx» (листы с «интерфейс» в названии). + +Запуск (путь к xlsx можно передать аргументом): + py extract_criteria_interface_layout.py "C:\\Users\\...\\Запрос для ИИ Общий(2).xlsx" +""" +from __future__ import annotations + +import json +import os +import sys + +import openpyxl + +SHEET_TO_SHIPPING_TYPES: dict[str, list[str]] = { + "Авто LTL интерфейс": ["Автомобильная перевозка (LTL)"], + "Авто FTL интерфейс": ["Автомобильная перевозка (FTL)"], + "Море, море+жд, жд LCL интерфейc": [ + "Морская перевозка (LCL)", + "Железнодорожная перевозка (LCL)", + "Мультимодальная перевозка море + ж/д (LCL)", + ], + "Море, море+жд, жд FCL интерфейc": [ + "Морская перевозка (FCL)", + "Железнодорожная перевозка (FCL)", + "Мультимодальная перевозка море + ж/д (FCL)", + ], + "Авиа Интерфейс": ["Авиаперевозка"], +} + + +def _row_cells(ws, r: int, max_c: int = 12) -> list[tuple[int, str]]: + out: list[tuple[int, str]] = [] + for c in range(1, max_c + 1): + v = ws.cell(r, c).value + if v is not None and str(v).strip(): + out.append((c, str(v).strip())) + return out + + +def parse_interface_sheet(ws) -> list[dict]: + """Разбор листа интерфейса в последовательность визуальных блоков.""" + blocks: list[dict] = [] + r = 1 + max_r = ws.max_row or 1 + while r <= max_r: + cells = _row_cells(ws, r) + if not cells: + r += 1 + continue + + # Заголовок: 7 ячеек в колонках 1,2,3,4,5,7,9 и все значения — «номера пунктов» + if ( + len(cells) == 7 + and {c[0] for c in cells} == {1, 2, 3, 4, 5, 7, 9} + and all(str(c[1]).strip().isdigit() or c[1].strip().replace(".", "").isdigit() for c in cells) + ): + nums = [c[1] for c in sorted(cells, key=lambda x: (x[0] not in (1, 2, 3, 4, 5, 7, 9), x[0]))] + # порядок как в Excel слева направо по колонкам + nums = [c[1] for c in sorted(cells, key=lambda x: x[0])] + 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 + continue + + # Только правая пара (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 + continue + + # Две пары: 1-2 и 6-7 + if len(cells) == 4 and {cells[i][0] for i in range(4)} == {1, 2, 6, 7}: + blocks.append( + { + "type": "pair", + "left": {"num": cells[0][1], "label": cells[1][1]}, + "right": {"num": cells[2][1], "label": cells[3][1]}, + } + ) + r += 1 + continue + + # Только левая пара 1-2 (один блок на строку) + if len(cells) == 2 and cells[0][0] == 1 and cells[1][0] == 2: + blocks.append({"type": "left_12", "num": cells[0][1], "label": cells[1][1]}) + r += 1 + continue + + r += 1 + return blocks + + +def main() -> None: + xlsx = ( + sys.argv[1] + if len(sys.argv) > 1 + else os.path.join(os.path.dirname(__file__), "Запрос для ИИ Общий(3).xlsx") + ) + if not os.path.isfile(xlsx): + print("Файл не найден:", xlsx) + sys.exit(1) + + wb = openpyxl.load_workbook(xlsx, read_only=False, data_only=True) + out: dict = {"version": 1, "source_xlsx": os.path.basename(xlsx), "by_shipping_type": {}} + + for sheet_name, type_names in SHEET_TO_SHIPPING_TYPES.items(): + if sheet_name not in wb.sheetnames: + print("Пропуск: лист не найден:", sheet_name) + 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()