179 lines
7.0 KiB
Python
179 lines
7.0 KiB
Python
# -*- 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
|
||
|