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