NEKReport/precheck/additional_services.py

231 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
from typing import List
def extract_additional_services(text: str) -> List[str]:
"""
Извлекает дополнительные услуги ТОЛЬКО из тела письма (без вложений).
Услуга добавляется, если:
- упоминается сама услуга,
- рядом (в пределах ~120 символов) есть слово-запрос,
- отсутствует отрицание.
"""
if not text or not text.strip():
return []
text_lower = text.lower().strip()
# ----- вспомогательные функции -----
def _has_negation_near(regex: str, text_scope: str, window: int = 60) -> bool:
for m in re.finditer(regex, text_scope):
start = max(0, m.start() - window)
end = min(len(text_scope), m.end() + window)
ctx = text_scope[start:end]
if re.search(
r"(?:не\s+(?:нуж(?:ен|на|но|ны)|требу(?:ется|ются)|треб(?:уется|уются)"
r"|предоставл(?:яется|яются)|план(?:ируется|ируем)|будет|надо)"
r"|без\s|нет\s+необходимост[и]|не\s+требуется)",
ctx,
re.IGNORECASE,
):
return True
return False
def _has_request_near(regex: str, text_scope: str, window: int = 120) -> bool:
for m in re.finditer(regex, text_scope):
start = max(0, m.start() - window)
end = min(len(text_scope), m.end() + window)
ctx = text_scope[start:end]
if re.search(
r"(?:необход[иа]м[оы]?|требу[ею]тся?|нуж(?:ен|на|но|ны)|"
r"прош[уу]|предусмотр[ие]те?|организу[ей]те?|зака[жз]ите?|"
r"сдела[ей]те?|запросите?|обеспечьте?|просим|пожалуйста|"
r"просьба|required|please\s+(?:provide|arrange)|need|necessary|"
r"provide|arrange|request|ensure)",
ctx,
re.IGNORECASE,
):
return True
return False
# ----- Единый список услуг (упоминание + запрос) -----
services = {
# упаковка и тара
"упаковка / переупаковка": [
r"переупаковк[аи]", r"упаковк[аи]\s*груза", r"паллет(?:иров|изаци)",
r"обреш[её]тк[аи]", r"ж[её]стк[аи]я\s+(?:упаковка|тара)",
r"деревянн[аи]я\s+(?:тара|упаковка)", r"вакуумн[аи]я\s+упаковк[аи]",
r"креплени[ея]\s+груза", r"страпы|упорные\s+брусья",
],
"маркировка / этикетирование": [
r"маркировк[аи]", r"этикетировани[ея]",
r"нанесени[ея]\s+(?:маркировки|знаков\s+опасности)",
r"штрихкод", r"стикер[а-я]*",
],
"страхование груза": [
r"страхов(?:ани[ея]|к[аи])\s+груз[а-я]*", r"застраховать\s+груз",
r"страхов(?:ой|ого)\s+полис", r"cargo\s+insurance",
],
"таможенное оформление / брокерские услуги": [
r"таможенн(?:ое|ая|ый)\s+оформлени[ея]", r"таможн[а-я]+\s+очистк[аи]",
r"таможенн[а-я]+\s+брокер", r"customs\s+clearance", r"customs\s+broker",
],
"сертификация / разрешительные документы": [
r"сертификац[а-я]*", r"сертификат\s+соответствия",
r"декларац[а-я]*\s+соответствия", r"фитосанитарн[а-я]+\s+сертификац",
r"ветеринарн[а-я]+\s+сертификац", r"карантинн[а-я]+\s+сертификац",
r"радиационн[а-я]+\s+контрол[а-я]*", r"сертификат\s+происхождения",
],
"фитосанитарный / карантинный контроль": [
r"фитосанитарн[а-я]+\s+контрол[а-я]*", r"фитоконтрол[а-я]*",
r"карантинн[а-я]+\s+контрол[а-я]*",
],
"фумигация": [
r"фумигац[а-я]*", r"fumigation",
],
"погрузо-разгрузочные работы (ПРР)": [
r"погрузочн[а-я]*\s*[-]\s*разгрузочн[а-я]*", r"погрузк[аи]\s*/\s*разгрузк[аи]",
r"погрузочн[а-я]*\s+работ[а-я]*", r"разгрузочн[а-я]*\s+работ[а-я]*",
r"грузов[а-я]*\s+работ[а-я]*", r"тальманск[а-я]+\s+услуг",
r"сюрвейерск[а-я]+\s+услуг",
],
"складские услуги / хранение": [
r"складск[а-я]+\s+(?:услуг|обработк|логистик)", r"ответственное\s+хранение",
r"временное\s+хранение", r"бондов[а-я]+\s+склад",
r"консолидац[а-я]+\s+груз[а-я]*", r"кросс[-\s]?докинг",
r"управлени[ея]\s+запасами", r"pick\s*(?:&|and)\s*pack",
],
"доставка до двери / last mile": [
r"доставк[аи]\s+до\s+двер[ией]", r"от\s+двери\s+до\s+двери",
r"last\s*mile\s*delivery", r"плечев[а-я]+\s+доставк[аи]",
r"door[-\s]to[-\s]door",
],
"отслеживание / мониторинг груза": [
r"отслеживани[ея]\s+груз[а-я]*", r"трекинг\s+груз[а-я]*",
r"мониторинг\s+груз[а-я]*", r"gps[-\s]?слежени[ея]",
r"датчик[а-я]*\s+(?:удар[а-я]*|вскрыти[ея]|температур)",
r"cargo\s+tracking",
],
"охрана / сопровождение груза": [
r"охран[а-я]*\s+груз[а-я]*", r"сопровождени[ея]\s+груз[а-я]*",
r"вооруж[её]нн[а-я]+\s+охран[а-я]*", r"бронированн[а-я]+\s+машин",
r"security\s+escort",
],
"фотоотчёт / осмотр груза": [
r"фотоотч[её]т", r"фотографировани[ея]\s+груза",
r"осмотр[а-я]*\s+груз[а-я]*", r"инспекц[а-я]*\s+груз[а-я]*",
],
"утилизация / вывоз отходов": [
r"утилизац[а-я]*", r"уничтожени[ея]\s+(?:груз[а-я]*|отход)",
r"вывоз\s+отходов", r"disposal",
],
"переезды / relocation": [
r"переезд[а-я]*\s+(?:офис|завод|квартир)", r"перевозк[аи]\s+вещей",
r"relocation\s+service",
],
"консолидация / сборка грузов": [
r"консолидац[а-я]*\s+(?:груз[а-я]*|парти[ей])",
r"сбор[а-я]+\s+груз[а-я]*", r"сборн[а-я]+\s+отправк[аи]",
],
"обратная логистика / возврат": [
r"обратн[а-я]+\s+логистик[аи]", r"возврат\s+(?:товар[а-я]*|тары)",
r"reverse\s+logistics",
],
"фрахтовый аудит": [
r"фрахтов[а-я]+\s+аудит", r"проверк[аи]\s+счетов",
],
"консалтинг / оптимизация цепей поставок": [
r"консалтинг\s+(?:логистик|цеп[ей]|поставок)",
r"оптимизац[а-я]*\s+(?:логистик|маршрут)", r"supply\s+chain\s+consulting",
],
"сюрвейерские услуги": [
r"сюрвей[её]рск[а-я]*", r"независим[а-я]+\s+экспертиз[аи]",
],
"термобокс / термоконтейнер": [
r"термобокс[а-я]*", r"термоконтейнер[а-я]*",
r"температурн[а-я]+\s+упаковк[аи]", r"изотермическ[а-я]+\s+упаковк[аи]",
],
"хладагенты / холодоэлементы": [
r"хладагент[а-я]*", r"хладоэлемент[а-я]*", r"холодоэлемент[а-я]*",
r"сух[а-я]*\s+л[её]д", r"dry\s*ice",
],
"температурный логгер": [
r"логгер[а-я]*\s*(?:температур|считывани)", r"температурн[а-я]+\s+логгер",
r"датчик[а-я]*\s*температур", r"частота\s+считывани[ея]\s+каждые\s+\d+\s+минут",
],
"спецтранспорт / негабарит": [
r"негабаритн[а-я]+\s+перевозк[аи]", r"тяжеловесн[а-я]+\s+перевозк[аи]",
r"инжиниринг\s+маршрут[а-я]*", r"спецразрешени[ея]",
r"трал[а-я]*", r"низкорамн[а-я]+\s+платформ[а-я]*",
r"танк[-\s]?контейнер[а-я]*", r"рефрижератор[а-я]*",
r"изотермическ[а-я]+\s+фура|jumbo[-\s]?фура",
r"автовоз[а-я]*", r"зерновоз[а-я]*", r"муковоз[а-я]*",
],
"опасные грузы (ADRs)": [
r"опасн[а-я]+\s+груз[а-я]*", r"классификац[а-я]+\s+опасност[и]",
r"допог\s*/\s*adr", r"imdg", r"iata\s+dgr",
r"упаковк[аи]\s+опасн[а-я]*", r"перевозк[аи]\s+опасн[а-я]*",
],
"живые животные": [
r"жив[а-я]+\s+животн[а-я]*", r"перевозк[аи]\s+животных",
r"iata\s+live\s+animals", r"ветеринарн[а-я]+\s+сопровождени[ея]",
],
"ценные / VIP грузы": [
r"ценн[а-я]+\s+груз[а-я]*", r"vip\s+груз[а-я]*",
r"бронированн[а-я]+\s+машин[а-я]*", r"вооруж[её]нн[а-я]+\s+охран[а-я]*",
r"перевозк[аи]\s+искусств[а-я]*", r"музейн[а-я]+\s+экспонат",
r"климатическ[а-я]+\s+контейнер[а-я]*",
],
"гуманитарные / военные грузы": [
r"гуманитарн[а-я]+\s+груз[а-я]*", r"военн[а-я]+\s+груз[а-я]*",
r"бронированн[а-я]+\s+колонн[а-я]*",
],
"логистика мероприятий": [
r"логистик[аи]\s+мероприяти[йя]", r"доставк[аи]\s+оборудовани[яй]\s+для\s+(?:концерт|выстав|олимпиад)",
],
}
found = []
for service_name, patterns in services.items():
# Есть ли упоминание услуги?
mentioned = any(re.search(p, text_lower) for p in patterns)
if not mentioned:
continue
# Есть ли отрицание рядом?
if any(_has_negation_near(p, text_lower) for p in patterns):
continue
# Есть ли запросное слово рядом?
if any(_has_request_near(p, text_lower) for p in patterns):
found.append(service_name)
# Удаление дубликатов
unique = list(dict.fromkeys(found))
# Фильтрация не-услуг (фразы, которые не должны попасть в список, даже если рядом есть запрос)
stop_phrases = [
r"\btotal\b",
r"можно\s+кантовать",
r"штабелир",
r"non\s*stackable",
r"силами\s*NEC",
r"оплат[аи]\s+терминальн",
r"терминальн[а-я]+\s+расход",
r"экспедирован(?:ие)?\s*силами",
r"экспедирование\s*силами",
r"запрашиваем\s+текущие\s+ставки",
r"просят\s+индикатив",
r"нужны\s+dgm", # отдельный пункт
r"страхов(?:ани[ея]|к[аи])\s+силами\s*NEC\s*[-]\s*нет",
r"ftl|lcl|fcl", # типы перевозок
r"авиа\s*перевозк[аи]|жд\s*перевозк[аи]|морск[а-я]+\s*перевозк[аи]",
]
filtered = []
for srv in unique:
if any(re.search(ph, srv, re.IGNORECASE) for ph in stop_phrases):
continue
if len(srv.strip()) <= 3 or srv.strip().endswith(":"):
continue
filtered.append(srv.strip())
return filtered