231 lines
13 KiB
Python
231 lines
13 KiB
Python
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
|