/
Paff
/
declarant
Обзор
Документация
Войти
/
Paff
/
declarant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
pdf_parser.py
5 924 строки
325 KB
1pash1985-gif
856MLT: страна по папкам <CAR>ecu/ken/col, .doc-инвойсы в манифесте, формат FRESH LOGISTICS (ZAIC), AI-страна для MAIN/PART
26 июл 2026, 13:59
26 июл 2026, 13:59
bd664f2
Код
Авторство
О чём код?
""" Парсер инвойсов для извлечения данных о цветах и коробках. Поддерживает PDF, XLS, XLSX и DOC (Word 97-2003) форматы от разных поставщиков. """ import re import os import tempfile from typing import Optional import pdfplumber import openpyxl try: import xlrd except ImportError: xlrd = None # Для старых .xls файлов try: import win32com.client # Для .doc файлов (Word 97-2003) через Word COM import pythoncom _WIN32COM_AVAILABLE = True except ImportError: _WIN32COM_AVAILABLE = False from models import InvoiceData, InvoiceItem from flower_db import FlowerDatabase, extract_client_from_po, extract_client_from_folder from ocr_parser import is_ocr_available, extract_text_with_ocr from flower_matcher import FlowerMatcher def _convert_doc_to_pdf(doc_path: str) -> Optional[str]: """Конвертировать .doc в .pdf через Word COM. Возвращает путь к временному PDF или None.""" if not _WIN32COM_AVAILABLE: return None try: pythoncom.CoInitialize() except Exception: pass # Копируем .doc в temp — снимает NTFS Zone.Identifier ADS (Mark of the Web), # чтобы Word не открывал файл в Protected View. import shutil tmp_doc = os.path.join(tempfile.gettempdir(), f'_doc_src_{os.getpid()}_{abs(hash(doc_path))}.doc') tmp_pdf = os.path.join(tempfile.gettempdir(), f'_doc_conv_{os.getpid()}_{abs(hash(doc_path))}.pdf') try: shutil.copyfile(doc_path, tmp_doc) except Exception as e: print(f"[DOC] Не удалось скопировать {doc_path}: {e}") return None try: word = win32com.client.DispatchEx('Word.Application') word.Visible = False word.DisplayAlerts = False # Отключаем Protected View для всех случаев try: pv = word.ProtectedViewWindows for i in range(pv.Count, 0, -1): try: pv.Item(i).Close() except Exception: pass except Exception: pass try: word.AutomationSecurity = 3 # msoAutomationSecurityForceDisable — без macro-prompt except Exception: pass try: doc = word.Documents.Open( os.path.abspath(tmp_doc), ReadOnly=True, ConfirmConversions=False, AddToRecentFiles=False, Visible=False, ) # 17 = wdFormatPDF doc.SaveAs(os.path.abspath(tmp_pdf), FileFormat=17) doc.Close(SaveChanges=False) finally: try: word.Quit() except Exception: pass return tmp_pdf if os.path.exists(tmp_pdf) else None except Exception as e: print(f"[DOC] Ошибка конвертации {doc_path}: {e}") return None finally: try: os.remove(tmp_doc) except Exception: pass class InvoiceParser: """Парсер инвойсов (PDF, XLS, XLSX).""" def __init__(self, flower_db: FlowerDatabase): self.flower_db = flower_db self.flower_matcher = FlowerMatcher() # Загрузить пользовательские корректировки from corrections import load_corrections self._corrections = load_corrections() # Известные названия цветов для поиска в тексте self.flower_keywords = [ "HYDRANGEA", "ROSE", "ROSA", "ALSTROEMERIA", "CHRYSANTHEMUM", "CARNATION", "LILIUM", "LILY", "TULIP", "GERBERA", "ORCHID", "GYPSOPHILA", "SOLIDAGO", "HYPERICUM", "ERYNGIUM", "EUCALYPTUS", "STATICE", "LIMONIUM", "DELPHINIUM", "ASTILBE", "BOUVARDIA", "DIANTHUS", "LEUCADENDRON", "PROTEA", "RUSCUS", "PITTOSPORUM", "ASPIDISTRA", "AGAPANTHUS", "AMARANTHUS", "ANEMONE", "ANTHURIUM", "ASTER", "BUPLEURUM", "CALLA", "CELOSIA", "CRASPEDIA", "DAHLIA", "FREESIA", "GLADIOLUS", "HELICONIA", "IRIS", "LAVANDULA", "MATTHIOLA", "NARCISSUS", "PAEONIA", "RANUNCULUS", "SALIX", "SCABIOSA", "SNAPDRAGON", "SUNFLOWER", "TRACHELIUM", "VERONICA", "ZINNIA", "ALLIUM", "ALOE", "ALPINIA", "AMARINE", "AMARYLLIS", "AMARCRINUM", "AMMI", "AMSONIA", "ANANAS", "ANETHUM", "MISCANTHUS", "PHALARIS", "CROTALARIA", "VACCINIUM", "CHAMAEDOREA", "DRACAENA", "CORDYLINE", "FERN", "ASPARGUS", "PHORMIUM", "ARALIA", "MONSTERA", "POLYSCIAS", "SCHEFFLERA", "SPATHIPHYLLUM", "CALATHEA", # Дополнительные сорта роз (для RASUL и других форматов) "PLAYA BLANCA", "MONDIAL", "EXPLORER", "CANDLELIGHT", "VENDULA", "RED NAOMI", "PINK FLOYD", "FULL MONTY", "SWEETNESS", "RHODOS", "ATHENA", "MADAM RED", "SNOW STORM", "BELLE ROSE", "MOONWALK", "ORANGE WAVE", "BOMBASTIC", "GISELLE", ] def parse_file(self, file_path: str, folder_name: str = "") -> InvoiceData: """ Парсить один файл инвойса (PDF, XLS или XLSX). Args: file_path: Путь к файлу инвойса folder_name: Имя папки (для определения клиента) Returns: InvoiceData с извлеченными данными """ ext = os.path.splitext(file_path)[1].lower() if ext in ('.xls', '.xlsx'): result = self._parse_excel_file(file_path, folder_name) elif ext in ('.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.tif'): result = self._parse_image_file(file_path, folder_name) elif ext == '.doc': result = self._parse_doc_file(file_path, folder_name) else: result = self._parse_pdf_file(file_path, folder_name) # === Применить корректировки (синонимы цветов, правила поставщиков) === if result.items and self._corrections: for item in result.items: resolved = self._corrections.resolve_flower_name(item.raw_name) if resolved != item.raw_name: item.raw_name = resolved # Правила поставщика (страна) if result.supplier: rules = self._corrections.get_supplier_rule(result.supplier) if rules.get('country') and not result.country: result.country = rules['country'] return result def _parse_doc_file(self, file_path: str, folder_name: str = "") -> InvoiceData: """ Парсить .doc файл (Word 97-2003). Конвертирует в PDF через Word COM и переиспользует PDF-логику. """ tmp_pdf = _convert_doc_to_pdf(file_path) if not tmp_pdf: result = InvoiceData(file_path=file_path) if folder_name: parts = folder_name.strip().split(maxsplit=1) if len(parts) > 1: result.client_name = parts[1].strip() result.supplier = self._extract_supplier(file_path) if _WIN32COM_AVAILABLE: result.errors.append("Не удалось конвертировать .doc через Word COM") else: result.errors.append("Для .doc требуется Microsoft Word (win32com/pythoncom)") return result try: result = self._parse_pdf_file(tmp_pdf, folder_name) # Восстановим исходный путь для отчётов result.file_path = file_path # Пересобрать поставщика по оригинальному имени .doc result.supplier = self._extract_supplier(file_path) return result finally: try: os.remove(tmp_pdf) except Exception: pass def _parse_image_file(self, file_path: str, folder_name: str = "") -> InvoiceData: """ Парсить изображение инвойса (PNG, JPG и т.д.) с помощью OCR. Args: file_path: Путь к изображению folder_name: Имя папки (для определения клиента) Returns: InvoiceData с извлеченными данными """ result = InvoiceData(file_path=file_path) # Извлечь клиента из папки if folder_name: parts = folder_name.strip().split(maxsplit=1) if len(parts) > 1: result.client_name = parts[1].strip() # Извлечь поставщика из имени файла result.supplier = self._extract_supplier(file_path) try: if is_ocr_available(): full_text = extract_text_with_ocr(file_path) result.errors.append("Текст извлечен с помощью OCR") else: result.errors.append("EasyOCR не установлен. Невозможно распознать изображение.") return result if not full_text.strip(): result.errors.append("OCR не распознал текст на изображении") return result # Парсить различные поля result.invoice_number = self._extract_invoice_number(full_text) result.date = self._extract_date(full_text) result.client_po = self._extract_po(full_text) result.mawb = self._extract_mawb(full_text) result.hawb = self._extract_hawb(full_text) # Извлечь клиента из PO# если есть if result.client_po: po_client = extract_client_from_po(result.client_po) if po_client and not result.client_name: result.client_name = po_client # Извлечь позиции (цветы) result.items = self._extract_items(full_text) # Извлечь количество коробок # Если у items уже есть boxes - использовать их сумму (надёжнее регекса) if result.items and any(it.boxes_qty > 0 for it in result.items): result.total_boxes = sum(item.boxes_qty for item in result.items) else: result.total_boxes = self._extract_total_boxes(full_text) # Если не нашли total_boxes, посчитать по items if result.total_boxes == 0 and result.items: result.total_boxes = sum(item.boxes_qty for item in result.items) except Exception as e: result.errors.append(f"Ошибка при OCR распознавании: {e}") return result def _parse_pdf_file(self, file_path: str, folder_name: str = "") -> InvoiceData: """ Парсить один файл инвойса. Args: file_path: Путь к PDF файлу folder_name: Имя папки (для определения клиента) Returns: InvoiceData с извлеченными данными """ result = InvoiceData(file_path=file_path) # Извлечь клиента из папки if folder_name: parts = folder_name.strip().split(maxsplit=1) if len(parts) > 1: result.client_name = parts[1].strip() # Извлечь поставщика из имени файла result.supplier = self._extract_supplier(file_path) try: with pdfplumber.open(file_path) as pdf: full_text = "" for page in pdf.pages: text = page.extract_text() if text: full_text += text + "\n" if not full_text.strip(): # Fallback: попробовать OCR, если PDF содержит скан/изображение if is_ocr_available(): try: full_text = extract_text_with_ocr(file_path) result.errors.append("Текст извлечен с помощью OCR") except Exception as ocr_err: result.errors.append(f"Не удалось извлечь текст из PDF (OCR ошибка: {ocr_err})") return result else: result.errors.append("Не удалось извлечь текст из PDF") return result # Парсить различные поля result.invoice_number = self._extract_invoice_number(full_text) result.date = self._extract_date(full_text) result.client_po = self._extract_po(full_text) result.mawb = self._extract_mawb(full_text) result.hawb = self._extract_hawb(full_text) # Извлечь клиента из PO# если есть if result.client_po: po_client = extract_client_from_po(result.client_po) if po_client and not result.client_name: result.client_name = po_client # Извлечь позиции (цветы) result.items = self._extract_items(full_text) # AAA ROSES (Forest Gate EPZ) — вертикальный текст не поддаётся regex; если items пуст, берём таблицы if not result.items and ('AAA ROSES' in full_text.upper() or 'FOREST GATE' in full_text.upper()): result.items = self._extract_items_aaa_tables(pdf) # Повёрнутый текст (Cewi Flowers и подобные): pdfplumber рассыпает буквы по одной, # PyMuPDF (fitz) читает корректно — пробуем переизвлечь текст и распарсить заново if not result.items: try: import fitz # PyMuPDF with fitz.open(file_path) as _doc: fitz_text = "\n".join(pg.get_text() for pg in _doc) if fitz_text.strip(): fitz_items = self._extract_items(fitz_text) if fitz_items: result.items = fitz_items full_text = fitz_text except Exception: pass # Извлечь количество коробок # Если у items уже есть boxes - использовать их сумму (надёжнее регекса) if result.items and any(it.boxes_qty > 0 for it in result.items): result.total_boxes = sum(item.boxes_qty for item in result.items) else: result.total_boxes = self._extract_total_boxes(full_text) # Если не нашли total_boxes, посчитать по items if result.total_boxes == 0 and result.items: result.total_boxes = sum(item.boxes_qty for item in result.items) # Извлечь количество стеблей # Если у items уже есть stems - использовать их сумму (надёжнее регекса) if result.items and any(it.stems_qty > 0 for it in result.items): result.total_stems = sum(item.stems_qty for item in result.items) else: result.total_stems = self._extract_total_stems(full_text) # Фолбэк: если не нашли total_stems, посчитать по items if result.total_stems == 0 and result.items: result.total_stems = sum(item.stems_qty for item in result.items) # Сопоставить цветы с базой for item in result.items: # Сначала пробуем FlowerMatcher (база синонимов) canonical, method = self.flower_matcher.match(item.raw_name) if canonical: item.matched_flower = canonical item.is_matched = True else: # Фолбэк: старая база flower_db matched = self.flower_db.find_flower(item.raw_name) if matched: item.matched_flower = matched.full_name_en item.is_matched = True result.parse_success = True except Exception as e: result.errors.append(f"Ошибка парсинга: {str(e)}") return result def _parse_excel_file(self, file_path: str, folder_name: str = "") -> InvoiceData: """ Парсить Excel файл инвойса (XLS или XLSX). Args: file_path: Путь к файлу folder_name: Имя папки (для определения клиента) Returns: InvoiceData с извлеченными данными """ result = InvoiceData(file_path=file_path) # Извлечь клиента из папки if folder_name: parts = folder_name.strip().split(maxsplit=1) if len(parts) > 1: result.client_name = parts[1].strip() # Извлечь поставщика из имени файла result.supplier = self._extract_supplier(file_path) ext = os.path.splitext(file_path)[1].lower() try: if ext == '.xlsx': items = self._parse_xlsx(file_path) elif ext == '.xls': items = self._parse_xls(file_path, result) else: result.errors.append(f"Неподдерживаемый формат: {ext}") return result result.items = items # Сопоставить цветы с базой for item in result.items: canonical, method = self.flower_matcher.match(item.raw_name) if canonical: item.matched_flower = canonical item.is_matched = True else: matched = self.flower_db.find_flower(item.raw_name) if matched: item.matched_flower = matched.full_name_en item.is_matched = True # Посчитать общее количество коробок и стеблей result.total_boxes = sum(item.boxes_qty for item in result.items) result.total_stems = sum(item.stems_qty for item in result.items) result.parse_success = len(result.items) > 0 print(f"[DEBUG] Excel: {len(result.items)} позиций, {result.total_boxes} коробок, {result.total_stems} стеблей") except Exception as e: result.errors.append(f"Ошибка парсинга Excel: {str(e)}") print(f"[DEBUG] Ошибка парсинга Excel {file_path}: {e}") return result def _parse_xlsx(self, file_path: str) -> list[InvoiceItem]: """Парсить XLSX файл с помощью openpyxl.""" items = [] per_sheet: list[tuple[str, list[InvoiceItem]]] = [] actual_path = file_path tmp_fixed = None try: wb = openpyxl.load_workbook(file_path, data_only=True) except (TypeError, KeyError) as e: if 'sharedStrings' in str(e): # Fix case-sensitivity: SharedStrings.xml -> sharedStrings.xml tmp_fixed = self._fix_shared_strings_case(file_path) if tmp_fixed: actual_path = tmp_fixed wb = openpyxl.load_workbook(actual_path, data_only=True) else: return items elif isinstance(e, TypeError): # Workaround для файлов с повреждёнными атрибутами страницы (fitToHeight='') from openpyxl.worksheet.page import PrintPageSetup _orig_init = PrintPageSetup.__init__ def _patched_init(self, **kw): for k in ('fitToHeight', 'fitToWidth'): if k in kw and kw[k] == '': kw[k] = None _orig_init(self, **kw) PrintPageSetup.__init__ = _patched_init try: wb = openpyxl.load_workbook(file_path, data_only=True) finally: PrintPageSetup.__init__ = _orig_init else: return items except Exception: # Битый XML (напр. рисунки с pitchFamily>52) — openpyxl не открывает, # читаем через calamine (DANNA PACKING и подобные) return self._parse_xlsx_calamine(file_path) for sheet in wb.sheetnames: ws = wb[sheet] if not hasattr(ws, 'iter_rows'): continue sheet_items = self._extract_items_from_sheet(ws, file_path=file_path) if sheet_items: per_sheet.append((self._sheet_client_token(ws), sheet_items)) items = self._filter_sheets_by_client(per_sheet, file_path) wb.close() if tmp_fixed: try: os.remove(tmp_fixed) except Exception: pass return items def _parse_xlsx_calamine(self, file_path: str) -> list[InvoiceItem]: """Резервное чтение XLSX через calamine (битые файлы, которые openpyxl не открывает).""" items: list[InvoiceItem] = [] try: from python_calamine import CalamineWorkbook except ImportError: return items class _MatrixSheet: """Адаптер: матрица calamine -> интерфейс iter_rows openpyxl.""" def __init__(self, rows): self._rows = rows def iter_rows(self, min_row=1, values_only=True): for r in self._rows[min_row - 1:]: yield tuple(v if v != '' else None for v in r) try: wb = CalamineWorkbook.from_path(file_path) per_sheet: list[tuple[str, list[InvoiceItem]]] = [] for name in wb.sheet_names: rows = wb.get_sheet_by_name(name).to_python() if rows: ms = _MatrixSheet(rows) sheet_items = self._extract_items_from_sheet(ms, file_path=file_path) if sheet_items: per_sheet.append((self._sheet_client_token(ms), sheet_items)) items = self._filter_sheets_by_client(per_sheet, file_path) except Exception: pass return items @staticmethod def _sheet_client_token(ws) -> str: """Найти маркер клиента 'TO: NAME' в верхних строках листа ('' если нет).""" for r_i, row in enumerate(ws.iter_rows(values_only=True)): if r_i >= 6: break for cell in row: if cell is None: continue m = re.match(r'^\s*TO\s*:\s*([A-Za-zА-Яа-я0-9_\-]+)', str(cell), re.IGNORECASE) if m: return m.group(1).strip().upper() return '' @staticmethod def _filter_sheets_by_client(per_sheet: list[tuple[str, list[InvoiceItem]]], file_path: str) -> list[InvoiceItem]: """Если несколько листов дали позиции и имя файла содержит клиента одного из них ('TO: VINAR'), оставить только его листы (второй лист — старый шаблонный инвойс).""" items: list[InvoiceItem] = [] if len(per_sheet) > 1: fname_up = os.path.basename(file_path).upper() matched = [p for p in per_sheet if p[0] and p[0] in fname_up] if matched and len(matched) < len(per_sheet): per_sheet = matched for _, sheet_items in per_sheet: items.extend(sheet_items) return items @staticmethod def _fix_shared_strings_case(file_path: str) -> str | None: """Fix XLSX with SharedStrings.xml (capital S) -> sharedStrings.xml.""" import zipfile import tempfile import shutil try: with zipfile.ZipFile(file_path, 'r') as zin: names = zin.namelist() # Find mismatched case wrong_name = None for n in names: if n.lower() == 'xl/sharedstrings.xml' and n != 'xl/sharedStrings.xml': wrong_name = n break if not wrong_name: return None # Create temp fixed copy tmp_fd, tmp_path = tempfile.mkstemp(suffix='.xlsx') os.close(tmp_fd) with zipfile.ZipFile(tmp_path, 'w') as zout: for item in zin.infolist(): data = zin.read(item.filename) if item.filename == wrong_name: item.filename = 'xl/sharedStrings.xml' zout.writestr(item, data) return tmp_path except Exception: return None def _parse_xls(self, file_path: str, result: 'InvoiceData' = None) -> list[InvoiceItem]: """Парсить XLS файл с помощью xlrd.""" if xlrd is None: raise ImportError("xlrd не установлен. Установите: pip install xlrd") items = [] wb = xlrd.open_workbook(file_path) # === Определяем формат: GOLDMAN LM S.R.O. (Чехия, инвойсы на голландском) === # Якоря: "GOLDMAN LM" в верхней части + заголовок "Kolli | Inh. | Aantal | Omschrijving | ... | Statcod" goldman_sheet = None for sheet in wb.sheets(): found_goldman = False found_header = False for row_idx in range(min(sheet.nrows, 12)): for c in range(sheet.ncols): val = str(sheet.cell_value(row_idx, c)).strip().upper() if 'GOLDMAN LM' in val: found_goldman = True if val == 'KOLLI': # Проверим что в этой же строке есть 'OMSCHRIJVING' и 'STATCOD' row_vals = [str(sheet.cell_value(row_idx, cc)).strip().upper() for cc in range(sheet.ncols)] if 'OMSCHRIJVING' in row_vals and 'STATCOD' in row_vals: found_header = True if found_goldman and found_header: break if found_goldman and found_header: goldman_sheet = sheet break if goldman_sheet is not None: items = self._parse_goldman_lm_xls(wb, result) return items # === Определяем формат: FLOWERSALE FOREVER (Голландия, XLS-инвойс) === # Якоря: "FLOWERSALE FOREVER" в шапке + секции "Box no:" в товарной части fsf_sheet = None for sheet in wb.sheets(): found_fsf = False found_boxno = False for row_idx in range(min(sheet.nrows, 40)): for c in range(sheet.ncols): val = str(sheet.cell_value(row_idx, c)).strip().upper() if 'FLOWERSALE FOREVER' in val: found_fsf = True elif val == 'BOX NO:': found_boxno = True if found_fsf and found_boxno: break if found_fsf and found_boxno: fsf_sheet = sheet break if fsf_sheet is not None: items = self._parse_flowersale_xls(fsf_sheet, result) return items # === Определяем формат: голландский аукцион (PRO FORMA с CBS sheet) === cbs_sheet = None for sheet in wb.sheets(): for row_idx in range(min(sheet.nrows, 5)): row_text = ' '.join( str(sheet.cell_value(row_idx, c)).strip().upper() for c in range(sheet.ncols) if str(sheet.cell_value(row_idx, c)).strip() ) if 'CBS CODE' in row_text or 'CBS DEFINITION' in row_text: cbs_sheet = sheet break if cbs_sheet: break if cbs_sheet: # Голландский аукцион: парсим CBS таблицу (таможенная декларация) items = self._parse_cbs_sheet(cbs_sheet) # === Раскрываем "Different cutflowers" конкретными названиями из детального листа === generic_names = {'DIFFERENT CUTFLOWERS', 'MIXED FLOWERS', 'CUT FLOWERS', 'CUTFLOWERS'} has_generic = any(it.raw_name.upper() in generic_names for it in items) if has_generic: # Собираем названия уже конкретных CBS-записей для исключения из раскрытия specific_cbs_names = [it.raw_name for it in items if it.raw_name.upper() not in generic_names] expanded = self._expand_different_cutflowers(wb, exclude_cbs_names=specific_cbs_names) if expanded: # Заменяем generic-записи раскрытыми позициями specific_items = [it for it in items if it.raw_name.upper() not in generic_names] specific_items.extend(expanded) items = specific_items # Ищем "Total kolli" или суммируем по колонке "box" для общего количества коробок total_kolli = 0 for sheet in wb.sheets(): # Метод 1: ищем текст KOLLI for row_idx in range(sheet.nrows): for ci in range(sheet.ncols): cell_val = str(sheet.cell_value(row_idx, ci)).strip().upper() if 'KOLLI' in cell_val or 'TOTAL KOLLI' in cell_val: for ci2 in range(sheet.ncols): try: v = str(sheet.cell_value(row_idx, ci2)).strip() num = int(float(v)) if 1 <= num <= 5000: total_kolli = num break except (ValueError, TypeError): pass break if total_kolli > 0: break if total_kolli > 0: break # Метод 2: если kolli не найдено, суммируем колонку "box" из детального листа if total_kolli == 0: for sheet in wb.sheets(): if sheet == cbs_sheet: continue # Ищем заголовок "box" или "boxes" или "colli" box_col = None header_r = None for row_idx in range(min(sheet.nrows, 10)): for ci in range(sheet.ncols): cv = str(sheet.cell_value(row_idx, ci)).strip().upper() if cv in ('BOX', 'BOXES', 'COLLI', 'KOLLI'): box_col = ci header_r = row_idx break if box_col is not None: break if box_col is not None and header_r is not None: box_sum = 0 for row_idx in range(header_r + 1, sheet.nrows): try: v = float(sheet.cell_value(row_idx, box_col)) box_sum += int(v) except (ValueError, TypeError): pass if box_sum > 0: total_kolli = box_sum break # Распределяем коробки пропорционально стеблям if total_kolli > 0 and items: total_stems_all = sum(it.stems_qty for it in items) if total_stems_all > 0: allocated = 0 for i, it in enumerate(items): if i < len(items) - 1: it.boxes_qty = max(1, round(total_kolli * it.stems_qty / total_stems_all)) allocated += it.boxes_qty else: # Последний элемент получает остаток it.boxes_qty = max(1, total_kolli - allocated) print(f"[DEBUG] CBS формат: {len(items)} позиций, kolli={total_kolli}") else: # === Определяем формат: EFANA S.R.O. / подобные (Словакия, голландский аукцион) === # Якорь: заголовок содержит "BOXNRS" + "DESCRIPTION" + "ITEMS" efana_sheet = None efana_header_row = None for sheet in wb.sheets(): for row_idx in range(min(sheet.nrows, 12)): row_vals = [str(sheet.cell_value(row_idx, c)).strip().upper() for c in range(sheet.ncols)] if 'BOXNRS' in row_vals and 'DESCRIPTION' in row_vals and 'ITEMS' in row_vals: efana_sheet = sheet efana_header_row = row_idx break if efana_sheet: break if efana_sheet: items = self._parse_efana_xls(efana_sheet, efana_header_row) else: # Стандартный формат # Пропускаем служебные листы (каталог типов коробок и т.п.) skip_sheet_names = {'BOXES', 'BOX', 'PACKING', 'FUST'} for sheet in wb.sheets(): if sheet.name.upper().strip() in skip_sheet_names: continue if sheet.nrows < 7: continue items.extend(self._extract_items_from_xls_sheet(sheet, file_path)) return items def _parse_flowersale_xls(self, sheet, result: 'InvoiceData' = None) -> list[InvoiceItem]: """ Парсить XLS-инвойс FLOWERSALE FOREVER (Голландия, срезка и горшечные растения). Структура: Шапка: "INVOICE", "Invoice date:", "Invoice no.: 2601176", "Order: OKS" Товарная часть: секции "Box no: N <тип коробки>"; в секции строки кол-во (кол. ~3) | описание (кол. ~8) | цена | сумма, ниже строки-атрибуты "Cm: 80 Cm" / "Pot Ø: 11 Cm" и грувер. Секция упаковки: заголовок "Quan | Code | Packing | Fustprice" — конец товаров. Итог: "Total colli/boxes loaded: N" + HS-сводка (Number/Description/Quan). """ items: list[InvoiceItem] = [] n = sheet.nrows def row_cells(r): return [(c, sheet.cell_value(r, c)) for c in range(sheet.ncols) if str(sheet.cell_value(r, c)).strip() != ''] # Мета: номер и дата инвойса из шапки if result is not None: for r in range(min(n, 25)): cells = row_cells(r) for i, (c, v) in enumerate(cells): vs = str(v).strip() if vs == 'Invoice no.:' and i + 1 < len(cells) and not result.invoice_number: nv = str(cells[i + 1][1]).strip() result.invoice_number = nv.split('.')[0] if nv else '' elif vs == 'Invoice date:' and i + 1 < len(cells) and not result.date: result.date = str(cells[i + 1][1]).strip() in_items = False total_colli = 0 pending = None # последняя позиция — ей присваиваем длину из "Cm: NN Cm" for r in range(n): cells = row_cells(r) if not cells: continue upper = [str(v).strip().upper() for _, v in cells] if 'BOX NO:' in upper: in_items = True continue # Секция упаковки (Fustprice) — товарная часть закончилась if 'FUSTPRICE' in upper: in_items = False continue joined = ' '.join(upper) if 'TOTAL COLLI/BOXES LOADED:' in joined: for _, v in cells: try: num = int(float(v)) if 1 <= num <= 5000: total_colli = num break except (ValueError, TypeError): pass continue if not in_items: continue # Строка-атрибут "Cm: 80 Cm" — длина стебля последней позиции m_cm = re.match(r'^CM:\s*(\d+)', upper[0]) if m_cm and pending is not None: pending.raw_name += f' {m_cm.group(1)}cm' pending = None continue # Строка позиции: первая непустая ячейка — целое кол-во (кол. <= 7), # затем текст-описание правее c0, v0 = cells[0] if not isinstance(v0, (int, float)) or c0 > 7: continue qty = int(v0) if qty <= 0 or abs(v0 - qty) > 1e-9: continue desc = None for c, v in cells[1:]: if isinstance(v, str) and v.strip(): desc = v.strip() break if not desc or desc.upper().startswith(('CM:', 'POT', 'PACKING DETAILS')): continue item = InvoiceItem(raw_name=desc, boxes_qty=0, stems_qty=qty) items.append(item) pending = item # Распределяем коробки пропорционально количеству (метод наибольших остатков: # позиций обычно больше коробок, сумма должна быть строго = colli) if total_colli > 0 and items: total_qty = sum(it.stems_qty for it in items) if total_qty > 0: shares = [total_colli * it.stems_qty / total_qty for it in items] for it, sh in zip(items, shares): it.boxes_qty = int(sh) rest = total_colli - sum(it.boxes_qty for it in items) # Остаток — позициям с наибольшей дробной частью order = sorted(range(len(items)), key=lambda i: shares[i] - int(shares[i]), reverse=True) for i in order[:rest]: items[i].boxes_qty += 1 print(f"[DEBUG] FLOWERSALE формат: {len(items)} позиций, colli={total_colli}") return items def _parse_goldman_lm_xls(self, wb, result: 'InvoiceData' = None) -> list[InvoiceItem]: """ Парсить XLS-инвойс GOLDMAN LM S.R.O. (Чехия, голландский формат). Структура: R1-R7: мета (адрес, банк, Factuur nr, Date, Air Waybillnr) R10: заголовок: Kolli | Inh. | Aantal | Omschrijving | Grower | S1 | S2 | Fc | Price | Amount | BOX | VBN | Statcod R11+: данные; FUST-строки (Kolli=0, Aantal=0, VBN='FUST') = упаковка — пропускаем """ items: list[InvoiceItem] = [] for sheet in wb.sheets(): # Найти строку заголовков и определить колонки header_row = None col_map: dict[str, int] = {} for row_idx in range(min(sheet.nrows, 15)): for c in range(sheet.ncols): val = str(sheet.cell_value(row_idx, c)).strip().upper() if val == 'KOLLI': row_vals = [str(sheet.cell_value(row_idx, cc)).strip().upper() for cc in range(sheet.ncols)] if 'OMSCHRIJVING' in row_vals and 'STATCOD' in row_vals: header_row = row_idx for cc in range(sheet.ncols): vv = str(sheet.cell_value(row_idx, cc)).strip().upper() if vv: col_map[vv] = cc break if header_row is not None: break # Извлечь мета (Factuur nr, Date) из верхней части if result is not None: for row_idx in range(min(sheet.nrows, header_row or 10)): for c in range(sheet.ncols): val = str(sheet.cell_value(row_idx, c)).strip() if not val: continue vu = val.upper() if 'FACTUUR NR' in vu and not result.invoice_number: # Номер в соседней ячейке справа for cc in range(c + 1, sheet.ncols): nv = str(sheet.cell_value(row_idx, cc)).strip() if nv: # Обычно вид "202603953 / ROZAFL" — берём первый токен с цифрами m = re.match(r'(\d[\d\-_/]*)', nv) if m: result.invoice_number = m.group(1) else: result.invoice_number = nv.split()[0] break elif vu.rstrip(':') == 'DATE' and not result.date: for cc in range(c + 1, sheet.ncols): nv = str(sheet.cell_value(row_idx, cc)).strip() if nv: result.date = nv break if header_row is None: continue c_kolli = col_map.get('KOLLI') c_aantal = col_map.get('AANTAL') c_omschr = col_map.get('OMSCHRIJVING') c_vbn = col_map.get('VBN') c_statcod = col_map.get('STATCOD') c_box = col_map.get('BOX') if c_kolli is None or c_aantal is None or c_omschr is None: continue # HS → flower prefix (для статистики, в items отдаём raw_name) row_box_ids: list[set[int]] = [] for row_idx in range(header_row + 1, sheet.nrows): omschr = str(sheet.cell_value(row_idx, c_omschr)).strip() if not omschr: continue # VBN='FUST' — строка упаковки (AA-box, MM-box, Palet, Thermo, Small box, Fust Inc isolatie, Export Box Growers) vbn = '' if c_vbn is not None: vbn = str(sheet.cell_value(row_idx, c_vbn)).strip().upper() if vbn == 'FUST': continue # Стоп-строки: "Total product group", "Box nr" (таблица упаковки ниже товаров) if 'TOTAL PRODUCT GROUP' in omschr.upper() or omschr.upper().startswith('BOX NR'): break # Kolli (коробки) и Aantal (стебли) try: kolli_v = sheet.cell_value(row_idx, c_kolli) kolli = int(float(kolli_v)) if kolli_v not in ('', None) else 0 except (ValueError, TypeError): kolli = 0 try: aantal_v = sheet.cell_value(row_idx, c_aantal) aantal = int(float(aantal_v)) if aantal_v not in ('', None) else 0 except (ValueError, TypeError): aantal = 0 # Пропускаем пустые строки (без стеблей) if aantal <= 0: continue # HS code (для будущего расширения; сейчас не используем) # hs_code = str(sheet.cell_value(row_idx, c_statcod)).strip() if c_statcod is not None else '' # BOX: ID/диапазоны коробок строки ('2', '29-51', '3,7', '9-10,3') box_ids: set[int] = set() if c_box is not None: box_ids = self._parse_box_id_spec(sheet.cell_value(row_idx, c_box)) items.append(InvoiceItem(raw_name=omschr, boxes_qty=kolli, stems_qty=aantal)) row_box_ids.append(box_ids) # Одна физическая коробка может делиться между строками — сумма Kolli # завышает итог (KASH: Kolli-сумма 17, реально 7 коробок). Пересчитываем # по первому вхождению ID из BOX: сумма boxes == число уникальных коробок. if row_box_ids and len(row_box_ids) == len(items) and all(row_box_ids): seen_ids: set[int] = set() for it, ids in zip(items, row_box_ids): it.boxes_qty = len(ids - seen_ids) seen_ids.update(ids) # Обычно Goldman LM — один лист, но пройдёмся по всем на всякий случай if items: break print(f"[DEBUG] GOLDMAN LM: {len(items)} позиций") return items @staticmethod def _parse_box_id_spec(val) -> set[int]: """Разобрать спецификацию ID коробок: 2.0, '36', '29-51', '3,7', '9-10,3' -> набор ID. Возвращает пустой набор, если значение отсутствует или не разбирается.""" ids: set[int] = set() s = str(val).strip() if not s: return ids for part in s.split(','): part = part.strip() m = re.match(r'^(\d+)\s*-\s*(\d+)$', part) if m: a, b = int(m.group(1)), int(m.group(2)) if a <= b and b - a < 2000: ids.update(range(a, b + 1)) else: return set() elif re.match(r'^\d+(\.0+)?$', part): ids.add(int(float(part))) else: return set() return ids def _parse_efana_xls(self, sheet, header_row: int) -> list['InvoiceItem']: """ Парсить XLS-инвойс EFANA S.R.O. (Словакия, голландский аукцион). Структура: Header: BoxNrs | Quantity | Contents | Items | Description | Article | Pot | Length | Packing | Price | Amount Data: 1 | 1.0 | 60.0 | 60.0 | AMAR CAUD GREE PEARL | 00112334 | | 80 | | 1.99 | 119.4 Первые строки (без BoxNr) = сервисные сборы (INKOOPREGELS, KARTON) - пропускаем """ items: list[InvoiceItem] = [] found_flowers: dict[str, InvoiceItem] = {} # Определяем колонки по заголовку col_boxnrs = col_qty = col_items = col_desc = col_length = None for ci in range(sheet.ncols): val = str(sheet.cell_value(header_row, ci)).strip().upper() if val == 'BOXNRS': col_boxnrs = ci elif val == 'QUANTITY': col_qty = ci elif val == 'ITEMS': col_items = ci elif val == 'DESCRIPTION': col_desc = ci elif val == 'LENGTH': col_length = ci if col_desc is None or col_items is None: return items # Сервисные слова - пропускаем service_keywords = {'INKOOPREGELS', 'KARTON', 'MINIMUMDISTRIBUTIE', 'THERMORECORDER', 'TRANSPORTKOSTEN', 'COMMISSION', 'PACKING', 'FREIGHT'} for row_idx in range(header_row + 1, sheet.nrows): # Проверяем BoxNrs - должно быть число (1-N) boxnr_val = sheet.cell_value(row_idx, col_boxnrs) if col_boxnrs is not None else '' try: boxnr = int(float(str(boxnr_val))) except (ValueError, TypeError): continue # Пропускаем строки без номера коробки if boxnr < 1: continue desc = str(sheet.cell_value(row_idx, col_desc)).strip() if not desc or desc.upper() in service_keywords: continue # Пропускаем сервисные строки по ключевым словам if any(kw in desc.upper() for kw in service_keywords): continue # Items = total stems try: total_stems = int(float(str(sheet.cell_value(row_idx, col_items)))) except (ValueError, TypeError): total_stems = 0 if total_stems <= 0: continue raw_name = desc.upper().strip() if raw_name in found_flowers: found_flowers[raw_name].stems_qty += total_stems found_flowers[raw_name].boxes_qty += 1 else: item = InvoiceItem(raw_name=raw_name, boxes_qty=1, stems_qty=total_stems) found_flowers[raw_name] = item items.append(item) print(f"[DEBUG] EFANA: {len(items)} позиций, {sum(it.stems_qty for it in items)} стеблей") return items def _parse_cbs_sheet(self, sheet) -> list[InvoiceItem]: """ Парсить лист CBS (таможенная декларация) из голландского аукциона. Формат: CBS code | CBS definition | qua (stems) | bruto (kg) | netto (kg) | amount Или: CBS code | flower_name | CBS omschrijving | | aantal | bruto | netto | bedrag """ items = [] col_definition = None col_qua = None header_row = None for row_idx in range(min(sheet.nrows, 5)): for ci in range(sheet.ncols): cell_text = str(sheet.cell_value(row_idx, ci)).strip().upper() if 'DEFINITION' in cell_text or 'ОПИСАНИЕ' in cell_text or 'OMSCHRIJV' in cell_text: col_definition = ci header_row = row_idx elif cell_text in ('QUA', 'QUANTITY', 'AANTAL'): col_qua = ci # Fallback: if CBS code found in col 0 but definition column not detected, # flower name is typically in col 1 (next to CBS code) if header_row is None: for row_idx in range(min(sheet.nrows, 5)): row_text = ' '.join( str(sheet.cell_value(row_idx, c)).strip().upper() for c in range(sheet.ncols) if str(sheet.cell_value(row_idx, c)).strip() ) if 'CBS' in row_text: header_row = row_idx # Find CBS code column and assume name is next to it for ci in range(sheet.ncols): cv = str(sheet.cell_value(row_idx, ci)).strip().upper() if 'CBS' in cv and 'CODE' in cv: col_definition = ci + 1 # Name is next column break # Find quantity column (look for number header or 'AANTAL') for ci in range(sheet.ncols): cv = str(sheet.cell_value(row_idx, ci)).strip().upper() if cv in ('QUA', 'QUANTITY', 'AANTAL'): col_qua = ci break break if header_row is None or col_qua is None: return items # If col_definition not found, try col 1 as fallback (flower names after CBS code) if col_definition is None: col_definition = 1 # Validate: check if col_definition has actual data in first data row. # If empty, try adjacent columns (col 1 typically has flower names) if header_row is not None and header_row + 1 < sheet.nrows: test_val = str(sheet.cell_value(header_row + 1, col_definition)).strip() if not test_val: # Try col 1 (standard position for flower names next to CBS code) for try_col in [1, 0, col_definition - 1, col_definition + 1]: if 0 <= try_col < sheet.ncols and try_col != col_definition: test_val2 = str(sheet.cell_value(header_row + 1, try_col)).strip() if test_val2 and not test_val2.replace('.', '').isdigit(): col_definition = try_col break for row_idx in range(header_row + 1, sheet.nrows): definition = str(sheet.cell_value(row_idx, col_definition)).strip() if not definition: continue qua = 0 try: qua = int(float(sheet.cell_value(row_idx, col_qua))) except (ValueError, TypeError): continue if qua > 0 and len(definition) > 2: items.append(InvoiceItem(raw_name=definition, boxes_qty=0, stems_qty=qua)) return items def _expand_different_cutflowers(self, wb, exclude_cbs_names: list = None) -> list[InvoiceItem]: """ Раскрыть 'Different cutflowers' из CBS, используя детальный лист (Sheet1). Парсит лист с заголовками qua/cnt/total/description, группирует по типу цветка. exclude_cbs_names - CBS-записи которые уже учтены отдельно (исключаем из раскрытия). """ import json exclude_cbs_names = exclude_cbs_names or [] # Загружаем синонимы для сопоставления synonyms_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'flower_synonyms.json') synonyms_map = {} # synonym_upper -> canonical_name try: with open(synonyms_path, 'r', encoding='utf-8') as f: syn_data = json.load(f) for canonical, syns in syn_data.items(): if canonical.startswith('_'): continue # Не используем "Different cutflowers" как категорию для сопоставления if canonical == 'Different cutflowers': continue for s in syns: synonyms_map[s.upper()] = canonical except Exception: return [] # Ищем детальный лист (Sheet1 с описанием цветов) detail_sheet = None detail_header_row = None col_total = None # колонка total stems col_desc = None # колонка description for sheet in wb.sheets(): for row_idx in range(min(sheet.nrows, 10)): row_vals = [str(sheet.cell_value(row_idx, c)).strip().upper() for c in range(sheet.ncols)] # Ищем строку заголовков с "description" и ("total" или "qua") has_desc = any('DESCRIPTION' in v for v in row_vals) has_total = 'TOTAL' in row_vals or 'QUA' in row_vals if has_desc and has_total: detail_sheet = sheet detail_header_row = row_idx for ci, v in enumerate(row_vals): if v == 'TOTAL': col_total = ci elif 'DESCRIPTION' in v: col_desc = ci # Если нет "total", используем "cnt" (col после qua) if col_total is None: for ci, v in enumerate(row_vals): if v == 'CNT': col_total = ci break break if detail_sheet: break if not detail_sheet or detail_header_row is None or col_desc is None or col_total is None: return [] # Парсим строки деталей flower_groups = {} # canonical_name -> total_stems for row_idx in range(detail_header_row + 1, detail_sheet.nrows): desc = str(detail_sheet.cell_value(row_idx, col_desc)).strip() if not desc or len(desc) < 3: continue # Получаем количество стеблей (допускаем отрицательные для отмен) stems = 0 try: stems = int(float(detail_sheet.cell_value(row_idx, col_total))) except (ValueError, TypeError): continue if stems == 0: continue # Сопоставляем с каноническим названием цветка через FlowerMatcher canonical, method = self.flower_matcher.match(desc) # Если FlowerMatcher не нашёл, пробуем startswith по синонимам напрямую if not canonical: desc_upper = desc.upper() best_match_len = 0 for syn, can_name in synonyms_map.items(): if len(syn) >= 2 and desc_upper.startswith(syn) and len(syn) > best_match_len: # Проверяем boundary: после синонима пробел или конец if len(desc_upper) == len(syn) or desc_upper[len(syn)] in ' /,;()[].-': canonical = can_name best_match_len = len(syn) # Если всё ещё не найден — используем первое слово как название if not canonical: first_word = desc.split()[0].strip() if len(first_word) >= 3: canonical = first_word.capitalize() else: # Для коротких первых слов (FR, LI, HY) — пропускаем continue if canonical: flower_groups[canonical] = flower_groups.get(canonical, 0) + stems # Фильтруем: убираем группы покрытые CBS-specific записями и с нулевым/отрицательным итогом items = [] for name, stems in flower_groups.items(): if stems <= 0: continue # Проверяем, не покрыт ли этот тип уже CBS-записью is_covered = False name_upper = name.upper() for cbs_name in exclude_cbs_names: cbs_upper = cbs_name.upper() # Проверяем: CBS "Roses" покрывает canonical "Roses", "Roses stem length..." # CBS "Chrysanthemum" покрывает "Chrysanthemum Gr", "Chrysanthemum Tr" if cbs_upper in name_upper or name_upper in cbs_upper: is_covered = True break # Доп. проверка: первое слово CBS совпадает с первым словом canonical cbs_first = cbs_upper.split()[0] name_first = name_upper.split()[0] if len(cbs_first) >= 4 and len(name_first) >= 4: if cbs_first.startswith(name_first[:4]) or name_first.startswith(cbs_first[:4]): is_covered = True break if not is_covered: items.append(InvoiceItem(raw_name=name, boxes_qty=0, stems_qty=stems)) return items def _extract_items_from_sheet(self, ws, file_path: str = '') -> list[InvoiceItem]: """ Извлечь позиции из листа XLSX. Поддерживает форматы с заголовками (RASUL) и без. """ items = [] found_flowers = set() # Определяем rose invoice по имени файла file_name_upper = os.path.basename(file_path).upper() if file_path else '' is_rose_invoice = 'ROSE' in file_name_upper or 'ROSES' in file_name_upper or 'FLORA OLA' in file_name_upper # === FLORAL CHAIN (SERVICE) — Кения, безголовочный формат (BEST FLORA) === # Колонки: label | agent | date | awb | farm | variety | type | length | boxes | boxtype | packrate | stems all_rows_fc = [list(r) for r in ws.iter_rows(values_only=True)] is_floral_chain = any( len(r) > 11 and r[1] and 'FLORAL CHAIN' in str(r[1]).upper() for r in all_rows_fc ) if is_floral_chain: for r in all_rows_fc: if len(r) < 12 or not r[5]: continue farm_up = str(r[4] or '').upper() if 'TOTAL' in farm_up: continue variety_fc = str(r[5]).strip().upper() ftype_fc = str(r[6] or '').strip().upper() try: boxes_fc = int(float(r[8])) if r[8] not in (None, '') else 0 except (ValueError, TypeError): boxes_fc = 0 try: stems_fc = int(float(r[11])) if r[11] not in (None, '') else 0 except (ValueError, TypeError): stems_fc = 0 if stems_fc <= 0: continue raw_fc = f"{ftype_fc} {variety_fc}".strip() if raw_fc in found_flowers: for it in items: if it.raw_name == raw_fc: it.stems_qty += stems_fc it.boxes_qty += boxes_fc break else: found_flowers.add(raw_fc) items.append(InvoiceItem(raw_name=raw_fc, boxes_qty=boxes_fc, stems_qty=stems_fc)) return items # === FRESH LOGISTICS — Эквадор, безголовочный манифест (ZAIC 856MLT) === # Колонки: label | agent | date | awb | farm | variety | type | length | boxes | boxtype # Итоговая строка: farm = 'TOTAL BOXES FOB', boxes = число коробок, boxtype = 'BOX'. # Стеблей в файле нет — берём только коробки (без этого длина 70 принималась за коробки). is_fresh_manifest = any( len(r) > 9 and r[1] and 'FRESH LOGISTICS' in str(r[1]).upper() for r in all_rows_fc ) if is_fresh_manifest: for r in all_rows_fc: if len(r) < 10 or not r[5]: continue farm_up = str(r[4] or '').upper() if 'TOTAL' in farm_up: continue variety_fl = str(r[5]).strip().upper() ftype_fl = str(r[6] or '').strip().upper() length_fl = '' try: length_fl = str(int(float(r[7]))) if r[7] not in (None, '') else '' except (ValueError, TypeError): length_fl = '' try: boxes_fl = int(float(r[8])) if r[8] not in (None, '') else 0 except (ValueError, TypeError): boxes_fl = 0 if boxes_fl <= 0: continue raw_fl = f"{ftype_fl} {variety_fl} {length_fl}".strip() if raw_fl in found_flowers: for it in items: if it.raw_name == raw_fl: it.boxes_qty += boxes_fl break else: found_flowers.add(raw_fl) items.append(InvoiceItem(raw_name=raw_fl, boxes_qty=boxes_fl, stems_qty=0)) return items # Сначала проверим формат с заголовками (RASUL-like) header_row = None col_variety = None col_boxes = None col_stems = None for row_idx, row in enumerate(ws.iter_rows(values_only=True)): row_texts = [str(cell).strip().upper() if cell else '' for cell in row] # Ищем заголовки: СОРТ, КОЛ-ВО КОРОБОК, КОЛ-ВО СТЕБЛЕЙ for idx, cell_text in enumerate(row_texts): if cell_text in ('СОРТ', 'SORT', 'VARIETY', 'DESCRIPTION', 'ОПИСАНИЕ', 'ROW LABELS'): col_variety = idx header_row = row_idx elif ('КОРОБОК' in cell_text or 'BOX' in cell_text or 'BOXES' in cell_text) and '#' not in cell_text and 'NR' not in cell_text: col_boxes = idx elif 'СТЕБЛЕЙ' in cell_text or 'STEMS' in cell_text or cell_text in ('QUANTITY', 'QTY', 'TOTAL QTY', 'ANTAL', 'QUAN', 'STEAMS'): col_stems = idx if header_row is not None: break if header_row is not None and col_variety is not None: # === Специальная логика для Кенийских роз (Flora Ola формат) === # Ищем строки "NNcm Total" как агрегацию по ростовке rose_size_items: list[InvoiceItem] = [] has_cm_markers = False all_rows = list(ws.iter_rows(min_row=header_row + 2, values_only=True)) # Предварительная проверка: есть ли строки "NNcm" (маркеры ростовки)? for row in all_rows: cells = list(row) if col_variety < len(cells) and cells[col_variety]: v = str(cells[col_variety]).strip() if re.match(r'^\d{2,3}cm$', v, re.IGNORECASE): has_cm_markers = True break if has_cm_markers and is_rose_invoice: # Формат Flora Ola: парсим "NNcm Total" строки for row in all_rows: cells = list(row) if col_variety >= len(cells) or not cells[col_variety]: continue v = str(cells[col_variety]).strip() # Ищем строки вида "60cm Total", "40cm Total" cm_total_m = re.match(r'^(\d{2,3})cm\s+Total$', v, re.IGNORECASE) if cm_total_m: cm_val = int(cm_total_m.group(1)) cm_rounded = (cm_val // 10) * 10 boxes = 0 stems = 0 if col_boxes is not None and col_boxes < len(cells) and cells[col_boxes] is not None: try: boxes = int(float(str(cells[col_boxes]).replace(',', '.'))) except (ValueError, TypeError): pass if col_stems is not None and col_stems < len(cells) and cells[col_stems] is not None: try: stems = int(float(str(cells[col_stems]).replace(',', '.'))) except (ValueError, TypeError): pass if stems > 0 or boxes > 0: size_key = f'ROSA {cm_rounded}' rose_size_items.append(InvoiceItem( raw_name=size_key, boxes_qty=boxes, stems_qty=stems )) if rose_size_items: return rose_size_items # Ключевые слова для пропуска мусорных/итоговых строк GARBAGE_KEYWORDS = ('TOTAL', 'COST', 'FLIGHT', 'INVOICE', 'QUALITY', 'FULL BOX', 'KG', 'FLOWER COST', 'WEIGHT', 'PRICE', 'AWB', 'CARRIER', 'CONSIGNEE', 'ADDRESS', 'CLIENT', 'AGENCY', 'AIRLINE', 'DATE', 'ИТОГО', 'ВСЕГО', 'SUBTOTAL', '$') # Собираем все данные в память сначала data_rows = [] # (variety, box_val, stems_val) for row in ws.iter_rows(min_row=header_row + 2, values_only=True): cells = list(row) if col_variety >= len(cells) or not cells[col_variety]: continue variety = str(cells[col_variety]).strip() if not variety or len(variety) < 3: continue # Пропуск чисто числовых значений (коды, итоги) if re.match(r'^[\d,.\s€$]+$', variety): continue v_up = variety.upper() if any(kw in v_up for kw in GARBAGE_KEYWORDS): continue box_val = None if col_boxes is not None and col_boxes < len(cells) and cells[col_boxes] is not None: try: # Дробные значения (0.25/0.5 — доли коробки, VINAR) сохраняем как float box_val = float(str(cells[col_boxes]).replace(',', '.')) except (ValueError, TypeError): pass stems_val = 0 if col_stems is not None and col_stems < len(cells) and cells[col_stems] is not None: try: stems_val = int(float(str(cells[col_stems]).replace(',', '.'))) except (ValueError, TypeError): pass data_rows.append((variety, box_val, stems_val)) # Детект: BOX = порядковый номер (1,2,3,...) вместо количества box_values = [b for _, b, _ in data_rows if b is not None] is_sequence = ( len(box_values) >= 2 and box_values == list(range(1, len(box_values) + 1)) ) # Детект: BOX заполнен только у первой строки коробки (DANNA и подобные: # в одну коробку уложено несколько сортов, пустой BOX = продолжение) box_marker_style = ( not is_sequence and col_boxes is not None and any(b is not None and b > 0 for _, b, _ in data_rows) and any(b is None for _, b, _ in data_rows) ) # Агрегируем по названию agg: dict[str, dict] = {} for variety, box_val, stems_val in data_rows: if is_sequence: # BOX = порядковый номер. Новый номер = +1 физическая коробка; # пустой BOX = продолжение текущей коробки (RUFLORA/BUTA: в одну # коробку уложено несколько сортов, лишь первая строка имеет №). boxes_qty = 1 if box_val is not None else 0 elif box_marker_style: # Пустой BOX = продолжение той же коробки, не новая boxes_qty = box_val if (box_val is not None and box_val > 0) else 0 elif box_val is not None and box_val > 0: boxes_qty = box_val else: boxes_qty = 1 key = variety.upper() if key not in agg: agg[key] = {'boxes': 0, 'stems': 0, 'name': variety} agg[key]['boxes'] += boxes_qty agg[key]['stems'] += stems_val # Дробные доли коробок (VINAR/SOFSAR: T.BOX 0.25/0.5) — целевой итог = # округлённая сумма; распределяем по наибольшим остаткам, чтобы # по-позиционное округление не накапливало погрешность total_frac = sum(d['boxes'] for d in agg.values()) target_boxes = int(total_frac + 0.5) base_sum = sum(int(d['boxes']) for d in agg.values()) remainder = max(0, target_boxes - base_sum) order = sorted(agg.keys(), key=lambda k: agg[k]['boxes'] - int(agg[k]['boxes']), reverse=True) extra = {k: 0 for k in agg} for k in order[:remainder]: extra[k] = 1 for key, data in agg.items(): items.append(InvoiceItem( raw_name=data['name'], boxes_qty=int(data['boxes']) + extra[key], stems_qty=data['stems'] )) if items: return items # Обычный поиск по ключевым словам for row in ws.iter_rows(values_only=True): row_text = " ".join(str(cell) for cell in row if cell is not None) row_text_upper = row_text.upper() for keyword in self.flower_keywords: if keyword in row_text_upper: item = self._extract_item_from_row(row, keyword) if item and item.raw_name.upper() not in found_flowers: found_flowers.add(item.raw_name.upper()) items.append(item) return items def _extract_item_from_row(self, row, keyword: str) -> Optional[InvoiceItem]: """ Извлечь позицию из строки Excel. Паттерны: - [QTY] [FLOWER_NAME] [другое] - [FLOWER_NAME] [QTY] """ # Найти ячейку с ключевым словом flower_cell_idx = None for idx, cell in enumerate(row): if cell and keyword in str(cell).upper(): flower_cell_idx = idx break if flower_cell_idx is None: return None flower_name = str(row[flower_cell_idx]).strip() # Извлечь название цветка (убрать лишнее) flower_name = re.sub(r'\s*(PREMIUM|QUALITY|GRADE).*$', '', flower_name, flags=re.IGNORECASE) flower_name = re.sub(r'\s*\d+\s*$', '', flower_name) # Убрать числа в конце flower_name = flower_name.strip() if not flower_name or len(flower_name) < 3: return None # Отбросить явный junk: email-адреса, URL, СМС-текст, накладные и т.п. if '@' in flower_name or 'http' in flower_name.lower(): return None # Шиппинг-мета (частые колонки-ярлыки в xlsшапках) _junk_phrases = ('MASTER AIR WAYBILL', 'AWB', 'HAWB', 'MAWB', 'AIR WAYBILL') _up = flower_name.upper() if any(p in _up for p in _junk_phrases): return None # Найти количество коробок (ищем числа в соседних ячейках) boxes_qty = 0 for idx, cell in enumerate(row): if idx == flower_cell_idx: continue if cell is not None: try: val = int(float(str(cell).replace(',', '.'))) if 1 <= val <= 1000: # Разумные пределы boxes_qty = val break except (ValueError, TypeError): pass # Если не нашли количество, ищем в самой ячейке с цветком if boxes_qty == 0: match = re.search(r'(\d+)', flower_name) if match: try: boxes_qty = int(match.group(1)) except: pass return InvoiceItem(raw_name=flower_name, boxes_qty=boxes_qty) def _extract_items_from_xls_sheet(self, sheet, file_path: str = '') -> list[InvoiceItem]: """ Извлечь позиции из листа XLS (xlrd). Поддерживает формат PACKING (PRODUCT + VARIETY на следующей строке). Для розовых ферм (SIAN ROSES и т.п.) группирует по ростовке (ROSA 40, ROSA 50...). """ items = [] found_boxes: dict[str, int] = {} # flower_name -> total_boxes found_stems: dict[str, int] = {} # flower_name -> total_stems # === A-Bloemen en Planten (AVRORA): Amount='N x' | Content | Rest | Total | # Article Description | ... | Packed in Box; секции Products повторяются === ab_header = None for r_i in range(min(sheet.nrows, 30)): row_up = [str(sheet.cell_value(r_i, c)).strip().upper() for c in range(sheet.ncols)] if 'ARTICLE DESCRIPTION' in row_up and 'PACKED IN BOX' in row_up: ab_header = r_i break if ab_header is not None: row_up = [str(sheet.cell_value(ab_header, c)).strip().upper() for c in range(sheet.ncols)] ab_amount = row_up.index('AMOUNT') if 'AMOUNT' in row_up else 0 ab_desc = row_up.index('ARTICLE DESCRIPTION') # Колонка Total со стеблями — первая 'TOTAL' до Article Description ab_total = None for c_i in range(ab_desc): if row_up[c_i] == 'TOTAL': ab_total = c_i for r_i in range(ab_header + 1, sheet.nrows): amount_s = str(sheet.cell_value(r_i, ab_amount)).strip() m = re.match(r'^(\d+)\s*[xXхХ]$', amount_s) if not m: continue name = str(sheet.cell_value(r_i, ab_desc)).strip() if not name: continue boxes_ab = int(m.group(1)) stems_ab = 0 if ab_total is not None: try: stems_ab = int(float(sheet.cell_value(r_i, ab_total))) except (ValueError, TypeError): pass key = name.upper() found_boxes[key] = found_boxes.get(key, 0) + boxes_ab found_stems[key] = found_stems.get(key, 0) + stems_ab for fl, bx in found_boxes.items(): items.append(InvoiceItem(raw_name=fl, boxes_qty=bx, stems_qty=found_stems.get(fl, 0))) print(f"[DEBUG] A-Bloemen формат: {len(items)} позиций, коробок {sum(found_boxes.values())}") return items # Определяем, является ли это инвойсом роз (по имени файла) file_name_upper = os.path.basename(file_path).upper() if file_path else '' is_rose_invoice = 'ROSE' in file_name_upper or 'ROSES' in file_name_upper or 'ROSA' in file_name_upper # Поиск заголовков header_row_idx = None col_product = None col_boxes = None col_variety = None col_stems = None col_length = None col_boxes_is_box = False # заголовок колонки — именно 'BOX' (кандидат на ID коробки) for row_idx in range(min(sheet.nrows, 60)): for ci in range(sheet.ncols): cell_text = str(sheet.cell_value(row_idx, ci)).strip().upper() if not cell_text: continue if cell_text == 'PRODUCT' or cell_text == 'PRODUCTOMSCHRIJVING': col_product = ci header_row_idx = row_idx elif cell_text == 'VARIETY': col_variety = ci if header_row_idx is None: header_row_idx = row_idx elif cell_text in ('СОРТ', 'DESCRIPTION', 'DESCIPTION', 'ARTICLE', 'OMSCHRIJVING'): col_product = ci header_row_idx = row_idx elif cell_text in ('BOXES', 'BXS') or ('BOXES' in cell_text and '#' not in cell_text and 'NR' not in cell_text) or 'КОРОБОК' in cell_text: col_boxes = ci elif cell_text == 'BOX': # 'BOX' одно слово может быть количеством или типом; проверяем что нет 'BOX #' рядом # Если в этой строке есть 'BOX #' — это колонка типа, не количества row_has_box_hash = any( '#' in str(sheet.cell_value(row_idx, cc)).strip() for cc in range(sheet.ncols) if str(sheet.cell_value(row_idx, cc)).strip().upper().startswith('BOX') ) if not row_has_box_hash: col_boxes = ci col_boxes_is_box = True elif cell_text in ('STEMS', 'СТЕБЛЕЙ', 'TOTAL STEMS', 'STEM', 'TALLOS', 'UNITS', 'UNIDADES', 'UDS', 'TOT.UNITS', 'TOTAL UNITS', 'TTL UNITS', 'TTL ST', 'TOTAL STEM', 'PIECES', 'PCS', 'PZS', 'STAMS', 'QUANTITY', 'QTY', 'ANTAL', 'AANTAL', 'TOTAL QTY', 'TOTAL', 'QUAN'): col_stems = ci elif cell_text == 'KOLLI': col_boxes = ci elif cell_text in ('LENGTH', 'ДЛИНА', 'LEN', 'STEM LENGTH', 'SIZE'): col_length = ci if header_row_idx is not None: break # Если есть VARIETY но нет PRODUCT — использовать VARIETY как PRODUCT if col_product is None and col_variety is not None: col_product = col_variety col_variety = None if header_row_idx is not None and col_product is not None: # Формат с заголовками current_product = None # Голландский аукционный формат (VBN в заголовке): колонка Box = ID коробки header_has_vbn = any( str(sheet.cell_value(header_row_idx, c)).strip().upper() == 'VBN' for c in range(sheet.ncols) ) # Записи строк для пост-обработки режима "BOX = ID": (цветок, box_val, секция) rows_rec: list[tuple[str, int | None, int]] = [] section_idx = 0 # Если колонка BOXES сама содержит числовые значения в данных — колонки # выровнены, offset-поиск отключаем (иначе он хватает соседние # Length/Pack rate у строк-продолжений, где BOXES пуст; PF-OVB MISHA) boxes_col_aligned = False if col_boxes is not None: for r_chk in range(header_row_idx + 1, sheet.nrows): try: if 0 < float(sheet.cell_value(r_chk, col_boxes)) <= 500: boxes_col_aligned = True break except (ValueError, TypeError): continue for row_idx in range(header_row_idx + 1, sheet.nrows): # Остановиться на границе второй секции (TOTAL / новый PRODUCT header) row_upper = ' '.join( str(sheet.cell_value(row_idx, c)).strip().upper() for c in range(sheet.ncols) if str(sheet.cell_value(row_idx, c)).strip() ) if 'TOTAL' in row_upper and (found_boxes or found_stems): break if 'TOTAL STEMS' in row_upper or 'TOTAL BOX' in row_upper: break # Новый заголовочный ряд (другая таблица) if row_idx > header_row_idx + 3: row_headers = [str(sheet.cell_value(row_idx, c)).strip().upper() for c in range(sheet.ncols)] if 'PRODUCT' in row_headers and 'STEMS' in row_headers: break # Детект CBS/таможенной секции: ячейка col0 — 8-цифр HS-код (0603xxxx) c0 = str(sheet.cell_value(row_idx, 0)).strip() if re.match(r'^\d{8,10}$', c0) and c0.startswith('060'): break # Повторный заголовок секции (PRO FORMA: блок Products на каждого # клиента, нумерация коробок начинается заново) row_cells_up = [str(sheet.cell_value(row_idx, c)).strip().upper() for c in range(sheet.ncols)] if 'BOX' in row_cells_up and ('VBN' in row_cells_up or 'QUAN' in row_cells_up): section_idx += 1 continue # Проверить PRODUCT столбец (и соседние ±1 для merged cells) product_val = '' for offset in [0, 1, -1]: ci = col_product + offset if 0 <= ci < sheet.ncols: v = str(sheet.cell_value(row_idx, ci)).strip() if v and len(v) > 2: product_val = v break if product_val and product_val.upper() not in ('TOTAL', 'NONE', 'SELECT'): # Не путать с заголовком повторной секции if product_val.upper() not in ('PRODUCT', 'VARIETY', 'BOX', 'GRADE', 'STEMS', 'ARTICLE', 'DESCRIPTION', 'QUAN', 'VBN'): # Пропуск чисто числовых значений (итоговые строки, коды) if not re.match(r'^[\d,.\s€$]+$', product_val): # Пропуск упаковочных материалов (с размерами или ключевыми словами) pv_up = product_val.upper() is_packing = ( re.search(r'\d+\s*[xX×]\s*\d+', product_val) # размеры NxN or pv_up.startswith(('PALLET', 'THERMO', 'WINTERPACK', 'OVERBOX', 'FUST', 'ISOLAT')) or ('PAPER' in pv_up and 'FLOWER' not in pv_up) or ('PLASTIC' in pv_up and 'FLOWER' not in pv_up) ) if not is_packing: current_product = product_val # Извлечь ростовку (длину стебля) если есть колонка Length stem_length = '' if col_length is not None: for offset in [0, 1, -1]: ci = col_length + offset if 0 <= ci < sheet.ncols: v = str(sheet.cell_value(row_idx, ci)).strip() if v: # Извлечь число из "40cm", "60 cm", "40", "60" lm = re.search(r'(\d{2,3})', v) if lm: stem_length = lm.group(1) break # Извлечь количество коробок (с ±1 offset для merged cells) boxes_qty = 0 if col_boxes is not None: # Сначала проверяем основную ячейку; если она числовая (даже 0), не используем оффсеты primary_val = sheet.cell_value(row_idx, col_boxes) try: fval = float(primary_val) if 0 < fval <= 500: boxes_qty = int(fval) except (ValueError, TypeError): # Первичная ячейка не числовая/пустая — пробуем оффсеты # (только если сама колонка нигде не содержит чисел — merged cells) if not boxes_col_aligned: for offset in [-1, 1]: ci = col_boxes + offset if 0 <= ci < sheet.ncols: try: val = float(sheet.cell_value(row_idx, ci)) if 0 < val <= 500: boxes_qty = int(val) break except (ValueError, TypeError): pass # Извлечь количество стеблей (с ±1 offset для merged cells) stems_qty = 0 if col_stems is not None: for offset in [0, 1, -1]: ci = col_stems + offset if 0 <= ci < sheet.ncols: try: val = float(sheet.cell_value(row_idx, ci)) if val > 0: stems_qty = int(val) break except (ValueError, TypeError): pass # Если есть product и (boxes или stems) — добавить if current_product and (boxes_qty > 0 or stems_qty > 0): # Для розовых инвойсов с ростовкой: ROSA NN if is_rose_invoice and stem_length: flower_name = f'ROSA {stem_length}' else: flower_name = current_product.upper().strip() # Если boxes = 0 при наличии stems, ставим 0 (подкатегория без коробки) # Агрегировать количество found_boxes[flower_name] = found_boxes.get(flower_name, 0) + boxes_qty if stems_qty > 0: found_stems[flower_name] = found_stems.get(flower_name, 0) + stems_qty rows_rec.append((flower_name, boxes_qty if boxes_qty > 0 else None, section_idx)) # === Режим "BOX = ID физической коробки", а не количество === # FlowerPortal: есть итог 'Total Colli:', сумма ID его превышает; # PRO FORMA (голландский аукцион): заголовок Box+VBN и повторяющиеся ID. if rows_rec and any(b is not None for _, b, _ in rows_rec): # Итог 'Total Colli:' где-либо на листе (число в той же строке) total_colli = 0 for r_i in range(sheet.nrows): if any('TOTAL COLLI' in str(sheet.cell_value(r_i, c_i)).strip().upper() for c_i in range(sheet.ncols)): for c_i in range(sheet.ncols): try: v = float(sheet.cell_value(r_i, c_i)) if 1 <= v <= 5000: total_colli = int(v) break except (ValueError, TypeError): pass break sum_boxes = sum(found_boxes.values()) # Повторы одного ID внутри секции (одна коробка — несколько сортов) id_counts: dict[tuple[int, int], int] = {} for _, b, sec in rows_rec: if b is not None: id_counts[(sec, b)] = id_counts.get((sec, b), 0) + 1 has_repeats = any(v >= 2 for v in id_counts.values()) box_id_mode = ( (total_colli > 0 and sum_boxes > total_colli) or (col_boxes_is_box and header_has_vbn and has_repeats) ) if box_id_mode: # Каждый ID считаем один раз (первое вхождение определяет цветок) seen_ids: set[tuple[int, int]] = set() new_boxes: dict[str, int] = {} for fl, b, sec in rows_rec: if b is None: continue if (sec, b) not in seen_ids: seen_ids.add((sec, b)) new_boxes[fl] = new_boxes.get(fl, 0) + 1 if new_boxes: for fl in found_boxes: found_boxes[fl] = new_boxes.get(fl, 0) print(f"[DEBUG] BOX=ID режим: коробок {sum(found_boxes.values())} (было {sum_boxes}, colli={total_colli})") # Создать items из агрегированных данных for flower_name, total_boxes in found_boxes.items(): items.append(InvoiceItem( raw_name=flower_name, boxes_qty=total_boxes, stems_qty=found_stems.get(flower_name, 0), )) # Если col_boxes не найден, ищем 'Colli' в метаданных (Astra Fund формат) if items and col_boxes is None: colli_val = 0 for r_idx in range(min(sheet.nrows, header_row_idx or 30)): for c_idx in range(sheet.ncols): cv = str(sheet.cell_value(r_idx, c_idx)).strip().upper() if cv in ('COLLI:', 'COLLI', 'COLLIS:', 'KOLLIS:', 'KOLLI:'): # Значение в соседней ячейке for cc in range(c_idx + 1, min(c_idx + 3, sheet.ncols)): try: v = float(sheet.cell_value(r_idx, cc)) if 1 <= v <= 500: colli_val = int(v) break except (ValueError, TypeError): pass break if colli_val > 0: break if colli_val > 0: # Распределить colli пропорционально stems total_st = sum(it.stems_qty for it in items) if total_st > 0: allocated = 0 for i, it in enumerate(items): if i < len(items) - 1: it.boxes_qty = max(0, round(colli_val * it.stems_qty / total_st)) allocated += it.boxes_qty else: it.boxes_qty = max(0, colli_val - allocated) else: # Поровну for it in items: it.boxes_qty = max(1, colli_val // len(items)) if items: return items # Fallback: поиск по ключевым словам if header_row_idx is not None: return items found_set = set() for row_idx in range(sheet.nrows): row = [sheet.cell_value(row_idx, col_idx) for col_idx in range(sheet.ncols)] row_text = " ".join(str(cell) for cell in row if cell) row_text_upper = row_text.upper() for keyword in self.flower_keywords: if keyword in row_text_upper: item = self._extract_item_from_row(row, keyword) if item and item.raw_name.upper() not in found_set: # Пропуск итоговых строк ("TOTAL ROSAS", "TOTAL OTHERS" и т.п.) if item.raw_name.upper().startswith('TOTAL '): continue found_set.add(item.raw_name.upper()) items.append(item) return items def parse_folder(self, folder_path: str) -> list[InvoiceData]: """ Парсить все инвойсы в папке. Args: folder_path: Путь к папке с инвойсами Returns: Список InvoiceData """ results = [] folder_name = os.path.basename(folder_path) # Поддерживаемые расширения extensions = ['.pdf', '.xls', '.xlsx', '.doc'] for filename in os.listdir(folder_path): file_path = os.path.join(folder_path, filename) ext = os.path.splitext(filename)[1].lower() if ext in extensions and os.path.isfile(file_path): invoice = self.parse_file(file_path, folder_name) results.append(invoice) return results def _extract_supplier(self, file_path: str) -> str: """Извлечь имя поставщика из пути к файлу.""" filename = os.path.basename(file_path) # Паттерн: Invoice-XXXXX__SUPPLIER NAME__DATE.pdf match = re.search(r'__(.+?)__', filename) if match: return match.group(1).strip() return "" def _extract_invoice_number(self, text: str) -> str: """Извлечь номер инвойса.""" # Паттерны: "INVOICE: 48941", "Invoice #48941", "INVOICE NO. 48941", "Invoice # 26-25227" (UHURU) # "Invoice number : 145273" (Straathof), "Invoice nr.: 9894" (amsonia) patterns = [ r'INVOICE\s+(?:NUMBER|NR)\s*\.?\s*:\s*([\w][\w\-]*\d)', r'INVOICE[\s:#]+([\w][\w\-]*\d)', # поддерживает буквы/дефисы (INV-00494, 26-25227, 2026_52628) r'Invoice[\s#:]*([\w][\w\-]*\d)', r'INV[\s#:]+([\w][\w\-]*\d)', ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: val = match.group(1).strip() # Отсеиваем мусорные матчи как "NO", "DATED" итп. if len(val) >= 3 and any(ch.isdigit() for ch in val): return val # Red Lands Roses: номер инвойса на отдельной строке после "Invoice Date DD-MM-YYYY" # или перед "Approved By" внизу документа m = re.search(r'Invoice\s+Date\s+\d{2}[-/.]\d{2}[-/.]\d{2,4}\s*\n\s*(\d{6,10})', text, re.IGNORECASE) if m: return m.group(1) m = re.search(r'(\d{6,10})\s+Approved\s+By', text, re.IGNORECASE) if m: return m.group(1) # Baraka Roses: "Invoice No. Dated\n<number> DD-Mon-YYYY" — номер типа "2026_52628" # Номер может быть в той же строке что адрес — берём токен перед датой DD-Mon-YYYY after_hdr = re.search(r'Invoice\s+No\.\s*Dated([\s\S]{0,500})', text, re.IGNORECASE) if after_hdr: block = after_hdr.group(1) m2 = re.search(r'(\S+)\s+\d{1,2}[-/][A-Z][a-z]{2}[-/]\d{4}', block) if m2: val = m2.group(1).strip() # Номер должен содержать цифры (отсеивает "County." итп) if any(ch.isdigit() for ch in val) and len(val) >= 3: return val return "" def _extract_date(self, text: str) -> str: """Извлечь дату инвойса.""" # Паттерны: "DATE ISSUED 2026-06-19", "Date: 19/06/2026", "30-Jun-2026" (Baraka) patterns = [ r'DATE[\s:ISSUED]+(\d{4}-\d{2}-\d{2})', r'Date[\s:]*(\d{2}[/.-]\d{2}[/.-]\d{4})', r'(\d{4}-\d{2}-\d{2})', r'(\d{1,2}[-/][A-Z][a-z]{2}[-/]\d{4})', # 30-Jun-2026 ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: return match.group(1) return "" def _extract_po(self, text: str) -> str: """Извлечь PO# (Purchase Order).""" # Паттерн: "PO#:B-AIGUL X BEST FLORA-2" patterns = [ r'PO#?:?\s*(.+?)(?:\n|TERMS|$)', r'PO\s*#?:?\s*(.+?)(?:\n|TERMS|$)', ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: return match.group(1).strip() return "" def _extract_mawb(self, text: str) -> str: """Извлечь MAWB номер.""" patterns = [ r'MAWB[\s:]*(\S+)', r'M\.?A\.?W\.?B\.?[\s:]*(\S+)', ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: return match.group(1).strip() return "" def _extract_hawb(self, text: str) -> str: """Извлечь HAWB номер.""" patterns = [ r'HAWB[\s:]*(\S+)', r'H\.?A\.?W\.?B\.?[\s:]*(\S+)', ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: return match.group(1).strip() return "" def _extract_items_aaa_tables(self, pdf) -> list[InvoiceItem]: """Специальный обработчик AAA ROSES (Forest Gate EPZ, Kenya) — вертикальный текст. Извлекаем через extract_tables(): колонки = Variety, Length, Box Qty, Stems/box, Total Stems, ... Assorted box = header с Box Qty>0, дальше подстроки без Box Qty (varieties внутри mixed box). Single-variety box = row с Box Qty>0 и конкретной варьете. """ items: list[InvoiceItem] = [] found: set[str] = set() for page in pdf.pages: try: tables = page.extract_tables() or [] except Exception: continue for tbl in tables: if not tbl or len(tbl) < 2: continue # Проверить что header содержит Variety/Length/Box header = [(c or '').strip() for c in tbl[0]] header_str = ' '.join(header).lower() if 'variety' not in header_str or 'box' not in header_str: continue for row in tbl[1:]: cells = [(c or '').strip() for c in row] if len(cells) < 6: continue variety_cell = cells[1] if len(cells) > 1 else '' # Столбец 5 (Total Stems) — основной total_stems_cell = cells[5] if len(cells) > 5 else '' box_qty_cell = cells[3] if len(cells) > 3 else '' if not variety_cell or 'total' in variety_cell.lower(): continue # Assorted box — skip main line, ждём подстрок if 'assorted' in variety_cell.lower() or 'mixed' in variety_cell.lower(): continue # Parse total_stems (может быть "1,000") total_stems_clean = re.sub(r'[,\s]', '', total_stems_cell.split('\n')[0]) if not total_stems_clean.isdigit(): continue stems = int(total_stems_clean) if stems <= 0: continue box_qty_clean = re.sub(r'[,\s]', '', box_qty_cell.split('\n')[0]) boxes = int(box_qty_clean) if box_qty_clean.isdigit() else 0 # Очистка variety: "Ever Red (Sleeved) 50" → "EVER RED" variety = re.sub(r'\(.*?\)', '', variety_cell) variety = re.sub(r'\s+\d+\s*$', '', variety) # remove trailing length variety = variety.strip().upper() if not variety or variety in ('CONFIDENTIAL',): # Confidential — специальный маркер, но всё же цветок variety = variety or 'UNKNOWN' raw_name = f"ROSE {variety}" if raw_name in found: for it in items: if it.raw_name == raw_name: it.stems_qty += stems it.boxes_qty += boxes break else: found.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes, stems_qty=stems)) return items def _extract_items(self, text: str) -> list[InvoiceItem]: """ Извлечь позиции (цветы) из инвойса. Поддерживает разные форматы инвойсов. """ items = [] text_upper = text.upper() found_flowers = set() # === Метод 0: Сертификаты/проформы B2B PROEXP SAS (Колумбия) === # Таблица: PIECES | FULLES | DESCRIPTION | ATPA | CO | UNITS | PRICE | TOTAL FOB # Текст fitz — по одному полю на строку, FULLES склеен с именем: "0,25 HYDRANGEA"; # секции (проформы) повторяются — агрегируем. Коробки = PIECES, стебли = UNITS. if 'PROEXP' in text_upper and 'FULLES' in text_upper: lines_px = [l.strip() for l in text.split('\n')] agg_px: dict[str, dict] = {} # Вариант pdfplumber: вся строка целиком "20 5 CARNATIONS T C.O 7800 0,10 780,00" for l_px in lines_px: m_full = re.match( r'^(\d+)\s+[\d.,]+\s+([A-Z][A-Z ]{2,}?)\s+[TF]\s+C\.O\s+(\d+)\s+[\d.,]+\s+[\d.,]+$', l_px ) if m_full: key_px = m_full.group(2).strip().upper() if key_px not in agg_px: agg_px[key_px] = {'name': m_full.group(2).strip(), 'boxes': 0, 'stems': 0} agg_px[key_px]['boxes'] += int(m_full.group(1)) agg_px[key_px]['stems'] += int(m_full.group(3)) i_px = 0 while (not agg_px) and i_px < len(lines_px) - 4: m_name = re.match(r'^[\d.,]+\s+([A-Z][A-Z ]{2,})$', lines_px[i_px]) if (m_name and re.match(r'^\d+$', lines_px[i_px - 1] if i_px else '') and lines_px[i_px + 1].upper() in ('T', 'F') and lines_px[i_px + 2].upper().startswith('C.O')): name_px = m_name.group(1).strip() boxes_px = int(lines_px[i_px - 1]) try: stems_px = int(lines_px[i_px + 3].replace('.', '').replace(',', '')) except ValueError: stems_px = 0 key_px = name_px.upper() if key_px not in agg_px: agg_px[key_px] = {'name': name_px, 'boxes': 0, 'stems': 0} agg_px[key_px]['boxes'] += boxes_px agg_px[key_px]['stems'] += stems_px i_px += 4 continue i_px += 1 if agg_px: for d_px in agg_px.values(): items.append(InvoiceItem( raw_name=d_px['name'], boxes_qty=d_px['boxes'], stems_qty=d_px['stems'] )) return items # === Метод 0.5: MILELE FLOWERS (Кения) — инвойс внутри сертификата === # Строка: "Roses 35-80 7 215 1500 $0,10 $150,00" — VARIETY | cm | BOXES | RATE | TT STEMS | ... if 'MILELE' in text_upper: agg_ml: dict[str, dict] = {} for l_ml in text.split('\n'): m_ml = re.match( r'^\s*([A-Za-z][A-Za-z ]{2,}?)\s+\d{2}-\d{2}\s+(\d+)\s+\d+\s+(\d+)\s+\$', l_ml.strip() ) if not m_ml: continue key_ml = m_ml.group(1).strip().upper() if key_ml not in agg_ml: agg_ml[key_ml] = {'name': m_ml.group(1).strip(), 'boxes': 0, 'stems': 0} agg_ml[key_ml]['boxes'] += int(m_ml.group(2)) agg_ml[key_ml]['stems'] += int(m_ml.group(3)) if agg_ml: for d_ml in agg_ml.values(): items.append(InvoiceItem( raw_name=d_ml['name'], boxes_qty=d_ml['boxes'], stems_qty=d_ml['stems'] )) return items # === Метод 0.6: QUIMROSES (Эквадор) — "STEMS Q'TY | DESCRIPTION | UNIT VALUE | TOTAL" === # Строка: "500 EXPLORER 60cm 0,10 50"; коробки из "TOTAL HB/QB N" или "TOTAL FULL BOXES 0,5" if 'QUIMROSES' in text_upper: rows_qr = [] for l_qr in text.split('\n'): m_qr = re.match(r'^\s*(\d{2,5})\s+([A-Z][A-Z ]{2,}?)\s+(\d{2,3})\s*[Cc][Mm]\s+[\d.,]+\s+[\d.,]+\s*$', l_qr) if m_qr: rows_qr.append((m_qr.group(2).strip(), int(m_qr.group(1)))) if rows_qr: total_bx_qr = 0 for m_tb in re.finditer(r'TOTAL\s+(?:HB|QB)\s+(\d+)', text_upper): total_bx_qr += int(m_tb.group(1)) if total_bx_qr == 0: m_fb = re.search(r'TOTAL\s+FULL\s+BOXES\s+([\d.,]+)', text_upper) if m_fb: total_bx_qr = max(1, int(round(float(m_fb.group(1).replace(',', '.')) * 2))) for i_qr, (name_qr, stems_qr) in enumerate(rows_qr): items.append(InvoiceItem( raw_name=name_qr, boxes_qty=total_bx_qr if i_qr == 0 else 0, stems_qty=stems_qr )) return items # === Метод 0.7: QUIMFLOWERS (Эквадор) — "#Box Type Variety TxB ... T/B PRICE TOTAL" === # Строка: "1 QB EXPLORER 25 4 100 0.22 22.00 OPENMA" — #Box порядковый, стебли = T/B (последнее целое перед ценой) if 'QUIMFLOWERS' in text_upper: agg_qf: dict[str, dict] = {} for l_qf in text.split('\n'): m_qf = re.match( r'^\s*(\d+)\s+(QB|HB|FB|EB)\s+([A-Z][A-Z ]{2,}?)\s+((?:\d+\s+)+)\d+\.\d+\s+[\d.,]+', l_qf ) if not m_qf: continue nums_qf = m_qf.group(4).split() stems_qf = int(nums_qf[-1]) key_qf = m_qf.group(3).strip().upper() if key_qf not in agg_qf: agg_qf[key_qf] = {'name': m_qf.group(3).strip(), 'boxes': 0, 'stems': 0} agg_qf[key_qf]['boxes'] += 1 # #Box — порядковый номер: каждая строка = 1 коробка agg_qf[key_qf]['stems'] += stems_qf if agg_qf: for d_qf in agg_qf.values(): items.append(InvoiceItem( raw_name=d_qf['name'], boxes_qty=d_qf['boxes'], stems_qty=d_qf['stems'] )) return items # === Метод 0.8: Straathof Plants (Голландия) — черенки в треях === # Строка: "66Blitz Tray 66 0,00 0,00 0,59400 39,20" — кол-во слито с сортом; # род (Begonia) — отдельной строкой перед блоком; коробки из "Total shipment: N boxes" if 'STRAATHOF' in text_upper: rows_sp = [] genus_sp = '' skip_words_sp = {'COSTS', 'ROYALTY', 'MAINTENANCE', 'PAGE', 'SUBTOTAL', 'TOTAL', 'PAYMENT', 'QUALITY', 'INVOICE', 'HOLLAND', 'ORDERNUMBER'} for l_sp in text.split('\n'): l_sp = l_sp.strip() if re.match(r'^[A-Z][a-z]+$', l_sp) and l_sp.upper() not in skip_words_sp: genus_sp = l_sp continue m_sp = re.match( r'^(\d+)\s*([A-Za-z][A-Za-z .\'-]*?)\s+Tray\s+\d+\s+[\d.,]+\s+[\d.,]+\s+[\d.,]+\s+[\d.,]+\s*$', l_sp ) if m_sp: name_sp = f'{genus_sp} {m_sp.group(2).strip()}'.strip() rows_sp.append((name_sp, int(m_sp.group(1)))) if rows_sp: total_bx_sp = 0 m_ship = re.search(r'TOTAL\s+SHIPMENT\s*:?\s*(\d+)\s+BOXES', text_upper) if m_ship: total_bx_sp = int(m_ship.group(1)) else: m_bx = re.search(r'^\s*(\d+)\s+Boxes\s', text, re.MULTILINE) if m_bx: total_bx_sp = int(m_bx.group(1)) for i_sp, (name_sp, qty_sp) in enumerate(rows_sp): items.append(InvoiceItem( raw_name=name_sp, boxes_qty=total_bx_sp if i_sp == 0 else 0, stems_qty=qty_sp )) return items # === Метод 0.9: amsonia BV (Голландия) — луковичные (Lilium) === # Строка: "12 12 x 125 1.500 Santander 20-22 € 550,00 € 825,00 0%" # = BOXES | AMOUNT_PER_BOX | QUANTITY | VARIETY | SIZE | цены; род — строкой выше if 'AMSONIA' in text_upper: rows_am = [] genus_am = '' for l_am in text.split('\n'): l_am = l_am.strip() if re.match(r'^[A-Z][a-z]+$', l_am) and l_am.upper() not in ('INVOICE', 'SUMMARY', 'TOTAL'): genus_am = l_am continue m_am = re.match( r'^(\d+)\s+\d+\s*x\s*\d+\s+([\d.]+)\s+([A-Za-z][A-Za-z .\'-]+?)\s+[\d-]+\s+€', l_am ) if m_am: name_am = f'{genus_am} {m_am.group(3).strip()}'.strip() stems_am = int(m_am.group(2).replace('.', '')) rows_am.append((name_am, int(m_am.group(1)), stems_am)) if rows_am: for name_am, bx_am, stems_am in rows_am: items.append(InvoiceItem( raw_name=name_am, boxes_qty=bx_am, stems_qty=stems_am )) return items # === Метод 1: Формат NATURAL FLOWERS "QTY QB-TYPE STEMS FLOWER_NAME PO#" === # Пример: "1 QB-R 35 HYDRANGEA WHITE B-AIGUL" # (\d+) = qty, (\d+) = stems per box, (name) pattern1 = r'(\d+)\s+QB-?[A-Z0-9]*\s+(\d+)\s+([A-Z][A-Z\s]+?)(?:\s+B-|\s+PO\s|\s*\n)' for match in re.finditer(pattern1, text, re.IGNORECASE | re.MULTILINE): qty = int(match.group(1)) stems_per_box = int(match.group(2)) raw_name = match.group(3).strip() raw_name = re.sub(r'\s*PREMIUM.*$', '', raw_name) raw_name = re.sub(r'\s*\d+\s*$', '', raw_name) raw_name = raw_name.strip() if raw_name and raw_name.upper() not in found_flowers and len(raw_name) > 2: found_flowers.add(raw_name.upper()) item = InvoiceItem(raw_name=raw_name, boxes_qty=qty, stems_qty=qty * stems_per_box) items.append(item) # === Метод 1.5: Формат Flores De Serrezuela / Superior Blooms === # Пример: "1 Q CARNATION SELECT L.PINK 25 400 400 0.2000 80.00" # Колонки: PCS TY DESCRIPTION ST_x_BUN ST_x_PC TTL_ST PR_x_S TTL_US$ if not items: pattern_fds = r'^\s*(\d+)\s+([QHF])\s+([A-Z][A-Z\s.]+?)\s+(\d+)\s+(\d+)\s+([\d,]+)\s+\d+\.\d{2,4}\s+[\d,]+\.\d{2}\s*$' for match in re.finditer(pattern_fds, text, re.MULTILINE): qty = int(match.group(1)) raw_name = match.group(3).strip() total_stems_str = match.group(6).replace(',', '') try: total_stems = int(total_stems_str) except ValueError: total_stems = 0 # Очистить название: убрать "SELECT ...цвет" в конце clean_name = re.sub(r'\s+SELECT\s+.*$', '', raw_name).strip() if not clean_name: clean_name = raw_name if clean_name and len(clean_name) > 2: key = clean_name.upper() if key in found_flowers: # Агрегировать существующий item for it in items: if it.raw_name.upper() == key: it.boxes_qty += qty it.stems_qty += total_stems break else: found_flowers.add(key) item = InvoiceItem(raw_name=clean_name, boxes_qty=qty, stems_qty=total_stems) items.append(item) # === Метод 1.6: Формат Multiflora Corporation === # Пример: "FC -A LS000 ALSTRO Fancy AST Special Pack 10 St(Bunches) multiflora 1 Half tall 35 35 1.7000 59.5000 0.50" # Формат: <FC XXNNN> <FlowerAbbr> <Descr> multiflora <Qty> <BoxType tall NN> <UPB> <TotalUnits> <Price> <Ext> <FBE> # Аббревиатуры: ALSTRO=Alstroemeria, ROS/ROSA=Rosa, HYD=Hydrangea и т.п. if not items: pattern_multi = re.compile( r'^FC[\s\-A-Z]*\d+\s+([A-Z]{3,})\s+.*?multiflora\s+(\d+)\s+(?:Half|Full|Quarter|Eighth)\s+\w+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE | re.IGNORECASE ) multi_flower_map = { 'ALSTRO': 'ALSTROEMERIA', 'ALST': 'ALSTROEMERIA', 'ROS': 'ROSA', 'ROSE': 'ROSA', 'ROSA': 'ROSA', 'HYD': 'HYDRANGEA', 'HYDR': 'HYDRANGEA', 'HYDRA': 'HYDRANGEA', 'CARN': 'CARNATION', 'CARNA': 'CARNATION', 'CHRYS': 'CHRYSANTHEMUM', 'CHRY': 'CHRYSANTHEMUM', 'LIL': 'LILIUM', 'LILY': 'LILIUM', 'TUL': 'TULIP', 'TULIP': 'TULIP', 'GERB': 'GERBERA', 'GYPS': 'GYPSOPHILA', 'GYPSO': 'GYPSOPHILA', } for match in pattern_multi.finditer(text): abbr = match.group(1).strip().upper() boxes = int(match.group(2)) total_stems = int(match.group(3)) raw_name = multi_flower_map.get(abbr, abbr) if raw_name and len(raw_name) > 2: key = raw_name.upper() if key in found_flowers: for it in items: if it.raw_name.upper() == key: it.boxes_qty += boxes it.stems_qty += total_stems break else: found_flowers.add(key) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes, stems_qty=total_stems)) # === Метод 1.65: Формат PJ Dave Flora / Кения (HS CODE + Promo/YRP) === # Пример полной строки: "Rhodos-50cms 4.5 cm 600 Jumbo-100x47x27 4 21 Kgs 2,400 YRP USD 0.1500 USD 360.00" # Пример короткой строки: "Athena-50cms 150 150 YRP USD 0.1500 USD 22.50" # Якоря: "HS CODE" + Event-маркеры (Promo/YRP) перед "USD" # Колонки: Description H/Size CustItemCode P/Rate BoxSize Bxs BxWgt Qty Event Price Total # Все цветы определяются по HS CODE: 060311=Rosa, 060312=Carnation, 060314=Chrysanthemum # Для роз (060311): дополнительно группируем по ростовке (40cm, 50cm, 60cm и т.д.) if not items and 'HS CODE' in text_upper: hs_match = re.search(r'HS\s+CODE\s+(\d{6})', text, re.IGNORECASE) has_event_marker = bool(re.search(r'(?:Promo|YRP)\s+USD', text, re.IGNORECASE)) if hs_match and has_event_marker: hs_code = hs_match.group(1) # Маппинг HS-кода на цветок hs_flower_map = { '060311': 'ROSA', '060312': 'CARNATION', '060313': 'ORCHID', '060314': 'CHRYSANTHEMUM', '060319': 'MIXED FLOWERS', '060390': 'MIXED FLOWERS', } base_flower = hs_flower_map.get(hs_code, 'ROSA') is_rosa = (hs_code == '060311') # Группировка по ростовке (для роз) или единый счётчик (остальные) # Ключ: "ROSA 50" / "ROSA 60" или base_flower (если не роза) size_map: dict[str, dict] = {} # key → {boxes, stems} # Обработка построчно: ищем строки с (Promo|YRP) USD for line in text.split('\n'): # Извлекаем Qty (стебли): число перед Promo/YRP qty_m = re.search(r'([\d,]+)\s+(?:Promo|YRP)\s+USD', line, re.IGNORECASE) if not qty_m: continue qty_str = qty_m.group(1).replace(',', '') try: stems = int(qty_str) except (ValueError, TypeError): continue # Извлекаем Bxs (коробки): число после BoxSize (Jumbo/Zim) boxes = 0 bxs_m = re.search(r'(?:Jumbo|Zim)[-\w]*\s+(\d+)\s+(?:\d+[\d.]*\s+Kgs\s+)?[\d,]+\s+(?:Promo|YRP)', line, re.IGNORECASE) if bxs_m: try: boxes = int(bxs_m.group(1)) except (ValueError, TypeError): boxes = 0 # Определяем ростовку для роз: ищем NNcms или NN cms в начале строки size_key = base_flower if is_rosa: size_m = re.search(r'(\d{2,3})\s*cm', line, re.IGNORECASE) if size_m: cm = size_m.group(1) size_key = f'ROSA {cm}' # Агрегация if size_key not in size_map: size_map[size_key] = {'boxes': 0, 'stems': 0} size_map[size_key]['boxes'] += boxes size_map[size_key]['stems'] += stems # Создаём items из агрегированных данных for key, data in size_map.items(): if data['stems'] > 0: items.append(InvoiceItem(raw_name=key, boxes_qty=data['boxes'], stems_qty=data['stems'])) found_flowers.add(key.upper()) # === Метод 1.66: Формат Karen Roses / Кения (HS CODE + NNcm без Promo/YRP) === # Пример: "Giselle KE/030/0809/047 060311 Spr 62cm. 140 140 ₽ 0.8000 ₽ 112.00" # Итого: "1 240 ₽ 172.00" (boxes stems ₽ amount) # Якоря: HS CODE 060311 + "NNcm." в строке + ₽(\uf155) как валюта if not items and 'HS CODE' not in text_upper: # Проверяем альтернативный формат: 060311 в тексте + NNcm + символ валюты ₽ has_hs = bool(re.search(r'060311|060312|060314', text)) has_cm_pattern = bool(re.search(r'\d{2,3}cm', text, re.IGNORECASE)) has_currency = '\uf155' in text or '$' in text if has_hs and has_cm_pattern and has_currency: hs_match2 = re.search(r'(06031[1-4])', text) hs_code2 = hs_match2.group(1) if hs_match2 else '060311' hs_flower_map2 = { '060311': 'ROSA', '060312': 'CARNATION', '060313': 'ORCHID', '060314': 'CHRYSANTHEMUM', } base_flower2 = hs_flower_map2.get(hs_code2, 'ROSA') is_rosa2 = (hs_code2 == '060311') size_map2: dict[str, dict] = {} total_boxes_kr = 0 # Извлекаем из строк с NNcm: length + Qty + Total # Pattern: "NNcm. QQQ QQQ" (Qty Total) followed by price for m in re.finditer(r'(\d{2,3})cm\.?\s+(\d+)\s+(\d+)\s+', text): cm_raw = int(m.group(1)) qty = int(m.group(2)) total_val = int(m.group(3)) # Округляем до ближайшей стандартной ростовки (кратной 10) cm = str((cm_raw // 10) * 10) if cm_raw >= 10 else str(cm_raw) # Берём Total (обычно = Qty для одного цветка в одном боксе) stems = total_val size_key2 = f'{base_flower2} {cm}' if is_rosa2 else base_flower2 if size_key2 not in size_map2: size_map2[size_key2] = {'boxes': 0, 'stems': 0} size_map2[size_key2]['stems'] += stems # Извлекаем boxes из итоговой строки: "N NNN ₽ NNN.NN" (boxes total ₽ amount) # или "N NNN" перед ценовым маркером (последняя такая строка в блоке данных) box_pattern = re.findall(r'^(\d+)\s+(\d+)\s+[\uf155$]', text, re.MULTILINE) if box_pattern: # Берём последнюю (итоговую) запись total_boxes_kr = int(box_pattern[-1][0]) if size_map2: # Распределяем boxes пропорционально (или все в одну группу если одна ростовка) if len(size_map2) == 1: for key in size_map2: size_map2[key]['boxes'] = total_boxes_kr else: # Если несколько ростовок — пока boxes = общее (для упрощения) total_stems_sum = sum(d['stems'] for d in size_map2.values()) for key, data in size_map2.items(): if total_stems_sum > 0: data['boxes'] = round(total_boxes_kr * data['stems'] / total_stems_sum) for key, data in size_map2.items(): if data['stems'] > 0: items.append(InvoiceItem(raw_name=key, boxes_qty=data['boxes'], stems_qty=data['stems'])) found_flowers.add(key.upper()) # === Метод 1.67: Формат Winchester / MZURRIE / Кения (Grand Total + Stems Confirmed + NNcm) === # Пример: "Grand Total 2 750 750 442 2 750 750 442.00" # Якоря: "TOTAL STEMS" (или "STEMS CONFIRMED") + "GRAND TOTAL" + "NN cm" маркер ростовки if not items and 'GRAND TOTAL' in text_upper and ('TOTAL STEMS' in text_upper or 'STEMS CONFIRMED' in text_upper): # Ищем маркер ростовки "NN cm" cm_marker = re.search(r'(\d{2,3})\s*cm\b', text, re.IGNORECASE) if cm_marker: cm_val = int(cm_marker.group(1)) cm_rounded = (cm_val // 10) * 10 # Извлекаем из строки Grand Total: первое число = boxes, второе = stems (packrate=stems в итогах) gt_match = re.search(r'Grand\s+Total\s+(\d+)\s+(\d+)\s+(\d+)', text, re.IGNORECASE) if gt_match: total_boxes_gt = int(gt_match.group(1)) total_stems_gt = int(gt_match.group(3)) # Stems Confirmed = 3-е число # Определяем тип цветка: если "Rose"/"Spray" в тексте — роза is_rosa_gt = bool(re.search(r'\bRose\b|\bSpray\b|\bRoses\b', text, re.IGNORECASE)) base_flower_gt = 'ROSA' if is_rosa_gt else 'FLOWER' size_key_gt = f'{base_flower_gt} {cm_rounded}' if total_stems_gt > 0: items.append(InvoiceItem(raw_name=size_key_gt, boxes_qty=total_boxes_gt, stems_qty=total_stems_gt)) found_flowers.add(size_key_gt.upper()) # === Метод 1.68: Формат Red Lands Roses SEZ PLC / Кения (.doc через Word COM) === # Пример строки: "S CDL150 Candle Light 50 80 1.23 98.40" (опц. " Manual uniqu" в конце) # Колонки: S <ContentCode> <Variety...> <Length> <Stems> <StemPrice> <Amount> # Якоря: "RED LANDS ROSES" или "FLO NUMBER: 4660" + "HS Code: 06031100" # HS 06031100 = Roses (per PJ Dave convention: префикс 060311 → ROSA) # Группировка: по ростовке (ROSA 50, ROSA 60, ROSA 70) как в Методе 1.65 if not items and ('RED LANDS ROSES' in text_upper or 'FLO NUMBER: 4660' in text_upper): hs_match_rl = re.search(r'HS\s*Code\s*:?\s*(\d{6})', text, re.IGNORECASE) hs_code_rl = hs_match_rl.group(1) if hs_match_rl else '060311' hs_flower_map_rl = { '060311': 'ROSA', '060312': 'CARNATION', '060313': 'ORCHID', '060314': 'CHRYSANTHEMUM', } base_flower_rl = hs_flower_map_rl.get(hs_code_rl, 'ROSA') is_rosa_rl = (hs_code_rl == '060311') size_map_rl: dict[str, dict] = {} # key → {stems} # Pattern: S <code> <variety> <length> <stems> <price> <amount> [Manual uniqu] # ContentCode: буквы+цифры, Variety: 1-4 слова (буквы), Length: 2-3 цифры, Stems: 1-4 цифры line_pattern_rl = re.compile( r'^\s*S\s+([A-Z]{1,6}\d{2,4})\s+([A-Za-z][A-Za-z\s\-\.]+?)\s+(\d{2,3})\s+(\d{1,5})\s+([\d.]+)\s+([\d.]+)(?:\s+Manual\s+uniqu?)?\s*$', re.MULTILINE ) for m in line_pattern_rl.finditer(text): length_cm = int(m.group(3)) stems_v = int(m.group(4)) # Округляем ростовку до кратной 10 (50, 60, 70) cm_rounded_rl = (length_cm // 10) * 10 if length_cm >= 10 else length_cm size_key_rl = f'{base_flower_rl} {cm_rounded_rl}' if is_rosa_rl else base_flower_rl if size_key_rl not in size_map_rl: size_map_rl[size_key_rl] = {'stems': 0} size_map_rl[size_key_rl]['stems'] += stems_v # Извлекаем общее количество коробок из "Total BOXES N" total_boxes_rl = 0 tb_match_rl = re.search(r'Total\s+BOXES\s+(\d+)', text, re.IGNORECASE) if tb_match_rl: try: total_boxes_rl = int(tb_match_rl.group(1)) except (ValueError, TypeError): total_boxes_rl = 0 if size_map_rl: # Распределяем boxes пропорционально стеблям (если ростовок несколько) total_stems_sum_rl = sum(d['stems'] for d in size_map_rl.values()) if len(size_map_rl) == 1: for key in size_map_rl: size_map_rl[key]['boxes'] = total_boxes_rl else: assigned = 0 keys_sorted = list(size_map_rl.keys()) for i, key in enumerate(keys_sorted): if i == len(keys_sorted) - 1: # Остаток — последнему, чтобы сумма точно совпала size_map_rl[key]['boxes'] = max(0, total_boxes_rl - assigned) else: b = round(total_boxes_rl * size_map_rl[key]['stems'] / total_stems_sum_rl) if total_stems_sum_rl > 0 else 0 size_map_rl[key]['boxes'] = b assigned += b for key, data in size_map_rl.items(): if data['stems'] > 0: items.append(InvoiceItem(raw_name=key, boxes_qty=data.get('boxes', 0), stems_qty=data['stems'])) found_flowers.add(key.upper()) # === Метод 1.69: Формат Baraka Roses / Mumiflora Ltd (Кения) === # Пример строки типа "Rose Athena 50cm": # "1 Rose Athena 50cm 0001.12.00 420 1 box 420 $ 0.250 stem 0 % $ 105.000 32.410 13,612.20" # Пример строки типа "Rose Mix Box 40cm": # "2 Rose Mix Box 40cm 0001.12.00 500 1 box 500 $ 0.160 stem ..." # Колонки: Sl.No | Description (Rose <Variety> <cm>cm) | HS Code | Pack | Boxes | Stems | Rate | ... # Якоря: "Mumiflora Ltd" или "Baraka Roses" в тексте; HS Code 0001.12.00 (нетиповой, но все розы) # Группируем по ростовке (ROSA 40 / ROSA 50 / ROSA 60), строки sub-items ("Athena 100 Stems") пропускаем if not items and ('MUMIFLORA' in text_upper or 'BARAKA ROSES' in text_upper): # Строка товара: <Sl.No> Rose <слова> <cm>cm <HS> <Pack> <Boxes> box <Stems> $ <Rate> stem # UCR Charges — пропускаем (не цветок) baraka_pattern = re.compile( r'^\s*\d+\s+Rose\s+.+?\s+(\d{2,3})\s*cm\s+\d{4}\.\d{2}\.\d{2}\s+\d+\s+(\d+)\s+box\s+(\d+)\s+\$', re.MULTILINE | re.IGNORECASE ) size_map_br: dict[str, dict] = {} # "ROSA NN" → {boxes, stems} for m in baraka_pattern.finditer(text): cm_raw = int(m.group(1)) boxes_br = int(m.group(2)) stems_br = int(m.group(3)) cm_key = (cm_raw // 10) * 10 if cm_raw >= 10 else cm_raw size_key_br = f'ROSA {cm_key}' if size_key_br not in size_map_br: size_map_br[size_key_br] = {'boxes': 0, 'stems': 0} size_map_br[size_key_br]['boxes'] += boxes_br size_map_br[size_key_br]['stems'] += stems_br for key, data in size_map_br.items(): if data['stems'] > 0: items.append(InvoiceItem(raw_name=key, boxes_qty=data['boxes'], stems_qty=data['stems'])) found_flowers.add(key.upper()) # === Метод 1.7: Формат SANTILLANA / LA CONCHITA === # Пример: "2 Q ALSTROEMERIA PINK PERFECTION 0603.19.3000 B-AQUA 160 320 0,340 108.80" # Колонки: BOXES TY DESCRIPTION HTS PO BUNCHS STEMS PRICE NET_PRICE if not items: pattern_lc = r'^(\d+)\s+([QHF])\s+([A-Z][A-Z\s]+?)\s+(\d{4}\.\d{2}\.\d{2,4}(?:\.\d{2})?)\s+(B-\S+)\s+(\d+)\s+(\d+)\s+[\d,]+\s+[\d,]+\.\d+' for match in re.finditer(pattern_lc, text, re.MULTILINE): qty = int(match.group(1)) raw_name = match.group(3).strip() total_stems = int(match.group(7)) # Очистить название: убрать второе слово (цвет/сорт) parts = raw_name.split() clean_name = parts[0] if parts else raw_name if clean_name and len(clean_name) > 2: key = clean_name.upper() if key in found_flowers: for it in items: if it.raw_name.upper() == key: it.boxes_qty += qty it.stems_qty += total_stems break else: found_flowers.add(key) item = InvoiceItem(raw_name=clean_name, boxes_qty=qty, stems_qty=total_stems) items.append(item) # === Метод 1.85: Формат AGRICOLA EL CACTUS === # Пример: "Carnation Select Red 0.25 1 QB 16 CO 0603127000 AD F2 400 400 0.15 60.00" # Пример: "Carnation Select Red 1.25 5 QB 16 CO 0603127000 AD F2 2,000 400 0.15 300.00" # Колонки: DESC BOXES(frac) PIECES BXB_type BXB FTA HTS CLA FCA T.STEMS SXB Pri TOTAL # T.STEMS может быть в US-формате с запятой ("2,000" = 2000) if not items: pattern_cactus = re.compile( r'^([A-Z][A-Za-z][A-Za-z ]+?)\s+[\d.]+\s+(\d+)\s+(?:QB|HB|FB|EB)\s+\d+\s+[A-Z]{2}\s+\d{10}\s+\S+\s+\S+\s+([\d,]+)\s+\d+\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) for match in pattern_cactus.finditer(text): raw_name = match.group(1).strip() pieces = int(match.group(2)) stems_str = match.group(3).replace(',', '') try: total_stems = int(stems_str) except (ValueError, TypeError): total_stems = 0 parts = raw_name.split() clean_name = parts[0] if parts else raw_name if clean_name and len(clean_name) > 2: key = clean_name.upper() if key in found_flowers: for it in items: if it.raw_name.upper() == key: it.boxes_qty += pieces it.stems_qty += total_stems break else: found_flowers.add(key) items.append(InvoiceItem(raw_name=clean_name, boxes_qty=pieces, stems_qty=total_stems)) # === Метод 1.9: Формат La Gaitana Farms === # Итоговые строки по каждому виду цветов: # "Carnation Full Boxes CA 4 1.600 328,00" # "Minicarnation Full Boxes MI 2 800 160,00" # Формат: <FlowerName> Full Boxes <2-letter code> <boxes> <stems(EU format)> <value> # В EU-формате "1.600" = 1600 (точка как разделитель тысяч) if not items: pattern_lg = re.compile( r'^([A-Za-z][A-Za-z]+)\s+Full\s+Boxes\s+[A-Z]{2}\s+(\d+)\s+([\d.,]+)\s+[\d.,]+\s*$', re.MULTILINE ) for match in pattern_lg.finditer(text): raw_name = match.group(1).strip() boxes = int(match.group(2)) stems_str = match.group(3).replace('.', '').replace(',', '') try: total_stems = int(stems_str) except (ValueError, TypeError): total_stems = 0 if raw_name and len(raw_name) > 2: key = raw_name.upper() if key in found_flowers: for it in items: if it.raw_name.upper() == key: it.boxes_qty += boxes it.stems_qty += total_stems break else: found_flowers.add(key) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes, stems_qty=total_stems)) # === Метод 1.95: Формат Flores Aurora SAS === # Итоговые строки в блоке "Product | Pieces | Full Boxes | Bunches | Stems": # "Carnation: 10 QB 2.500 160 4,000" # где 10=количество коробок (QB), 2.500=full boxes equiv (2.5), 160=пучки, 4,000=стебли # Двоеточие после названия — надёжный якорь; строка Totals: пропускается по имени if not items: pattern_aurora = re.compile( r'^([A-Za-z]{4,}):\s+(\d+)\s+(?:QB|HB|FB|EB)\s+[\d.,]+\s+\d+\s+([\d,]+)\s*$', re.MULTILINE ) for match in pattern_aurora.finditer(text): raw_name = match.group(1).strip() boxes = int(match.group(2)) stems_str = match.group(3).replace(',', '') try: total_stems = int(stems_str) except (ValueError, TypeError): total_stems = 0 if raw_name and len(raw_name) > 2 and raw_name.lower() not in ('totals', 'total'): key = raw_name.upper() if key in found_flowers: for it in items: if it.raw_name.upper() == key: it.boxes_qty += boxes it.stems_qty += total_stems break else: found_flowers.add(key) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes, stems_qty=total_stems)) # === Метод 1.8: Формат Flores San Juan (двухстрочный с типом коробки) === # Строка 1: "CARNATION ASSORTED SELECT CO 0603.12.90.00" # Строка 2: "1 QUARTE 200 200 25 B-SWARTON..." # Типы коробок: QUARTE(R), HALF, FULL, EIGHT(H), SIXTEEN(TH) if not items: pattern_fsj = re.compile( r'^([A-Z][A-Z ]+?)\s+(?:SELECT|PREMIUM|STANDARD)\s+CO\s+\d{4}\.\d{2}\.\d{2}\.\d{2}\s*\n' r'\s*(\d+)\s+(?:QUARTE\w*|HALF\w*|FULL\w*|EIGHTH?\w*|SIXTEEN\w*)\s+(\d+)\s+\d+\s+\d+', re.MULTILINE ) for match in pattern_fsj.finditer(text): raw_name = match.group(1).strip() qty = int(match.group(2)) total_stems = int(match.group(3)) parts = raw_name.split() clean_name = parts[0] if parts else raw_name if clean_name and len(clean_name) > 2: key = clean_name.upper() if key in found_flowers: for it in items: if it.raw_name.upper() == key: it.boxes_qty += qty it.stems_qty += total_stems break else: found_flowers.add(key) items.append(InvoiceItem(raw_name=clean_name, boxes_qty=qty, stems_qty=total_stems)) # === Метод 2: Формат GLOW FLOWERS "QTY QB FLOWER PREMIUM ... PO#" === # Пример: "1 QB HYDRANGEA MIX PREMIUM Hydrangea L. ... 0603.19.90.10 140 0.700 ... B-TEPLO" if not items: pattern2 = r'(\d+)\s+QB\s+([A-Z][A-Z\s]+?)(?:\s+PREMIUM|\s+Hydrangea|\s+B-)' for match in re.finditer(pattern2, text, re.IGNORECASE | re.MULTILINE): qty = int(match.group(1)) raw_name = match.group(2).strip() raw_name = re.sub(r'\s*PREMIUM.*$', '', raw_name) raw_name = raw_name.strip() # Извлечь стебли из той же строки: после HTS кода (dddd.dd.dd.dd) идёт stems stems_qty = 0 line_start = text.rfind('\n', 0, match.start()) + 1 line_end = text.find('\n', match.end()) if line_end == -1: line_end = len(text) line = text[line_start:line_end] # Паттерн: HTS_CODE stems price/stem bunches price/bunch hts_match = re.search(r'\d{4}\.\d{2}\.\d{2}\.\d{2}\s+(\d+)\s+\d+\.\d+', line) if hts_match: stems_qty = int(hts_match.group(1)) else: # Фолбэк: после QB FLOWER PREMIUM ...\s+(\d+)\s+0\.\d+ (stems price/stem) tail = line[match.end() - line_start:] tail_match = re.search(r'\s(\d+)\s+0\.\d{2,4}\s+(\d+)\s+0\.\d{2,4}\s+[\d.]+', tail) if tail_match: stems_qty = int(tail_match.group(1)) if raw_name and raw_name.upper() not in found_flowers and len(raw_name) > 2: found_flowers.add(raw_name.upper()) item = InvoiceItem(raw_name=raw_name, boxes_qty=qty, stems_qty=stems_qty) items.append(item) # === Метод 3: Формат SJ Flowers "QTY,QBxNN FLOWER PREMIUM ... B-CLIENT" === # Пример: "0,250 1,00QBx30 HYDRANGEA ASSORTED PREMIUM 0604200000 B-DOMROZ" # Пример SURANDINA: "0,250 1,00QBx30 HYDRANGEA ASSORTED GREEN-PW 0603190125 B-ROUSI" # QBx20 = 20 стеблей на коробку → stems_qty = qty × stems_per_box # Разрешаем дефис и слэш в названии цветка (для "GREEN-PW", "WH/BL/PK" и т.п.) if not items: pattern3 = r'([\d,]+)\s*QBx?(\d+)\s+([A-Z][A-Z\s\-/]+?)(?:\s+PREMIUM|\s+\d{10}|\s+B-)' for match in re.finditer(pattern3, text, re.IGNORECASE | re.MULTILINE): qty_str = match.group(1).replace(',', '.') try: qty = int(float(qty_str)) except: qty = 1 try: stems_per_box = int(match.group(2)) except (ValueError, TypeError): stems_per_box = 0 raw_name = match.group(3).strip() raw_name = re.sub(r'\s*PREMIUM.*$', '', raw_name) raw_name = raw_name.strip() if raw_name and raw_name.upper() not in found_flowers and len(raw_name) > 2: found_flowers.add(raw_name.upper()) item = InvoiceItem(raw_name=raw_name, boxes_qty=qty, stems_qty=qty * stems_per_box) items.append(item) # === Метод 4: Формат Tessa Corporation (многострочный с TOTALS) === # Пример: "1 911753771 QB HYD 60 30 30 $0.78 $23.40" + "HYDRANGEAS" + "3 TOTALS" # Пример с описанием между кодом и Len: "1 911831820 QB GYP JUMBO WHITE 60 20 20 $1.45 $29.00" # Пример 1827: "1 911831827 QB HYD TESSA- ASSORTED-3 60 30 30 $0.78 $23.40" # Пример 1825 фрагментирован (QB и код на соседних строках): # "QB TESSA- JUMBO" / "1 911831825 60 20 20 $1.55 $31.00" / "CARN CO TINTED 60CM" # Колонки: Boxes Order BoxT Loc Len Bun/Box Stems Price Total if not items: # Основной паттерн: допускаем произвольные описательные токены между кодом цветка и Len # Len ограничиваем 2-3 цифрами (обычно 60/70/80/100), Bun/Box и Stems тоже цифры pattern4 = re.compile( r'^\s*(\d+)\s+\d{8,10}\s+QB\s+([A-Z]{2,10})(.*?)\s+(\d{2,3})\s+(\d+)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$', re.MULTILINE ) flower_qty_map: dict[str, dict] = {} # flower_code -> {boxes, stems} matched_line_indices: set = set() # Расшифровка сокращений Tessa flower_map = { 'HYD': 'HYDRANGEA', 'HYDR': 'HYDRANGEA', 'ROS': 'ROSA', 'ROSA': 'ROSA', 'ALST': 'ALSTROEMERIA', 'ALSTRO': 'ALSTROEMERIA', 'CHRYS': 'CHRYSANTHEMUM', 'CHRY': 'CHRYSANTHEMUM', 'CARN': 'CARNATION', 'CARNA': 'CARNATION', 'LIL': 'LILIUM', 'LILY': 'LILIUM', 'TUL': 'TULIP', 'TULIP': 'TULIP', 'GERB': 'GERBERA', 'GYPS': 'GYPSOPHILA', 'GYP': 'GYPSOPHILA', 'GYPSO': 'GYPSOPHILA' } # Полные ключевые слова цветов в описании (приоритетнее чем сокращение в коде) desc_keywords = { 'HYDRANGEA': 'HYDRANGEA', 'HYDRANGEAS': 'HYDRANGEA', 'ROSE': 'ROSA', 'ROSES': 'ROSA', 'ROSA': 'ROSA', 'CARNATION': 'CARNATION', 'CARNATIONS': 'CARNATION', 'ALSTROEMERIA': 'ALSTROEMERIA', 'CHRYSANTHEMUM': 'CHRYSANTHEMUM', 'LILIUM': 'LILIUM', 'LILY': 'LILIUM', 'TULIP': 'TULIP', 'TULIPS': 'TULIP', 'GERBERA': 'GERBERA', 'GYPSOPHILA': 'GYPSOPHILA' } lines_all = text.split('\n') def _find_desc_flower(line_idx: int) -> str | None: """Ищет ключевое слово цветка в описании (±3 строки). Возвращает канон. имя или None.""" for ofs in (0, -1, 1, -2, 2, -3, 3): j = line_idx + ofs if j < 0 or j >= len(lines_all): continue up = lines_all[j].upper() for kw, canon in desc_keywords.items(): # \b границы слова, чтобы CARN не матчился внутри CARNATION и наоборот if re.search(rf'\b{kw}\b', up): return canon return None for match in pattern4.finditer(text): qty = int(match.group(1)) flower_code = match.group(2).strip().upper() try: stems = int(match.group(6)) except (ValueError, TypeError): stems = 0 # Индекс строки, где нашлось совпадение line_idx = text[:match.start()].count('\n') # Приоритет описанию над кодом raw_name = _find_desc_flower(line_idx) or flower_map.get(flower_code, flower_code) key = raw_name.upper() if key not in flower_qty_map: flower_qty_map[key] = {'boxes': 0, 'stems': 0} flower_qty_map[key]['boxes'] += qty flower_qty_map[key]['stems'] += stems matched_line_indices.add(line_idx) # Дополнительный проход: строки с данными без QB-кода на них (фрагментированные) # Формат: "1 911831825 60 20 20 $1.55 $31.00" # Приоритет: описание в ±3 строках, иначе код цветника в ±5 строках lines = lines_all pattern_bare = re.compile( r'^\s*(\d+)\s+\d{8,10}\s+(\d{2,3})\s+(\d+)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$' ) for idx, line in enumerate(lines): if idx in matched_line_indices: continue m = pattern_bare.match(line) if not m: continue qty = int(m.group(1)) try: stems = int(m.group(4)) except (ValueError, TypeError): stems = 0 # 1) Приоритет — описание с полным ключом цветка raw_name = _find_desc_flower(idx) # 2) Fallback — код цветка в ±5 строках if not raw_name: found_code = None for offset in (-1, 1, -2, 2, -3, 3, -4, 4, -5, 5): j = idx + offset if j < 0 or j >= len(lines): continue for tok in re.findall(r'\b([A-Z]{3,6})\b', lines[j]): if tok in flower_map: found_code = tok break if found_code: break if not found_code: continue raw_name = flower_map[found_code] key = raw_name.upper() if key not in flower_qty_map: flower_qty_map[key] = {'boxes': 0, 'stems': 0} flower_qty_map[key]['boxes'] += qty flower_qty_map[key]['stems'] += stems # Создать items из агрегированных данных for flower_name, data in flower_qty_map.items(): if flower_name and len(flower_name) > 2: item = InvoiceItem(raw_name=flower_name, boxes_qty=data['boxes'], stems_qty=data['stems']) items.append(item) found_flowers.add(flower_name.upper()) # === Метод 4.5: Tessa Corporation с обёрнутым описанием === # Когда QB и Loc код перенесены на отдельные строки, а данные ряда выглядят так: # "1 911814177 EMERALD 60 35 35 $1.00 $35.00" # Название цветка ищем среди соседних строк по self.flower_keywords. # Не запускаем для Tessa Kazakhstan HB — там Method 9l справится лучше is_tessa_hb_kz = ('TESSA-' in text) and not bool(re.search(r'^\s*\d+\s+\d{9,10}\s+QB\b', text, re.MULTILINE)) if not items and not is_tessa_hb_kz: lines = text.split('\n') pattern4b = re.compile( r'^\s*(\d+)\s+\d{8,10}\s+[A-Z]+\s+(\d+)\s+(\d+)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$' ) for idx, line in enumerate(lines): m = pattern4b.match(line) if not m: continue qty = int(m.group(1)) try: stems = int(m.group(4)) except (ValueError, TypeError): stems = 0 # Искать название цветка в окне ±8 строк flower_name = None for offset in range(-8, 9): j = idx + offset if j < 0 or j >= len(lines): continue up = lines[j].upper() for kw in self.flower_keywords: if kw in up: flower_name = kw break if flower_name: break if not flower_name: continue key = flower_name.upper() if key in found_flowers: for it in items: if it.raw_name.upper() == key: it.boxes_qty += qty it.stems_qty += stems break else: found_flowers.add(key) items.append(InvoiceItem(raw_name=flower_name, boxes_qty=qty, stems_qty=stems)) # === Метод 5: Простой поиск по ключевым словам цветов === # Не запускаем для Tessa Kazakhstan HB — там Method 9l лучше справится if not items and not is_tessa_hb_kz: for keyword in self.flower_keywords: if keyword in text_upper: pattern = rf'(\d+)\s+(?:QB-?[A-Z0-9]*\s+)?\d*\s*({keyword}[A-Z\s]*?)(?:\s*B-|\s*PO\s|\s*\n|$)' for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE): qty_str = match.group(1) raw_name = match.group(2).strip() raw_name = re.sub(r'\s*PREMIUM.*$', '', raw_name) raw_name = re.sub(r'\s*\d+\s*$', '', raw_name) raw_name = raw_name.strip() if raw_name and raw_name.upper() not in found_flowers: found_flowers.add(raw_name.upper()) item = InvoiceItem(raw_name=raw_name, boxes_qty=int(qty_str)) items.append(item) # === Метод 6: Поиск в блоке DETAILS/DESCRIPTION === if not items: detail_match = re.search( r'DETAILS.*?DESCRIPTION.*?\n(.*?)(?:\n\s*\n|SUBTOTAL|TOTAL)', text, re.IGNORECASE | re.DOTALL ) if detail_match: detail_text = detail_match.group(1) for line in detail_text.split('\n'): line = line.strip() if line and any(kw in line.upper() for kw in self.flower_keywords): if line.upper() not in found_flowers: found_flowers.add(line.upper()) item = InvoiceItem(raw_name=line) items.append(item) # === Метод 7: Формат Superior Blooms / Flores de Serrezuela / LA CONCHITA / Tessa === # Пример: "1 Q CARNATION SELECT L.PINK 25 400 400 0.2000 80.00" # Пример LA CONCHITA: "2 Q ALSTROEMERIA PINK PERFECTION 0603.19.3000 B-AQUA 160 320 0,340 108.80" # Пример Tessa: "2 Q CARNATION SELECT RED 25" if not items: flowers_for_q = ( 'CARNATION|MINICARNATION|ROSA|ROSE|ALSTROEMERIA|CHRYSANTHEMUM|' 'LILIUM|HYDRANGEA|TULIP|GERBERA|GYPSOPHILA|SOLIDAGO|' 'LIMONIUM|ERYNGIUM|DELPHINIUM|DIANTHUS|ORCHID' ) # Общий паттерн: QTY Q FLOWER [слова] (стоп на конце строки, B-CLIENT или числах) pattern7 = rf'(\d+)\s+Q\s+(MINI)?(?:({flowers_for_q}))[ \t]+[\w. ]*?(?:B-\w+[ \t]+)?' # Собираем количества по сопоставленному цветку flower_qty: dict[str, int] = {} for match in re.finditer(pattern7, text, re.IGNORECASE | re.MULTILINE): qty = int(match.group(1)) mini = match.group(2) or "" flower = match.group(3).strip().upper() raw_name = f"{mini}{flower}".strip() # Сопоставить с базой, чтобы агрегировать по одному названию matched = self.flower_db.find_flower(raw_name) key_name = matched.full_name_en if matched else raw_name flower_qty[key_name] = flower_qty.get(key_name, 0) + qty for flower_name, total_qty in flower_qty.items(): if flower_name and len(flower_name) > 2: item = InvoiceItem(raw_name=flower_name, boxes_qty=total_qty) items.append(item) # === Метод 8: Kenya "Mixed roses" (Gatoka, Lauren, Shalimar, Mzurrie) === # Пример Gatoka: "Mixed roses 35CM 1000 1 1000 $ 0.08 $ 80.00 ELA" # Пример Lauren: "1 1000 35CM MIXED ROSES B-NERO 0.08 80.00" if not items: # Gatoka: "Mixed roses 35CM 1000 1 1000 $ 0.08 $ 80.00 CLIENT" pattern8a = r'[Mm]ixed\s+roses?\s+(\d+)\s*[Cc][Mm]\s+(\d+)\s+(\d+)\s+(\d+)\s+\$\s+[\d.]+\s+\$\s+[\d.]+\s+(\S+)' for match in re.finditer(pattern8a, text, re.MULTILINE): length = int(match.group(1)) stems_per_box = int(match.group(2)) boxes = int(match.group(3)) total_stems = int(match.group(4)) client = match.group(5).strip() raw_name = f"MIXED ROSES {length}CM" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Lauren: "BOXES STEMS LENGTHCM MIXED ROSES CLIENT PRICE TOTAL" pattern8b = r'(\d+)\s+(\d+)\s+(\d+)\s*[Cc][Mm]\s+MIXED\s+ROSES?\s+(B-\w+|[A-Z]+)\s+[\d.]+\s+[\d.]+' for match in re.finditer(pattern8b, text, re.MULTILINE): boxes = int(match.group(1)) raw_name = f"MIXED ROSES {match.group(3)}CM" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # === Метод 9: Ecuador roses (Khad, Rosas del Corazon, ALAIA) === # Пример Khad: "1 - 1 B-CAMETON (SO) 1 HALF EXPLORER 60 25 16 400 0.400 160.000" # Пример Rosas: "1 HB CANDLELIGHT 20X .00 2 40 0.40 16.00" # Пример ALAIA: "1 0.50 HB 1 1 EXPLORER 25 60 4 4 100 0,330000 33,00" if not items: # Khad: "N - N B-CLIENT (XX) N HALF/FULL VARIETY LENGTH ... STEMS PRICE TOTAL" pattern9a = r'(\d+)\s+-\s+(\d+)\s+B-\w+\s+\(\w+\)\s+(\d+)\s+(HALF|FULL)\s+(\w+)\s+(\d+)' for match in re.finditer(pattern9a, text, re.IGNORECASE | re.MULTILINE): boxes = int(match.group(1)) box_type = match.group(4).upper() variety = match.group(5).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Метод 9d: Ecuadorian Fresh Growers (EFG) — ECUAFLOR/Santino/Herrera/BellaRosa/ANNE FLOWERS/Muchflowers/La Rosaleda/Gardaexport/GIRON/AGROTERRA # Формат: "<boxes> HB <VARIETY (1..3 слова)> <NN>X .00 <bunches> <stems> <price> <total>" # Пример: "1 HB PINK MONDIAL 25X .00 3 75 0.35 26.25" # Пример: "1 HB PLAYA BLANCA 25X .00 2 50.00 0.40 20.00" pattern9d = re.compile( r'^\s*(\d+)\s+HB\s+([A-Z][A-Z ]+?)\s+(\d+)X\s+\.00\s+(\d+)\s+(\d+(?:\.\d+)?)\s+([\d.]+)\s+([\d.]+)\s*$', re.MULTILINE ) d_matches = list(pattern9d.finditer(text)) for match in d_matches: boxes_d = int(match.group(1)) variety_d = match.group(2).strip().upper() stems_d = int(float(match.group(5))) if variety_d in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE'): continue raw_name_d = f"ROSE {variety_d}" if raw_name_d in found_flowers: for it in items: if it.raw_name == raw_name_d: it.stems_qty += stems_d it.boxes_qty += boxes_d break else: found_flowers.add(raw_name_d) items.append(InvoiceItem(raw_name=raw_name_d, boxes_qty=boxes_d, stems_qty=stems_d)) # Метод 9e: NYCOL ROSES — формат с NNcm # Пример: "1 HB EXPLORER 60cm 25 16 400 0,32 128,00" pattern9e = re.compile( r'^\s*(\d+)\s+HB\s+([A-Z][A-Z ]+?)\s+\d+cm\s+\d+\s+\d+\s+(\d+)\s+[\d,]+\s+[\d,.]+\s*$', re.MULTILINE ) e_matches = list(pattern9e.finditer(text)) for match in e_matches: boxes_e = int(match.group(1)) variety_e = match.group(2).strip().upper() stems_e = int(match.group(3)) if variety_e in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE'): continue raw_name_e = f"ROSE {variety_e}" if raw_name_e not in found_flowers: found_flowers.add(raw_name_e) items.append(InvoiceItem(raw_name=raw_name_e, boxes_qty=boxes_e, stems_qty=stems_e)) # Rosas: "N HB/FB VARIETY SIZE ... STEMS PRICE TOTAL" # Только если 9d и 9e не сработали (иначе даёт обрезанные single-word items) if not d_matches and not e_matches: pattern9b = r'(\d+)\s+(HB|FB)\s+(\w+)\s+(\d+\s*[Xx]|[\w.]+)' for match in re.finditer(pattern9b, text, re.IGNORECASE | re.MULTILINE): boxes = int(match.group(1)) box_type = match.group(2).upper() variety = match.group(3).strip().upper() if variety and len(variety) > 2 and variety not in ('BOX', 'TYPE', 'STEMS'): raw_name = f"ROSE {variety}" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Метод 9f: MIXED BOX индентированные строки (BellaRosa/ANNE FLOWERS/Muchflowers/Qualisa) # Формат заголовка бокса: "1 HB 2 MIXED BOX MIXED BOX 14 $13.4821 350 $0.5392 $188.75" # Формат строк варьете: "BE SWEET 70CM N 25ST BECBE ROSES 1 $17.5000 25 $0.7000 $17.5000" # Формат Muchflowers: "PINK MONDIAL 60CM 25ST CROSAS ROSES Rosaspe 4 $9.5000 100 $0.3800 $38.0000" # Формат Qualisa: "ESPERANCE 60CM P 25ST QUCT ROSES 1 $12.5000 25 $0.5000 $12.5000" pattern9f_box = re.compile( r'^\s*(\d+)\s+(HB|FB|JUMBO\s+HALF\s+BOX)\s+\d*\s*MIXED\s+BOX\s+MIXED\s+BOX\s+(?:\S+\s+)?\d+\s+\$', re.MULTILINE | re.IGNORECASE ) pattern9f_var = re.compile( r'^\s*([A-Z][A-Z ]+?)\s+\d+CM(?:\s+[NPnp])?\s+(?:\d+ST\s+)?[A-Z]+\s+(?:\S+\s+)?ROSES\s+(?:\S+\s+)?(\d+)\s+\$[\d.,]+\s+(\d+)\s+\$[\d.,]+\s+\$[\d.,]+\s*$', re.MULTILINE ) f_box_matches = list(pattern9f_box.finditer(text)) f_var_matches = list(pattern9f_var.finditer(text)) if f_box_matches or f_var_matches: # Считаем общее количество боксов из заголовков for match in f_var_matches: variety_f = match.group(1).strip().upper() stems_f = int(match.group(3)) if variety_f in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'MIXED', 'MIXED BOX', 'SPECIES', 'DESTINATION'): continue raw_name_f = f"ROSE {variety_f}" # Ищем существующую позицию — если есть, добавляем stems existing = None for it in items: if it.raw_name == raw_name_f: existing = it break if existing: existing.stems_qty += stems_f else: found_flowers.add(raw_name_f) # boxes_qty=0 — распределится позже пропорционально items.append(InvoiceItem(raw_name=raw_name_f, boxes_qty=0, stems_qty=stems_f)) # Распределяем boxes по варьете пропорционально stems total_boxes_9f = sum(int(m.group(1)) for m in f_box_matches) new_items_9f = [it for it in items if it.raw_name.startswith('ROSE ') and it.boxes_qty == 0 and it.stems_qty > 0] if total_boxes_9f > 0 and new_items_9f: total_stems_9f = sum(it.stems_qty for it in new_items_9f) if total_stems_9f > 0: allocated = 0 min_box = 1 if total_boxes_9f >= len(new_items_9f) else 0 for i, it in enumerate(new_items_9f): if i < len(new_items_9f) - 1: it.boxes_qty = max(min_box, round(total_boxes_9f * it.stems_qty / total_stems_9f)) allocated += it.boxes_qty else: it.boxes_qty = max(0, total_boxes_9f - allocated) # Метод 9g: La Rosaleda — многосортовые боксы JB/QB ROSALEDA # Формат: "1 - 1 B-ROUSI 1 JB ROSALEDA EXPLORER 60 25 16 400 0.38 152.00" # Продолжение: "MONDIAL 70 25 7 175 0.43 75.25" pattern9g_main = re.compile( r'^\s*\d+\s+-\s+\d+\s+B-\w+\s+(\d+)\s+(?:JB|QB|HB|FB)\s+ROSALEDA\s+([A-Z][A-Z ]+?)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) pattern9g_cont = re.compile( r'^\s*([A-Z][A-Z ]+?)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) g_main_matches = list(pattern9g_main.finditer(text)) if g_main_matches: boxes_pool_g = 0 for m in g_main_matches: boxes_pool_g += int(m.group(1)) variety_g = m.group(2).strip().upper() stems_g = int(m.group(3)) raw_g = f"ROSE {variety_g}" if raw_g not in found_flowers: found_flowers.add(raw_g) items.append(InvoiceItem(raw_name=raw_g, boxes_qty=0, stems_qty=stems_g)) else: for it in items: if it.raw_name == raw_g: it.stems_qty += stems_g break # Строки-продолжения (без префикса box) for m in pattern9g_cont.finditer(text): variety_c = m.group(1).strip().upper() stems_c = int(m.group(2)) if variety_c in ('TOTAL', 'BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'FCA', 'JB ROSALEDA', 'QB ROSALEDA'): continue raw_c = f"ROSE {variety_c}" if raw_c in found_flowers: for it in items: if it.raw_name == raw_c: it.stems_qty += stems_c break else: found_flowers.add(raw_c) items.append(InvoiceItem(raw_name=raw_c, boxes_qty=0, stems_qty=stems_c)) # Распределяем boxes if boxes_pool_g > 0: new_items_g = [it for it in items if it.raw_name.startswith('ROSE ') and it.boxes_qty == 0 and it.stems_qty > 0] total_stems_g = sum(it.stems_qty for it in new_items_g) if new_items_g and total_stems_g > 0: allocated = 0 min_box_g = 1 if boxes_pool_g >= len(new_items_g) else 0 for i, it in enumerate(new_items_g): if i < len(new_items_g) - 1: it.boxes_qty = max(min_box_g, round(boxes_pool_g * it.stems_qty / total_stems_g)) allocated += it.boxes_qty else: it.boxes_qty = max(0, boxes_pool_g - allocated) # Метод 9h: Gardaexport S.A. — "H ROSAS (ROSOIDEA) <Color> <Variety> <NN> <bunches> <stems> <price> <total>" # Пример: "H ROSAS (ROSOIDEA) Red Explorer 70 28 700 0,50 350,00" # Первая строка с префиксом бокса: "01 - 02 H ROSAS (ROSOIDEA) Red Explorer 70 28 700 0,50 350,00" pattern9h = re.compile( r'^\s*(?:\d+\s*-\s*\d+\s+)?H\s+ROSAS\s+\(ROSOIDEA\)\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?\s+([A-Z][A-Za-z]*(?:\s+[A-Z][A-Za-z\'\`]+)*)\s+\d+\s+\d+\s+(\d+)\s+[\d,]+\s+[\d,.]+\s*$', re.MULTILINE ) h_matches = list(pattern9h.finditer(text)) if h_matches: for m in h_matches: variety_h = m.group(1).strip().upper() stems_h = int(m.group(2)) if variety_h in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'ROSAS', 'ROSOIDEA'): continue raw_h = f"ROSE {variety_h}" if raw_h in found_flowers: for it in items: if it.raw_name == raw_h: it.stems_qty += stems_h it.boxes_qty += 1 break else: found_flowers.add(raw_h) items.append(InvoiceItem(raw_name=raw_h, boxes_qty=1, stems_qty=stems_h)) # Метод 9i: FLOREQUISA — "[box_num]? Q <VARIETY> SELECT <flag> <bunches> <stems/bunch> <stems> <price> <total>" # Пример: "01 Q CHERRY TESSINO SELECT 0 5 10 50 0,180 9,000" # Пример продолжения: " Q IMAGINE SELECT 0 5 10 50 0,180 9,000" pattern9i = re.compile( r'^\s*(?:\d+\s+)?Q\s+([A-Z][A-Z ]+?)\s+SELECT\s+\d+\s+\d+\s+\d+\s+(\d+)\s+[\d,.]+\s+[\d,.]+\s*$', re.MULTILINE ) i_matches = list(pattern9i.finditer(text)) if i_matches: for m in i_matches: variety_i = m.group(1).strip().upper() stems_i = int(m.group(2)) if variety_i in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL'): continue raw_i = f"ROSE {variety_i}" if raw_i in found_flowers: for it in items: if it.raw_name == raw_i: it.stems_qty += stems_i break else: found_flowers.add(raw_i) items.append(InvoiceItem(raw_name=raw_i, boxes_qty=0, stems_qty=stems_i)) # Распределяем boxes из "TOTAL CAJAS Q: N" или подсчитаем по префиксам boxes_pool_i = 0 for tm in re.finditer(r'TOTAL\s+CAJAS\s+[A-Z]:\s*(\d+)', text, re.IGNORECASE): boxes_pool_i += int(tm.group(1)) if boxes_pool_i == 0: # Считаем префиксы 01/02/... в начале Q-строк boxes_pool_i = len(list(re.finditer(r'^\s*\d+\s+Q\s+[A-Z]', text, re.MULTILINE))) new_items_i = [it for it in items if it.raw_name.startswith('ROSE ') and it.boxes_qty == 0 and it.stems_qty > 0] if boxes_pool_i > 0 and new_items_i: total_stems_i = sum(it.stems_qty for it in new_items_i) if total_stems_i > 0: allocated = 0 for j, it in enumerate(new_items_i): if j < len(new_items_i) - 1: it.boxes_qty = max(1, round(boxes_pool_i * it.stems_qty / total_stems_i)) allocated += it.boxes_qty else: it.boxes_qty = max(1, boxes_pool_i - allocated) # Метод 9j: AGROTERRA-NORTE — "HB N - N B - <label> <VARIETY> 25 <bun> <bun> <stems> <price> <total>" # Пример заголовка бокса: "HB 11 - 1 B - Dryros MONDIAL 25 3 3 75 0.60 45.00" # Пример без B-лейбла: "HB 12 - 2 EXPLORER 25 12 12 300 0.45 135.00" # Продолжение varieties в боксе: "PANDORA 25 2 2 50 0.30 15.00" # Продолжение без name (same-variety diff-length): "25 4 4 100 0.70 70.00" # Слипшиеся лейблы: "B - ORHIDEYHAOT SPOT" → "HOT SPOT" (strip ORHIDEYA/DRYROS/ROSSOH/…) # Извлекаем consignee label из верха PDF для fuzzy-стрипа consignee_label = None cm = re.search(r'CONSIGNEE[^\n]*?B\s*-\s*(\w+)', text, re.IGNORECASE) if cm: consignee_label = cm.group(1).upper() # Смешанный список: все возможные консигни-коды + так-называемые типо-варианты agroterra_labels = ['ORHIDEYA', 'ORHIDEY', 'DRYROS', 'ROSSOH', 'ROUSI', 'ORHI', 'TEPLO', 'TATIANA', 'KHAR', 'HERO'] if consignee_label and consignee_label not in agroterra_labels: agroterra_labels.insert(0, consignee_label) def _strip_agroterra_label(name: str) -> str: up = name.strip().upper() if up.startswith('B - '): up = up[4:].strip() elif up.startswith('B -'): up = up[3:].strip() # Фаза 0: известные глюки слияния label+variety (с typo/swap) merged_fixups = { 'ORHIDEYHAOT SPOT': 'HOT SPOT', 'ORHIDEYHAOT': 'HOT', 'ROSSOHCANDLELIGHT': 'CANDLELIGHT', 'DRYROSMONDIAL': 'MONDIAL', 'DRYROSEXPLORER': 'EXPLORER', } for k, v in merged_fixups.items(): if up.startswith(k): return (v + up[len(k):]).strip() # Фаза 1: точный prefix + space for lbl in agroterra_labels: if up.startswith(lbl + ' ') and len(up) > len(lbl) + 2: return up[len(lbl):].strip() # Фаза 2: точный prefix без space (макс label глюе-strip) — сравниваем все labels, берем самый длинный подходящий best_strip = None for lbl in sorted(agroterra_labels, key=len, reverse=True): if up.startswith(lbl) and len(up) > len(lbl) + 2: rest = up[len(lbl):].strip() if rest and rest[0].isalpha(): best_strip = rest break if best_strip: return best_strip return up # Паттерн заголовка: берем всё между "HB N - N" и " 25 ", включая B-label если есть pattern9j_header = re.compile( r'^\s*HB\s+\d+\s+-\s+\d+\s+((?:B\s*-\s*)?[A-Za-z][A-Za-z\s\xb4\-]+?)\s+25\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) pattern9j_cont = re.compile( r'^\s*([A-Z][A-Z\s\xb4\-]+?)\s+25\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) # Чисто-числовые continuation строки (same variety, diff length): "25 4 4 100 0.70 70.00" pattern9j_num = re.compile( r'^\s*25\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) j_header_matches = list(pattern9j_header.finditer(text)) if j_header_matches: agro_box_count = len(j_header_matches) agro_matched_positions = set() # Собираем все матчи (заголовки + continuation + числовые) в порядке появления all_matches: list[tuple[int, str, str, int]] = [] # (pos, kind, variety, stems) for m in j_header_matches: variety_j = _strip_agroterra_label(m.group(1)).strip().upper() stems_j = int(m.group(2)) agro_matched_positions.add(m.start()) all_matches.append((m.start(), 'header', variety_j, stems_j)) for m in pattern9j_cont.finditer(text): if m.start() in agro_matched_positions: continue line = text[m.start():m.end()] if line.strip().startswith('HB '): continue variety_c = m.group(1).strip().upper() stems_c = int(m.group(2)) all_matches.append((m.start(), 'cont', variety_c, stems_c)) for m in pattern9j_num.finditer(text): stems_n = int(m.group(1)) all_matches.append((m.start(), 'num', '', stems_n)) # Сортируем по позиции и обрабатываем по очереди all_matches.sort(key=lambda x: x[0]) last_variety = None for pos, kind, variety, stems in all_matches: if kind == 'num': # Добавляем stems к последней variety if last_variety: raw_last = f"ROSE {last_variety}" for it in items: if it.raw_name == raw_last: it.stems_qty += stems break continue if not variety or variety in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'ROSES', 'FCA'): continue raw_j = f"ROSE {variety}" if raw_j in found_flowers: for it in items: if it.raw_name == raw_j: it.stems_qty += stems break else: found_flowers.add(raw_j) items.append(InvoiceItem(raw_name=raw_j, boxes_qty=0, stems_qty=stems)) last_variety = variety # Распределяем боксы пропорционально stems new_items_j = [it for it in items if it.raw_name.startswith('ROSE ') and it.boxes_qty == 0 and it.stems_qty > 0] if agro_box_count > 0 and new_items_j: total_stems_j = sum(it.stems_qty for it in new_items_j) if total_stems_j > 0: allocated = 0 min_box_j = 1 if agro_box_count >= len(new_items_j) else 0 for i, it in enumerate(new_items_j): if i < len(new_items_j) - 1: it.boxes_qty = max(min_box_j, round(agro_box_count * it.stems_qty / total_stems_j)) allocated += it.boxes_qty else: it.boxes_qty = max(0, agro_box_count - allocated) # Метод 9k: Qualisa с одной варьете в боксе (без MIXED BOX) # Пример: "1 HB 3 ESPERANCE 60CM P 25ST QUCT ROSES 16 $12.5000 400 $0.5000 $200.00 31.50 0.00" # Допускаем до 4 хвостовых числовых колонок (volume/weight) pattern9k = re.compile( r'^\s*(\d+)\s+(HB|FB)\s+\d+\s+([A-Z][A-Z ]+?)\s+\d+CM(?:\s+[NPnp])?\s+\d+ST\s+[A-Z]+\s+ROSES\s+(?:\S+\s+)?\d+\s+\$[\d.,]+\s+(\d+)\s+\$[\d.,]+\s+\$[\d.,]+(?:\s+[\d.,]+){0,4}\s*$', re.MULTILINE ) k_matches = list(pattern9k.finditer(text)) for m in k_matches: boxes_k = int(m.group(1)) variety_k = m.group(3).strip().upper() stems_k = int(m.group(4)) if variety_k in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'MIXED', 'MIXED BOX'): continue raw_k = f"ROSE {variety_k}" if raw_k in found_flowers: for it in items: if it.raw_name == raw_k: it.stems_qty += stems_k it.boxes_qty += boxes_k break else: found_flowers.add(raw_k) items.append(InvoiceItem(raw_name=raw_k, boxes_qty=boxes_k, stems_qty=stems_k)) # Метод 9n: FLORAL SCENT (ex HALI ROSES) — "# BOX BOX T VARIEDAD STxB OTR ... T UNIT." # Пример: "1 HB EXPLORER 25 X 10 10 250 0,40 100,00" # Формат: <#> <HB|FB|QB|EB> <VARIETY> <STxB> X <cnt> <cnt> <total_stems> <price_eu> <total_eu> pattern9n = re.compile( r'^\s*(\d+)\s+(?:HB|FB|QB|EB)\s+([A-Z][A-Z ]+?)\s+\d+\s+X\s+(?:\d+\s+){1,10}(\d+)\s+[\d,]+\s+[\d,]+\s*$', re.MULTILINE ) n_matches = list(pattern9n.finditer(text)) for m in n_matches: boxes_n = int(m.group(1)) variety_n = m.group(2).strip().upper() stems_n = int(m.group(3)) if variety_n in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'ROSES', 'VARIEDAD'): continue raw_n = f"ROSE {variety_n}" if raw_n in found_flowers: for it in items: if it.raw_name == raw_n: it.stems_qty += stems_n it.boxes_qty += boxes_n break else: found_flowers.add(raw_n) items.append(InvoiceItem(raw_name=raw_n, boxes_qty=boxes_n, stems_qty=stems_n)) # Метод 9o: KURARE / ROSADEX — "1 - 1 AKD 1 HB CANDLELIGHT 40 25 1 25 0.300 7.500" # Продолжение: "CANDLELIGHT 50 25 1 25 0.300 7.500" и "EXPLORER 50 25 4 100 0.250 25.000" # Формат: <VARIETY> <LEN> <STEMS/BUNCH> <BUNCHES/BOX> <STEMS/BOX> <PRICE> <TOTAL> pattern9o_head = re.compile( r'^\s*\d+\s+-\s+\d+\s+[A-Z]{2,5}\s+(\d+)\s+(?:HB|FB|QB|EB)\s+([A-Z][A-Z \'\-\.]+?)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) pattern9o_cont = re.compile( r'^\s*([A-Z][A-Z \'\-\.]+?)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) o_head_matches = list(pattern9o_head.finditer(text)) if o_head_matches: o_matched_positions = set() o_total_boxes = 0 for m in o_head_matches: o_matched_positions.add(m.start()) o_total_boxes += int(m.group(1)) variety_o = m.group(2).strip().upper() stems_o = int(m.group(3)) if variety_o in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'ROSES', 'FOB'): continue raw_o = f"ROSE {variety_o}" if raw_o in found_flowers: for it in items: if it.raw_name == raw_o: it.stems_qty += stems_o break else: found_flowers.add(raw_o) items.append(InvoiceItem(raw_name=raw_o, boxes_qty=0, stems_qty=stems_o)) # Continuation rows for m in pattern9o_cont.finditer(text): if m.start() in o_matched_positions: continue variety_c = m.group(1).strip().upper() stems_c = int(m.group(2)) # skip common noise if variety_c in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'TOTAL FOB', 'ROSES', 'FOB', 'HB', 'FB', 'QB'): continue # Игнорируем если выглядит как заголовок таблицы if 'BUNCH' in variety_c or 'PRICE' in variety_c: continue raw_c = f"ROSE {variety_c}" if raw_c in found_flowers: for it in items: if it.raw_name == raw_c: it.stems_qty += stems_c break else: found_flowers.add(raw_c) items.append(InvoiceItem(raw_name=raw_c, boxes_qty=0, stems_qty=stems_c)) # Распределяем боксы пропорционально if o_total_boxes > 0: new_items_o = [it for it in items if it.raw_name.startswith('ROSE ') and it.boxes_qty == 0 and it.stems_qty > 0] total_stems_o = sum(it.stems_qty for it in new_items_o) if new_items_o and total_stems_o > 0: allocated = 0 min_b_o = 1 if o_total_boxes >= len(new_items_o) else 0 for i, it in enumerate(new_items_o): if i < len(new_items_o) - 1: it.boxes_qty = max(min_b_o, round(o_total_boxes * it.stems_qty / total_stems_o)) allocated += it.boxes_qty else: it.boxes_qty = max(0, o_total_boxes - allocated) # Метод 9p: Qualisa 2026 — Standing/Open Order (mixed SOLID BOX или inline single-variety) # Формат detail: "ROSES PINK FLOYD N 25ST QUCT 60 6 150 0.4000 60.00" # Inline single: "Standing 1 HB2 ROSES EXPLORER N 25ST QUCT 70 12 300 0.4500 135.00" # Open Market: "Open 1 HB2 ROSES EXPLORER N 25ST QUCT 60 14 350 0.3000 105.00" # Alstro (wrap): "ALSTROEMERI PRIMADONNA N 10ST QUCT 80 4 40 0.3200 12.80" + "A" на след строке # Заголовки mixed (SOLID BOX/MIXED BOX) не матчатся regex варьете pattern9p_variety = re.compile( r'^\s*(?:(?:Standing|Open)\s+\d+\s+\S+\s+)?(ROSES|ALSTROEMERI[A]?)\s+([A-Z][A-Z0-9 \'\-\.]+?)\s+[NPnp]\s+\d+ST\s+[A-Z]+\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) pattern9p_standing = re.compile(r'^\s*(?:Standing|Open)\s+(\d+)\s+[A-Z]{1,3}\d?\s+', re.MULTILINE) p_matches = list(pattern9p_variety.finditer(text)) if p_matches: p_stem_by_key: dict[tuple[str, str], int] = {} # (species_prefix, variety) -> stems p_order: list[tuple[str, str]] = [] for m in p_matches: species_raw = m.group(1).upper() # ALSTROEMERI/ALSTROEMERIA -> ALSTROEMERIA prefix species_pref = 'ALSTROEMERIA' if species_raw.startswith('ALSTROEMERI') else 'ROSE' variety_p = m.group(2).strip().upper() stems_p = int(m.group(3)) if variety_p in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'SOLID', 'SOLID BOX', 'MIXED', 'MIXED BOX'): continue key = (species_pref, variety_p) if key not in p_stem_by_key: p_order.append(key) p_stem_by_key[key] = 0 p_stem_by_key[key] += stems_p p_total_boxes = sum(int(m.group(1)) for m in pattern9p_standing.finditer(text)) total_stems_p = sum(p_stem_by_key.values()) if p_stem_by_key and total_stems_p > 0: added_p: list[InvoiceItem] = [] for key in p_order: species_pref, variety_p = key stems_p = p_stem_by_key[key] raw_p = f"{species_pref} {variety_p}" if raw_p in found_flowers: for it in items: if it.raw_name == raw_p: it.stems_qty += stems_p added_p.append(it) break else: found_flowers.add(raw_p) it = InvoiceItem(raw_name=raw_p, boxes_qty=0, stems_qty=stems_p) items.append(it) added_p.append(it) if p_total_boxes > 0 and added_p: min_b_p = 1 if p_total_boxes >= len(added_p) else 0 allocated_p = 0 for i, it in enumerate(added_p): # отделяем species prefix от variety чтобы найти ключ it_key: tuple[str, str] | None = None for k in p_stem_by_key: if f"{k[0]} {k[1]}" == it.raw_name: it_key = k break if it_key is None: continue if i < len(added_p) - 1: add_b = max(min_b_p, round(p_total_boxes * p_stem_by_key[it_key] / total_stems_p)) it.boxes_qty += add_b allocated_p += add_b else: it.boxes_qty += max(0, p_total_boxes - allocated_p) # Метод 9q: MARILYN ROSES / FLORAROMA / CAPITAL / MEGA FRUITS / FLORICOLA / INROSES # Колонки: BOXES ORDER BOX_TYPE [MARK] [QUAL] BUN DESCRIPTION LEN S/BT STEMS PRICE TOTAL # 9q-a MARILYN c MARK: "1 1 - 1 QB b-CHIPANDALE 4 Esperance 70 25 100 0.440 44.000" # 9q-a2 FLORAROMA + QUAL: "1 1 - 1 HB E 20 Mondial 50 25 500 0.150 75.000" # 9q-b без MARK: "1 1 - 1 QB 1 Explorer 60 25 25 0.370 9.250" (Capital, MARILYN FUAD) # 9q-c glued BUN+VAR: "1 1 - 1 QB 4MOODY BLUES 60 25 100 0.2400 24.00" (Ecuagarden) # 9q-d glued box+type: "1 - 1 1HB EXPLORER 50 25 12 300 0.1600 48.00" (Mega Fruits) # 9q-e Floricola/Inroses с HB [XXL]: "1 - 1 1 HB XXL OPALA 50 25 11 275 0.18 49.50" # 9q-f HALF+[EXP]: "1 - 1 1 HALF CHERRY O [EXP] 60 25 1 25 0.33 8.25" (Inroses header) # 9q-cont: продолжение без header: "4 Melon X-pression 50 25 100 0.380 38.000" # 9q-cont-exp: "EXPLORER [EXP] 60 25 6 150 0.33 49.50" (Inroses cont) # 9q-cont-plain: "OPALA 50 25 7 175 0.18 31.50" (Floricola cont) pattern9q_a = re.compile( r'^\s*(\d+)\s+\d+\s+-\s+\d+\s+(HB|QB|FB|EB)\s+\S+\s+(\d+)\s+([A-Z][A-Za-z0-9 \'\-\.]+?)\s+(\d{2,3})\s+25\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_b = re.compile( r'^\s*(\d+)\s+\d+\s+-\s+\d+\s+(HB|QB|FB|EB)\s+(\d+)\s+([A-Z][A-Za-z0-9 \'\-\.]+?)\s+(\d{2,3})\s+25\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_c = re.compile( r'^\s*(\d+)\s+\d+\s+-\s+\d+\s+(HB|QB|FB|EB)\s+(\d+)([A-Z][A-Z ]+?)\s+(\d{2,3})\s+25\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_d = re.compile( r'^\s*\d+\s+-\s+\d+\s+(\d+)(HB|QB|FB|EB)\s+([A-Z][A-Za-z0-9 \'\-\.]+?)\s+(\d{2,3})\s+25\s+\d+\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_e = re.compile( r'^\s*\d+\s+-\s+\d+\s+(\d+)\s+(HB|QB|FB|EB)(?:\s+XXL|\s+XL)?\s+([A-Z][A-Za-z0-9 \'\-\.]+?)\s+(\d{2,3})\s+25\s+\d+\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_f = re.compile( r'^\s*\d+\s+-\s+\d+\s+(\d+)\s+(HALF|FULL|QUARTER|EIGHTH)\s+([A-Z][A-Za-z0-9 \-\.]+?)\s+\[[A-Z]+\]\s+(\d{2,3})\s+25\s+\d+\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_cont = re.compile( r'^\s*(\d+)\s+([A-Z][A-Za-z0-9 \-\.]+?)\s+(\d{2,3})\s+25\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_cont_exp = re.compile( r'^\s*([A-Z][A-Za-z0-9 \-\.]+?)\s+\[[A-Z]+\]\s+(\d{2,3})\s+25\s+\d+\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) pattern9q_cont_plain = re.compile( r'^\s*([A-Z][A-Za-z0-9 \-\.]+?)\s+(\d{2,3})\s+25\s+\d+\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) # Собираем все header-матчи (позиции) чтобы избежать двойного учёта в cont q_header_positions: set[int] = set() q_total_boxes_extra = 0 def _q_process(m: re.Match, boxes_group: int, variety_group: int, stems_group: int) -> tuple[str, int, int] | None: variety = m.group(variety_group).strip().upper() if variety in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'DESCRIPTION', 'MARK', 'BUN', 'BUNCH', 'TOTALS'): return None boxes = int(m.group(boxes_group)) if boxes_group else 0 stems = int(m.group(stems_group)) return (variety, boxes, stems) def _q_add(variety: str, boxes: int, stems: int) -> None: raw = f"ROSE {variety}" if raw in found_flowers: for it in items: if it.raw_name == raw: it.stems_qty += stems it.boxes_qty += boxes return found_flowers.add(raw) items.append(InvoiceItem(raw_name=raw, boxes_qty=boxes, stems_qty=stems)) # 9q-a (с MARK): boxes(1), variety(4), stems(6) for m in pattern9q_a.finditer(text): r = _q_process(m, 1, 4, 6) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-b (без MARK): boxes(1), variety(4), stems(6) for m in pattern9q_b.finditer(text): if m.start() in q_header_positions: continue r = _q_process(m, 1, 4, 6) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-c (glued BUN+VAR): boxes(1), variety(4), stems(6) for m in pattern9q_c.finditer(text): if m.start() in q_header_positions: continue r = _q_process(m, 1, 4, 6) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-d (Mega Fruits): boxes(1), variety(3), stems(5) for m in pattern9q_d.finditer(text): if m.start() in q_header_positions: continue r = _q_process(m, 1, 3, 5) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-e (Floricola/Inroses HB XXL): boxes(1), variety(3), stems(5) for m in pattern9q_e.finditer(text): if m.start() in q_header_positions: continue r = _q_process(m, 1, 3, 5) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-f (HALF+[EXP] Inroses): boxes(1), variety(3), stems(5) for m in pattern9q_f.finditer(text): if m.start() in q_header_positions: continue r = _q_process(m, 1, 3, 5) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-cont-exp: variety(1), stems(3) — continuation внутри [EXP]-группы for m in pattern9q_cont_exp.finditer(text): if m.start() in q_header_positions: continue r = _q_process(m, 0, 1, 3) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-cont (bunches+variety continuation): variety(2), stems(4) for m in pattern9q_cont.finditer(text): if m.start() in q_header_positions: continue # только если уже есть hits от 9q-a/b/c/d/e/f (чтобы не матчить чужие форматы) if not q_header_positions: continue r = _q_process(m, 0, 2, 4) if r: _q_add(*r) q_header_positions.add(m.start()) # 9q-cont-plain (продолжение без bunches, Floricola): variety(1), stems(3) for m in pattern9q_cont_plain.finditer(text): if m.start() in q_header_positions: continue if not q_header_positions: continue r = _q_process(m, 0, 1, 3) if r: _q_add(*r) q_header_positions.add(m.start()) # Метод 9r: Muchflowers Group (Ecuador) — с species inline # Пример: "1 QB 3 ALSTRO PINK 80CM 10ST CMUCH ALSTROEMERIA Cotacachi Exportcalas 16 $2.6000 160 $0.2600 $41.60" # Структура: <boxes> <box_type> <#> <VARIETY> <CM>CM <ST>ST <farm_code> <SPECIES> <origin...> <bunches> $<b_price> <stems> $<s_price> $<total> pattern9r = re.compile( r'^\s*(\d+)\s+(HB|QB|FB|EB)\s+\d+\s+([A-Z][A-Z ]+?)\s+\d+CM\s+\d+ST\s+\S+\s+(ROSES?|ALSTROEMERIA|GYPSOPHILA|CARNATION|CHRYSANTHEMUM|HYDRANGEA|LIMONIUM|LILIUM|HYPERICUM|CRASPEDIA|EUCALYPTUS|STATICE|ANTHURIUM|MATRICARIA|DELPHINIUM|BOUVARDIA)\s+.+?\s+\d+\s+\$[\d.]+\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$', re.MULTILINE ) r_matches = list(pattern9r.finditer(text)) if r_matches: for m in r_matches: boxes_r = int(m.group(1)) variety_r = m.group(3).strip().upper() species_r = m.group(4).upper() stems_r = int(m.group(5)) if variety_r in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL'): continue # species prefix normalization if species_r.startswith('ROSE'): prefix_r = 'ROSE' elif species_r == 'ALSTROEMERIA': prefix_r = 'ALSTROEMERIA' else: prefix_r = species_r raw_r = f"{prefix_r} {variety_r}" if raw_r in found_flowers: for it in items: if it.raw_name == raw_r: it.stems_qty += stems_r it.boxes_qty += boxes_r break else: found_flowers.add(raw_r) items.append(InvoiceItem(raw_name=raw_r, boxes_qty=boxes_r, stems_qty=stems_r)) # Метод 9s: Mystic Flowers (Ecuador) — запятая как десятичный разделитель # Колонки: BOX_N TB BoxCode VARIETY PESO CANTID BUNCHE LENGT STEMS PRICE/UNIT TOTAL # Пример first row: "1 H LIMONADA 2 25 70 50 0,045 2,250" # Пример continuation: "H TNT BARBIE PINK MO 3 25 60 75 0,700 52,500" # Группа: box#=1, type=H, variety=LIMONADA, bunches=2, sbt=25, len=70, stems=50, price=0.045, total=2.250 pattern9s = re.compile( r'^\s*(?:\d+\s+)?([HQFE])\s+([A-Z][A-Za-z0-9 \'\-\.]+?)\s+(\d+)\s+(\d+)\s+(\d{2,3})\s+(\d+)\s+[\d,.]+\s+[\d,.]+\s*$', re.MULTILINE ) has_mystic = 'MYSTICFLOWERS' in text.upper() or 'TOTAL CAJAS' in text if has_mystic: s_matches = list(pattern9s.finditer(text)) for m in s_matches: variety_s = m.group(2).strip().upper() stems_s = int(m.group(6)) if variety_s in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'CAJAS', 'CANTID', 'BUNCHE', 'LENGT'): continue raw_s = f"ROSE {variety_s}" if raw_s in found_flowers: for it in items: if it.raw_name == raw_s: it.stems_qty += stems_s break else: found_flowers.add(raw_s) items.append(InvoiceItem(raw_name=raw_s, boxes_qty=0, stems_qty=stems_s)) # Боксы: сумма по TOTAL CAJAS X: N s_total_boxes = sum(int(bm.group(1)) for bm in re.finditer(r'TOTAL CAJAS [HQFE]:\s*(\d+)', text)) if s_total_boxes > 0 and s_matches: # Собираем items без боксов (созданные этим методом) s_new_items = [it for it in items if it.boxes_qty == 0 and it.stems_qty > 0] total_stems_s = sum(it.stems_qty for it in s_new_items) if s_new_items and total_stems_s > 0: allocated_s = 0 min_b_s = 1 if s_total_boxes >= len(s_new_items) else 0 for i, it in enumerate(s_new_items): if i < len(s_new_items) - 1: it.boxes_qty = max(min_b_s, round(s_total_boxes * it.stems_qty / total_stems_s)) allocated_s += it.boxes_qty else: it.boxes_qty = max(0, s_total_boxes - allocated_s) # Метод 9t: ADRIFLOR (Ecuador) — простая таблица box/type/variety # Пример: "1 QB MANDALA 25 4 100 0.24 24.00 H" # Формат: <boxes> <HB|QB|FB|EB> <VARIETY> <spb> <bunches> <stems> <price> <total> [flag] has_adriflor = 'ADRIFLOR' in text.upper() if has_adriflor: pattern9t = re.compile( r'^\s*(\d+)\s+(HB|QB|FB|EB)\s+([A-Z][A-Za-z0-9 \-\.]+?)\s+\d+\s+\d+\s+(\d+)\s+[\d.,]+\s+[\d.,]+(?:\s+[A-Z])?\s*$', re.MULTILINE ) for m in pattern9t.finditer(text): variety_t = m.group(3).strip().upper() if variety_t in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'MANDALA', 'TXB'): # 'MANDALA' здесь оставим как валидное имя if variety_t == 'MANDALA': pass else: continue boxes_t = int(m.group(1)) stems_t = int(m.group(4)) raw_t = f"ROSE {variety_t}" if raw_t in found_flowers: for it in items: if it.raw_name == raw_t: it.stems_qty += stems_t it.boxes_qty += boxes_t break else: found_flowers.add(raw_t) items.append(InvoiceItem(raw_name=raw_t, boxes_qty=boxes_t, stems_qty=stems_t)) # Метод 9u: Anni Roses (Ecuador) — header + continuation формат # Header: "1 HB 1 - 1 0603.11.006 . Roses Pink Explorer 60 25 6 150 0.45 67.50" # Cont: "0603.11.006 . Roses Pink Explorer 70 25 8 200 0.55 110.00" has_anni = 'ANNIROSES' in text.upper() or 'anniroses' in text.lower() or 'brezza' in text.lower() if has_anni: pattern9u_head = re.compile( r'^\s*(\d+)\s+(HB|QB|FB|EB)\s+\d+\s+-\s+\d+\s+[\d.]+\s+\.\s+Roses\s+([A-Za-z][A-Za-z0-9 \-]+?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) pattern9u_cont = re.compile( r'^\s*[\d.]+\s+\.\s+Roses\s+([A-Za-z][A-Za-z0-9 \-]+?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) u_hits = 0 for m in pattern9u_head.finditer(text): boxes_u = int(m.group(1)) variety_u = m.group(3).strip().upper() stems_u = int(m.group(5)) raw_u = f"ROSE {variety_u}" if raw_u in found_flowers: for it in items: if it.raw_name == raw_u: it.stems_qty += stems_u it.boxes_qty += boxes_u break else: found_flowers.add(raw_u) items.append(InvoiceItem(raw_name=raw_u, boxes_qty=boxes_u, stems_qty=stems_u)) u_hits += 1 if u_hits: for m in pattern9u_cont.finditer(text): variety_u = m.group(1).strip().upper() stems_u = int(m.group(3)) raw_u = f"ROSE {variety_u}" if raw_u in found_flowers: for it in items: if it.raw_name == raw_u: it.stems_qty += stems_u break else: found_flowers.add(raw_u) items.append(InvoiceItem(raw_name=raw_u, boxes_qty=0, stems_qty=stems_u)) # Метод 9v: Arequipa Farms (Ecuador) — CARN с запятой как десятичный # Пример: "1 QB CARN WHITE 60 16 25 400 0,090 36,000$" # Формат: <boxes> <HB|QB|FB|EB> <SPECIES> <VARIETY> <len> <bunches> <spb> <stems> <price> <total>$ has_arequipa = 'AREQUIPA' in text.upper() if has_arequipa: pattern9v = re.compile( r'^\s*(\d+)\s+(HB|QB|FB|EB)\s+([A-Z]+)\s+([A-Z][A-Za-z ]+?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d,.]+\s+[\d,.]+\$?\s*$', re.MULTILINE ) for m in pattern9v.finditer(text): boxes_v = int(m.group(1)) species_v = m.group(3).strip().upper() variety_v = m.group(4).strip().upper() stems_v = int(m.group(6)) if species_v in ('BOX', 'TYPE', 'CAJAS', 'TOTAL', 'PIECES'): continue # Развернуть аббревиатуру: CARN → CARNATION species_full = {'CARN': 'CARNATION', 'ROS': 'ROSE', 'GYPS': 'GYPSOPHILA', 'ALSTRO': 'ALSTROEMERIA'}.get(species_v, species_v) raw_v = f"{species_full} {variety_v}" if raw_v in found_flowers: for it in items: if it.raw_name == raw_v: it.stems_qty += stems_v it.boxes_qty += boxes_v break else: found_flowers.add(raw_v) items.append(InvoiceItem(raw_name=raw_v, boxes_qty=boxes_v, stems_qty=stems_v)) # Метод 9w: EQR-EQUATOROSES (Ecuador) — Description с encoded строкой # Пример: "Roses Red Explorer 50 Cm x 450 Stem b- 1 HB 450 $0.130 $58.50" # Формат: Roses <VARIETY> <len> Cm x <stems> Stem <mark>- <boxes> <HB|QB|FB> <stems> $<price> $<total> has_eqr = 'EQR USA' in text.upper() or 'EQUATOROSES' in text.upper() if has_eqr: pattern9w = re.compile( r'Roses\s+([A-Z][A-Za-z ]+?)\s+\d{2,3}\s+Cm\s+x\s+\d+\s+Stem\s+\S*\s*(\d+)\s+(HB|QB|FB|EB)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+', re.MULTILINE ) for m in pattern9w.finditer(text): variety_w = m.group(1).strip().upper() boxes_w = int(m.group(2)) stems_w = int(m.group(4)) raw_w = f"ROSE {variety_w}" if raw_w in found_flowers: for it in items: if it.raw_name == raw_w: it.stems_qty += stems_w it.boxes_qty += boxes_w break else: found_flowers.add(raw_w) items.append(InvoiceItem(raw_name=raw_w, boxes_qty=boxes_w, stems_qty=stems_w)) # Метод 9x: MJ Flowers / MYJFLOWERS (Ecuador) — header + cont с QUALITY # Header: "B-KDANCE FR 1 QB" → бокс самостоятельно # Body: "100 25 4 BIRMANIA FANCY $0.150 $15.00" (stems spb bunches variety quality price total) # "50 5 10 KIWI MELLOW COOL FANCY $0.320 $16.00" has_mjf = 'MYJFLOWERS' in text.upper() or 'MJ FLOWERS' in text.upper() if has_mjf: pattern9x_hdr = re.compile(r'^\s*\S+\s+(?:FR|F R)?\s*(\d+)\s+(HB|QB|FB|EB)\s*$', re.MULTILINE) pattern9x_row = re.compile( r'^\s*(\d+)\s+\d+\s+\d+\s+([A-Z][A-Za-z0-9 \-\.]+?)\s+(?:FANCY|SELECT|STANDARD|EXP|PREMIUM|EXTRA|CLASSIC|BASIC|S)\s+\$[\d.]+\s+\$[\d.]+\s*$', re.MULTILINE ) # Считаем общее число боксов (из header'ов) total_boxes_x = sum(int(m.group(1)) for m in pattern9x_hdr.finditer(text)) x_items = [] for m in pattern9x_row.finditer(text): variety_x = m.group(2).strip().upper() if variety_x in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'BUNCH', 'PRICE', 'MUESTRA MINI CLAVEL'): continue stems_x = int(m.group(1)) raw_x = f"ROSE {variety_x}" x_items.append((raw_x, stems_x)) # Добавить items с нулевыми boxes, а потом распределить if x_items: total_stems_x = sum(s for _, s in x_items) for raw_x, stems_x in x_items: if raw_x in found_flowers: for it in items: if it.raw_name == raw_x: it.stems_qty += stems_x break else: found_flowers.add(raw_x) items.append(InvoiceItem(raw_name=raw_x, boxes_qty=0, stems_qty=stems_x)) # Распределить боксы пропорционально (но QB — 1 бокс на весь ассорт) if total_boxes_x > 0 and total_stems_x > 0: new_x = [it for it in items if it.raw_name in {r for r, _ in x_items} and it.boxes_qty == 0] # Один QB на весь ассорт → назначаем его первому в списке if new_x: new_x[0].boxes_qty = total_boxes_x # Метод 9y: Grupo Florisol / FLORISOL CIA (Ecuador) — SPRAY ROSE с varieties # Пример: "Q 1 1SPRAY ROSE 186673M07 10 50 CM 3 30 Stems 30 0.2800 8.400" # "Q 1 1SPRAY ROSE DREAMS CHARM 10 60 CM 1 10 Stems 10 0.3800 3.800" # Формат: <type> <box_n> <marker><SPECIES> [<variety_words>|<code>] <spb> <len> CM <bunches> <stems> Stems <total> ... has_florisol = 'FLORISOL' in text.upper() if has_florisol: pattern9y = re.compile( r'^\s*([HQFE])\s+\d+\s+\d+SPRAY\s+ROSE\s+(.+?)\s+(\d{2,3})\s+CM\s+\d+\s+(\d+)\s+Stems\s+\d+\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) for m in pattern9y.finditer(text): tail_y = m.group(2).strip().upper() stems_y = int(m.group(4)) # Отбросить code типа "186673M07" (цифры + буквы) — остается generic 'SPRAY ROSE' if re.match(r'^[\d]+[A-Z]?\d*$', tail_y): variety_y = 'SPRAY ROSE' else: variety_y = f'SPRAY ROSE {tail_y}' raw_y = variety_y if raw_y in found_flowers: for it in items: if it.raw_name == raw_y: it.stems_qty += stems_y it.boxes_qty += 1 # каждая строка — новый бокс break else: found_flowers.add(raw_y) items.append(InvoiceItem(raw_name=raw_y, boxes_qty=1, stems_qty=stems_y)) # Метод 9z: La Toscana / FLORES MAGICAS (Ecuador) — I-разделители # Пример: "1 I HALF I 200I ST IB - ROUSI IESPERANCE 60CM I $0.320000I $64.00" # Нормализуем в "1 HALF 200 ST B - ROUSI ESPERANCE 60CM $0.320000 $64.00" has_toscana = 'FLORES MAGICAS' in text.upper() or 'FLOR MAGIC' in text.upper() or 'FLORESMAGICAS' in text.upper() if has_toscana: # Нормализация: заменить " I " и "I " между полями (разделитель), а также разбить склеенные строки # "$XX.XX HALF ..." → перенос перед HALF/FULL/QUARTER text_norm = re.sub(r' I(?=[A-Z\$\d])', ' ', text) # " I<capital>" в начале поля text_norm = re.sub(r'(?<=\d)I ', ' ', text_norm) # "200I ST" → "200 ST" text_norm = re.sub(r' I(?= )', ' ', text_norm) # "200 I ST" → "200 ST" text_norm = re.sub(r'\$([\d.]+)\s+(HALF|FULL|QUARTER|EIGHTH)\s', r'$\1\n\2 ', text_norm) pattern9z = re.compile( r'^\s*(\d+)?\s*(HALF|FULL|QUARTER|EIGHTH)\s+(\d+)\s+ST\s+[Bb]\s*-\s*\S+\s+([A-Z][A-Za-z ]+?)\s+(\d{2,3})CM\s+\$[\d.]+\s+\$[\d.]+\s*$', re.MULTILINE ) for m in pattern9z.finditer(text_norm): boxes_z = int(m.group(1)) if m.group(1) else 0 stems_z = int(m.group(3)) variety_z = m.group(4).strip().upper() raw_z = f"ROSE {variety_z}" if raw_z in found_flowers: for it in items: if it.raw_name == raw_z: it.stems_qty += stems_z it.boxes_qty += boxes_z break else: found_flowers.add(raw_z) items.append(InvoiceItem(raw_name=raw_z, boxes_qty=boxes_z, stems_qty=stems_z)) # Метод 9aa: Esmeralda Farms / UTOPIA FARMS UTF (Ecuador) — сложный GYPSO/single row # Пример: "GYPSO. XLENCE WHITE 25ST 1000GR 12 B (SP 2310844-1) 40 GR 1 H 0.5 12 BC 300 0.320 96.00" # Найдём: <SPECIES>. <VARIETY> ... <boxes> <H|F|Q|E> <fbe> <bunches> BC <stems> <price> <total> has_esmeralda = 'UTOPIA FARMS' in text.upper() or 'ESMERALDA FARMS' in text.upper() or 'JBENAVIDES' in text.upper() if has_esmeralda: pattern9aa = re.compile( r'^\s*([A-Z][A-Z]+)\.\s+([A-Z][A-Za-z0-9 ]+?)\s+\d+ST\s+.+?\s+(\d+)\s+([HFQE])\s+[\d.]+\s+\d+\s+BC\s+(\d+)\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE ) for m in pattern9aa.finditer(text): species_aa = m.group(1).strip().upper() variety_aa = m.group(2).strip().upper() boxes_aa = int(m.group(3)) stems_aa = int(m.group(5)) species_full = {'GYPSO': 'GYPSOPHILA', 'CARN': 'CARNATION', 'ALSTRO': 'ALSTROEMERIA'}.get(species_aa, species_aa) raw_aa = f"{species_full} {variety_aa}" if raw_aa in found_flowers: for it in items: if it.raw_name == raw_aa: it.stems_qty += stems_aa it.boxes_qty += boxes_aa break else: found_flowers.add(raw_aa) items.append(InvoiceItem(raw_name=raw_aa, boxes_qty=boxes_aa, stems_qty=stems_aa)) # Метод 9ab: TERRAPACIFIC (Ecuador) — cross-tab с CM колонками 60/70 # Пример: "QBA 11 - 1 B-CHIPANDACLEANDY X-PRESSION 25 3 1 4 100 0.40 40.00" # Формат: <QBA|HBA|FBA> <boxN> - <N2> <mark_glued_variety> <variety_tail> <spb> <n60> <n70> <bunches> <stems> <price> <total> # mark и variety склеены (B-CHIPANDA+CLEANDY), берём лишь variety_tail как приближение has_terrap = 'TERRA PACIFIC' in text.upper() or 'TERRAPACIFIC' in text.upper() if has_terrap: pattern9ab = re.compile( r'^\s*(QB|HB|FB|EB)[A-Z]?\s+(\d+)\s+-\s+\d+\s+\S+\s+([A-Z][A-Za-z0-9 \-]+?)\s+\d+\s+\d+\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) for m in pattern9ab.finditer(text): boxes_ab = int(m.group(2)) variety_ab = m.group(3).strip().upper() stems_ab = int(m.group(4)) if variety_ab in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'BUNCH', 'STEM', 'PRICE'): continue # SPRAY ROSE handling: префикс SPRAY ROSE есть в mark_glued if 'SPRAY ROSE' in text.upper(): raw_ab = f"SPRAY ROSE {variety_ab}" else: raw_ab = f"ROSE {variety_ab}" if raw_ab in found_flowers: for it in items: if it.raw_name == raw_ab: it.stems_qty += stems_ab it.boxes_qty += boxes_ab break else: found_flowers.add(raw_ab) items.append(InvoiceItem(raw_name=raw_ab, boxes_qty=boxes_ab, stems_qty=stems_ab)) # Метод 9ac: TERRAPACIFIC (Ecuador) — вариант с одной CM-колонкой, mark+variety склеены целиком # Пример: "QBA 11 - 1 B-ECOBLOOMFREEDOM 25 4 4 100 0.25 25.00" # variety восстанавливаем отрезанием маркировки из CONSIGNEE if has_terrap: mark_m_tp = re.search(r'CONSIGNEE\s*:.*?(B-[A-Z0-9.\-]+)', text, re.IGNORECASE) mark_tp = mark_m_tp.group(1).upper() if mark_m_tp else '' pattern9ac = re.compile( r'^\s*(QB|HB|FB|EB)[A-Z]?\s+\d+\s+-\s+\d+\s+(B-\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) for m in pattern9ac.finditer(text): glued_ac = m.group(2).upper() stems_ac = int(m.group(6)) if mark_tp and glued_ac.startswith(mark_tp): variety_ac = glued_ac[len(mark_tp):] else: variety_ac = re.sub(r'^B-[A-Z0-9.]*?(?=[A-Z][a-z])', '', glued_ac) variety_ac = variety_ac.strip(' -') if not variety_ac or stems_ac <= 0: continue raw_ac = f"ROSE {variety_ac}" if raw_ac in found_flowers: for it in items: if it.raw_name == raw_ac: it.stems_qty += stems_ac it.boxes_qty += 1 break else: found_flowers.add(raw_ac) items.append(InvoiceItem(raw_name=raw_ac, boxes_qty=1, stems_qty=stems_ac)) # Метод 9ad: Luxus Blumen (Ecuador) — тип коробки словом перед вариететом. # Первая строка коробки: "1 - 1 1 QUARTER STAR BLUSH 60 10 11 110 0.50 55.00" # Строки-продолжения (другие сорта в той же коробке, без префикса): # "BALI 60 10 3 30 0.50 15.00" has_luxus = 'LUXUSBLUMEN' in text.upper() or 'LUXUS BLUMEN' in text.upper() if has_luxus: line_box_ad = re.compile( r'^\s*\d+\s+-\s+\d+\s+(?:[A-Za-z][A-Za-z ]*?\s+)?(\d+)\s+(QUARTER|HALF|FULL|EIGHTH)\s+((?=\S*[A-Za-z])[A-Za-z0-9][A-Za-z0-9 \-\']*?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$' ) line_cont_ad = re.compile( r'^\s*((?=\S*[A-Za-z])[A-Za-z0-9][A-Za-z0-9 \-\']*?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$' ) in_table_ad = False for line_ad in text.split('\n'): m = line_box_ad.match(line_ad) if m: in_table_ad = True boxes_ad = int(m.group(1)) variety_ad = m.group(3).strip().upper() stems_ad = int(m.group(5)) elif in_table_ad: m2 = line_cont_ad.match(line_ad) if not m2: continue # Продолжение коробки: стебли есть, коробка уже учтена boxes_ad = 0 variety_ad = m2.group(1).strip().upper() stems_ad = int(m2.group(3)) else: continue if stems_ad <= 0 or 'TOTAL' in variety_ad or variety_ad == 'FOB': continue raw_ad = f"ROSE {variety_ad}" if raw_ad in found_flowers: for it in items: if it.raw_name == raw_ad: it.stems_qty += stems_ad it.boxes_qty += boxes_ad break else: found_flowers.add(raw_ad) items.append(InvoiceItem(raw_name=raw_ad, boxes_qty=boxes_ad, stems_qty=stems_ad)) # Метод 9ae: Qualisa "Customer Invoice" (Ecuador) — Open Market строки # Примеры: "Open 2 QB3 ALSTRO ALSTROEMERI WINTERFELL N 10ST QUCT 90 32 320 0.3000 96.00" # "Open 1 HB2 ROSES EXPLORER N 25ST QUCT 60 14 350 0.3000 105.00" (без длинного вида) has_qualisa_ci = 'QUALISA' in text.upper() and 'Customer Invoice' in text if has_qualisa_ci: pattern9ae = re.compile( r'^\s*Open\s+(\d+)\s+\S+\s+([A-Z][A-Za-z ]+?)\s+(?:N\s+)?\d+ST\s+\S+\s+\d{2,3}\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) species_map_ae = {'ALSTRO': 'ALSTROEMERIA', 'GYPSO': 'GYPSOPHILA', 'CARN': 'CARNATION', 'ROSE': 'ROSE', 'ROSES': 'ROSE'} for m in pattern9ae.finditer(text): # Строки "Open N HBx ROSES <VAR> N 25ST ..." уже обработаны методом 9p — пропускаем if re.match(r'^\s*(?:(?:Standing|Open)\s+\d+\s+\S+\s+)?(?:ROSES|ALSTROEMERI[A]?)\s+[A-Z][A-Z0-9 \'\-\.]+?\s+[NPnp]\s+\d+ST\s', m.group(0)): continue boxes_ae = int(m.group(1)) mid_tokens = m.group(2).strip().upper().split() species_ae = species_map_ae.get(mid_tokens[0], mid_tokens[0]) rest_ae = mid_tokens[1:] # Второй токен — обрезанное полное имя вида (ALSTROEMERI) — пропускаем if rest_ae and len(rest_ae[0]) >= 6 and species_ae.startswith(rest_ae[0][:6]): rest_ae = rest_ae[1:] variety_ae = ' '.join(rest_ae).strip() stems_ae = int(m.group(3)) if stems_ae <= 0 or not variety_ae: continue raw_ae = f"{species_ae} {variety_ae}" if raw_ae in found_flowers: for it in items: if it.raw_name == raw_ae: it.stems_qty += stems_ae it.boxes_qty += boxes_ae break else: found_flowers.add(raw_ae) items.append(InvoiceItem(raw_name=raw_ae, boxes_qty=boxes_ae, stems_qty=stems_ae)) # Метод 9af: Olimpo Flowers (Ecuador) — склейка boxtype+variety (QUABLUE) # Пример: "1 QUABLUE 25X .00 4 100.00 0.70 70.00" has_olimpo = 'OLIMPO FLOWERS' in text.upper() if has_olimpo: pattern9af = re.compile( r'^\s*(\d+)\s+(QUA|HAL|FUL|EIG|QB|HB|FB|EB)([A-Z][A-Za-z \-]*?)\s+(\d+)X\s+.*?(\d+)\s+(\d+)(?:\.00)?\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) for m in pattern9af.finditer(text): boxes_af = int(m.group(1)) variety_af = m.group(3).strip().upper() stems_af = int(m.group(6)) if not variety_af or stems_af <= 0: continue raw_af = f"ROSE {variety_af}" if raw_af in found_flowers: for it in items: if it.raw_name == raw_af: it.stems_qty += stems_af it.boxes_qty += boxes_af break else: found_flowers.add(raw_af) items.append(InvoiceItem(raw_name=raw_af, boxes_qty=boxes_af, stems_qty=stems_af)) # Метод 9ag: Zveti Flowers (Ecuador) — строки вида "<box> <o>-<o> QB <Variety> <len> ..." # Пример: "1 1-1 QB Gotcha 80 1 1 25 0.270 6.750" / "1-1 QB Gotcha 70 2 2 50 0.240 12.000" has_zveti = 'ZVETIFLOWERS' in text.upper() or 'ZVETI FLOWERS' in text.upper() if has_zveti: pattern9ag = re.compile( r'^\s*(?:(\d+)\s+)?\d+-\d+\s+(QB|HB|FB|EB)\s+([A-Z][A-Za-z \-\']+?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) for m in pattern9ag.finditer(text): boxes_ag = int(m.group(1)) if m.group(1) else 0 variety_ag = m.group(3).strip().upper() stems_ag = int(m.group(5)) if stems_ag <= 0 or variety_ag in ('TOTALS', 'TOTAL'): continue raw_ag = f"ROSE {variety_ag}" if raw_ag in found_flowers: for it in items: if it.raw_name == raw_ag: it.stems_qty += stems_ag it.boxes_qty += boxes_ag break else: found_flowers.add(raw_ag) items.append(InvoiceItem(raw_name=raw_ag, boxes_qty=boxes_ag, stems_qty=stems_ag)) # Метод 9ah: CANTIZA FLORAL GROUP / VALTHOMIG (Ecuador) # Пример: "1 QB CAN-96X30X18 PLAYA BLANCA 70CM N 25ST CZ ROSES C4 4 $21.2500 100 $0.8500 $85.00" has_cantiza = 'CANTIZA' in text.upper() or 'VALTHOMIG' in text.upper() if has_cantiza: pattern9ah = re.compile( r'^\s*(\d+)\s+(QB|HB|FB|EB)\s+\S+\s+([A-Z][A-Za-z ]+?)\s+(\d{2,3})CM\s+.*?\s+(\d+)\s+\$[\d.,]+\s+(\d+)\s+\$[\d.,]+\s+\$[\d.,]+\s*$', re.MULTILINE ) for m in pattern9ah.finditer(text): boxes_ah = int(m.group(1)) variety_ah = m.group(3).strip().upper() stems_ah = int(m.group(6)) if stems_ah <= 0: continue raw_ah = f"ROSE {variety_ah}" if raw_ah in found_flowers: for it in items: if it.raw_name == raw_ah: it.stems_qty += stems_ah it.boxes_qty += boxes_ah break else: found_flowers.add(raw_ah) items.append(InvoiceItem(raw_name=raw_ah, boxes_qty=boxes_ah, stems_qty=stems_ah)) # Метод 9ai: Josarflor (Ecuador) — head + continuation с Iva-колонкой # Head: "1 3286970 0.5 Roses Mamma Mia 60 5 5 125 0.3500 43.7500 0.0000 43.7500" # Cont: "Roses Mamma Mia 70 7 7 175 0.3500 61.2500 0.0000 61.2500" has_josar = 'UNOSOF' in text.upper() or bool(re.search(r'Species\s+Description\s+Len', text)) if has_josar: sp_ji = r'(Roses?|Spray|Gypso\w*|Alstro\w*|Carn\w*)' p_head_ji = re.compile( r'^\s*(\d+)\s+\d{6,8}\s+[\d.]+\s+' + sp_ji + r'\s+([A-Z][A-Za-z \-\']+?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE | re.IGNORECASE ) p_cont_ji = re.compile( r'^\s*' + sp_ji + r'\s+([A-Z][A-Za-z \-\']+?)\s+(\d{2,3})\s+\d+\s+\d+\s+(\d+)\s+[\d.]+\s+[\d.]+\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE | re.IGNORECASE ) sp_map_ji = {'ROSE': 'ROSE', 'ROSES': 'ROSE', 'SPRAY': 'SPRAY ROSE'} def _add_ji(species_raw, variety_raw, stems_ji, boxes_ji): sp_u = species_raw.upper() sp_full = sp_map_ji.get(sp_u) if sp_full is None: if sp_u.startswith('GYPSO'): sp_full = 'GYPSOPHILA' elif sp_u.startswith('ALSTRO'): sp_full = 'ALSTROEMERIA' elif sp_u.startswith('CARN'): sp_full = 'CARNATION' else: sp_full = sp_u raw_ji = f"{sp_full} {variety_raw.strip().upper()}" if raw_ji in found_flowers: for it in items: if it.raw_name == raw_ji: it.stems_qty += stems_ji it.boxes_qty += boxes_ji break else: found_flowers.add(raw_ji) items.append(InvoiceItem(raw_name=raw_ji, boxes_qty=boxes_ji, stems_qty=stems_ji)) matched_spans_ji = [] for m in p_head_ji.finditer(text): matched_spans_ji.append(m.span()) _add_ji(m.group(2), m.group(3), int(m.group(5)), int(m.group(1))) for m in p_cont_ji.finditer(text): # пропускаем участки, уже пойманные head-паттерном if any(s <= m.start() < e for s, e in matched_spans_ji): continue _add_ji(m.group(1), m.group(2), int(m.group(4)), 0) # Метод 9aj: EC BLOOMS S.A.S. / Star Roses / El Campanario (Ecuador) — запятая как десятичный разделитель. # BOX N° — ПОРЯДКОВЫЙ номер коробки (не количество!): каждая box-строка = +1 коробка. # Первая строка коробки: "1 H MAMMA MIA 60 8 200 0,35 70,00" # С BOX CODE (SUN FLORA): "2 H SUN FLORA APPLE JACK 70 1 25 0,70 17,50" # Строки-продолжения (та же коробка, без номера): "H ESPERANCE 70 2 50 0,55 27,50" has_ecblooms = 'EC BLOOMS' in text.upper() or 'STAROSES' in text.upper() if has_ecblooms: line_box_aj = re.compile( r'^\s*(\d+)\s+([HFQE])\s+([A-Z][A-Za-z0-9 \-\']+?)\s+(\d{2,3})\s+(\d+)\s+(\d+)\s+\d+,\d+\s+[\d.,]+\s*$' ) line_cont_aj = re.compile( r'^\s*([HFQE])\s+([A-Z][A-Za-z0-9 \-\']+?)\s+(\d{2,3})\s+(\d+)\s+(\d+)\s+\d+,\d+\s+[\d.,]+\s*$' ) rows_aj: list[tuple[str, int, int]] = [] # (variety, box_no|0, stems) in_table_aj = False for line_aj in text.split('\n'): m = line_box_aj.match(line_aj) if m: in_table_aj = True rows_aj.append((m.group(3).strip().upper(), int(m.group(1)), int(m.group(6)))) elif in_table_aj: m2 = line_cont_aj.match(line_aj) if m2: rows_aj.append((m2.group(2).strip().upper(), 0, int(m2.group(5)))) rows_aj = [r for r in rows_aj if r[2] > 0 and 'TOTAL' not in r[0]] if rows_aj: # BOX CODE (общий префикс всех строк, напр. "SUN FLORA") срезаем из вариетета if len(rows_aj) > 1: words0 = rows_aj[0][0].split() n_pref = 0 for i_w, w in enumerate(words0): if all(len(r[0].split()) > i_w + 1 and r[0].split()[i_w] == w for r in rows_aj): n_pref = i_w + 1 else: break if n_pref: rows_aj = [(' '.join(r[0].split()[n_pref:]), r[1], r[2]) for r in rows_aj] for variety_aj, box_no_aj, stems_aj in rows_aj: boxes_aj = 1 if box_no_aj > 0 else 0 raw_aj = f"ROSE {variety_aj}" if raw_aj in found_flowers: for it in items: if it.raw_name == raw_aj: it.stems_qty += stems_aj it.boxes_qty += boxes_aj break else: found_flowers.add(raw_aj) items.append(InvoiceItem(raw_name=raw_aj, boxes_qty=boxes_aj, stems_qty=stems_aj)) # Метод 9ak: Cewi Flowers (Ecuador) — повёрнутый PDF, текст извлекается через fitz по одному полю на строку # Ключевые строки: "QB LIGHT PINK" (тип+вариетет), "VARIETY ALSTROEMERIA" (вид), # "TOT. STEMS\n440" (стебли), "TOT.PIEZAS\n2" (коробки) has_cewi = 'CEWI FLOWERS' in text.upper() if has_cewi and not items: m_var = re.search(r'^(?:QB|HB|FB|EB)\s+([A-Z][A-Z ]+?)\s*$', text, re.MULTILINE) m_species = re.search(r'VARIETY\s+([A-Z]+)', text) m_stems = re.search(r'TOT\.?\s*STEMS\s*\n\s*(\d+)', text) m_boxes = re.search(r'TOT\.?\s*PIEZAS\s*\n\s*(\d+)', text) if m_var and m_stems: variety_ak = m_var.group(1).strip().upper() species_ak = (m_species.group(1).upper() if m_species else 'ROSE') if species_ak.startswith('ALSTRO'): species_ak = 'ALSTROEMERIA' stems_ak = int(m_stems.group(1)) boxes_ak = int(m_boxes.group(1)) if m_boxes else 0 if stems_ak > 0: raw_ak = f"{species_ak} {variety_ak}" found_flowers.add(raw_ak) items.append(InvoiceItem(raw_name=raw_ak, boxes_qty=boxes_ak, stems_qty=stems_ak)) # Метод 9l: Tessa Corporation # Форматы: # 1186076: "1 911831621 TESSA-E1 LOLA 60 4 100 $0.60 $60.00" + "LOLA 70 3 75 $0.70 $52.50" # 1186084: "1 911877195 ALBA 60 1 25 $0.40 $10.00" + "CREAM X-\nPRESSION 60 1 25 $0.40 $10.00" (обёрнуто) # 1186087: "1 911877162 PINK INTENZ 80 2 20 $0.36 $7.20" (окружено ALSTRO HOT / ALSTRO PS2 DARK PINK) # 90784349: "1 911832158 HB XL 2 MONDIAL 70 10 250 $0.60 $150.00" (префикс HB XL 2) has_tessa_kz = ('TESSA-' in text) or bool(re.search(r'Boxes\s+Order\s+BoxT', text)) if has_tessa_kz: lines_kz = text.split('\n') junk_prefix = {'HB', 'FB', 'QB', 'EB', 'XL', 'XXL', 'XLXL', 'L', 'M', 'S', 'QBXL', 'QBXXL', 'HBXL', 'HBXXL', 'MB', 'JB'} # Паттерн 1: строка с order# (10-значным) — миддл может включать шум p_first = re.compile(r'^\s*(\d+)\s+\d{9,10}\s+(.+?)\s+(\d{2,3})\s+(\d+)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$') # Паттерн 1b: строка с order# но БЕЗ inline варьете (варьете обёрнута сверху+снизу) # Пример 1186089 order 911877191: "1 911877191 70 2 50 $0.45 $22.50" с "HB XXL TESSA- ABSOLUT IN" сверху и "2 1 PINK" снизу p_first_empty = re.compile(r'^\s*(\d+)\s+\d{9,10}\s+(\d{2,3})\s+(\d+)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$') # Паттерн 2: continuation варьете (без order#) p_cont = re.compile(r'^\s*([A-Z][A-Z \'\-&/]+?)\s+(\d{2,3})\s+(\d+)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$') # Паттерн 3: только числа (варьете обёрнута) p_nums = re.compile(r'^\s*(\d{2,3})\s+(\d+)\s+(\d+)\s+\$[\d.]+\s+\$[\d.]+\s*$') # Определяем тип цветка: ALSTRO/GYPSO/HYDRA в тексте ⇒ не rose is_alstro = bool(re.search(r'\bALSTRO(?:E?MERIA)?\b', text)) is_hydra = bool(re.search(r'\bHYDR[AO]NGEAS?\b', text)) is_gypso = bool(re.search(r'\bGYPS?O(?:PHILA)?\b', text)) is_carn = bool(re.search(r'\bCARN(?:ATION)?S?\b', text)) if is_alstro: prefix_kz = 'ALSTROEMERIA' elif is_hydra: prefix_kz = 'HYDRANGEA' elif is_gypso: prefix_kz = 'GYPSOPHILA' elif is_carn: prefix_kz = 'CARNATION' else: prefix_kz = 'ROSE' def _clean_variety_kz(middle: str) -> str: tokens = middle.split() while tokens and (tokens[0] in junk_prefix or tokens[0].isdigit() or tokens[0].startswith('TESSA-')): tokens.pop(0) while tokens and (tokens[-1].isdigit() or tokens[-1] in junk_prefix): tokens.pop() # Фильтруем служебные токены tokens = [t for t in tokens if t not in {'ALSTRO', 'HYD', 'HYDR', 'CARN', 'GYPS', 'GYP', 'ROS', 'ROSA'}] return ' '.join(tokens).strip().upper() # Подсчёт боксов: каждая order# строка = 1 новый бокс box_count_kz = 0 # Сделаем первый проход — считаем боксы и запоминаем позиции с order# matched_positions_kz = set() for i, line in enumerate(lines_kz): m = p_first.match(line) if m: matched_positions_kz.add(i) try: box_count_kz += int(m.group(1)) except (ValueError, TypeError): box_count_kz += 1 middle = m.group(2) stems_kz = int(m.group(5)) variety_kz = _clean_variety_kz(middle) if not variety_kz or not any(c.isalpha() for c in variety_kz): continue if variety_kz in ('BOX', 'TYPE', 'STEMS', 'ORDER', 'MIXED'): continue raw_kz = f"{prefix_kz} {variety_kz}" if raw_kz in found_flowers: for it in items: if it.raw_name == raw_kz: it.stems_qty += stems_kz break else: found_flowers.add(raw_kz) items.append(InvoiceItem(raw_name=raw_kz, boxes_qty=0, stems_qty=stems_kz)) # Второй проход — continuation и обёрнутые for i, line in enumerate(lines_kz): if i in matched_positions_kz: continue # 2.0: p_first_empty — order# без inline варьете, ищем в prev/next m = p_first_empty.match(line) if m: boxes_here = int(m.group(1)) stems_kz = int(m.group(4)) # prev может быть "HB XXL TESSA- ABSOLUT IN" — берём хвост после junk # nxt может быть "2 1 PINK" — берём последнее слово (латинское) prev = lines_kz[i - 1].strip() if i > 0 else '' nxt = lines_kz[i + 1].strip() if i + 1 < len(lines_kz) else '' # Извлекаем variety-часть из prev: пропускаем junk-префикс + TESSA- prev_tokens = prev.split() while prev_tokens and (prev_tokens[0] in junk_prefix or prev_tokens[0].startswith('TESSA-') or prev_tokens[0].isdigit()): prev_tokens.pop(0) prev_variety = ' '.join(prev_tokens).upper() # Извлекаем tail-часть из nxt: пропускаем цифры в начале nxt_tokens = nxt.split() while nxt_tokens and nxt_tokens[0].isdigit(): nxt_tokens.pop(0) nxt_variety = ' '.join(nxt_tokens).upper() variety_kz = f"{prev_variety} {nxt_variety}".strip() if variety_kz and any(c.isalpha() for c in variety_kz) and variety_kz not in junk_prefix: box_count_kz += boxes_here matched_positions_kz.add(i) raw_kz = f"{prefix_kz} {variety_kz}" if raw_kz in found_flowers: for it in items: if it.raw_name == raw_kz: it.stems_qty += stems_kz break else: found_flowers.add(raw_kz) items.append(InvoiceItem(raw_name=raw_kz, boxes_qty=0, stems_qty=stems_kz)) continue m = p_cont.match(line) if m: variety_kz = m.group(1).strip().upper() stems_kz = int(m.group(4)) # Отбрасываем если это обёрнутое начало варьетети (короткое + суффикс -/X-) # Пример: "CREAM X- 60 1 25 ..." — склеиваем со следующей строкой if variety_kz.endswith('-') or variety_kz.endswith('X-') and i + 1 < len(lines_kz): tail = lines_kz[i + 1].strip() if re.match(r'^[A-Z][A-Z\-]*\s*$', tail): variety_kz = f"{variety_kz}{tail.upper()}" if variety_kz in junk_prefix or variety_kz in ('BOX', 'TYPE', 'STEMS', 'ORDER'): continue if not any(c.isalpha() for c in variety_kz): continue # Проверка на ключевые слова шума if variety_kz in ('ALSTRO PS2', 'DARK PINK', 'PS2 BOUNTY', 'PS2 DARK', 'HOT PINK'): continue raw_kz = f"{prefix_kz} {variety_kz}" if raw_kz in found_flowers: for it in items: if it.raw_name == raw_kz: it.stems_qty += stems_kz break else: found_flowers.add(raw_kz) items.append(InvoiceItem(raw_name=raw_kz, boxes_qty=0, stems_qty=stems_kz)) continue m = p_nums.match(line) if m: # variety обёрнута на предыдущей/следующей строке prev = lines_kz[i - 1].strip() if i > 0 else '' nxt = lines_kz[i + 1].strip() if i + 1 < len(lines_kz) else '' variety_kz = '' # Проверка: prev — однословное или многословное варьете prev_is_variety = bool(re.match(r'^[A-Z][A-Z\- ]*$', prev)) and any(c.isalpha() for c in prev) nxt_is_suffix = bool(re.match(r'^[A-Z][A-Z\-]*$', nxt)) # Случай 1: prev оканчивается на "-" (ПИНК X-, CREAM X-, SUNSET X-) → склеиваем без пробела if prev_is_variety and prev.rstrip().endswith('-') and nxt_is_suffix: variety_kz = f"{prev}{nxt}".upper() # Случай 2: prev обычное варьете + nxt — слово-суффикс (MOTHER OF + PEARL) elif prev_is_variety and nxt_is_suffix: variety_kz = f"{prev} {nxt}".upper() # Случай 3: только prev elif prev_is_variety: variety_kz = prev.upper() stems_kz = int(m.group(3)) if not variety_kz or variety_kz in junk_prefix: continue # Фильтруем чисто junk-комбинации v_tokens = variety_kz.split() if all(t in junk_prefix or t.isdigit() for t in v_tokens): continue raw_kz = f"{prefix_kz} {variety_kz}" if raw_kz in found_flowers: for it in items: if it.raw_name == raw_kz: it.stems_qty += stems_kz break else: found_flowers.add(raw_kz) items.append(InvoiceItem(raw_name=raw_kz, boxes_qty=0, stems_qty=stems_kz)) # Распределяем боксы пропорционально if box_count_kz > 0: new_items_kz = [it for it in items if it.boxes_qty == 0 and it.stems_qty > 0] total_stems_kz = sum(it.stems_qty for it in new_items_kz) if new_items_kz and total_stems_kz > 0: allocated = 0 min_b_kz = 1 if box_count_kz >= len(new_items_kz) else 0 for i, it in enumerate(new_items_kz): if i < len(new_items_kz) - 1: it.boxes_qty = max(min_b_kz, round(box_count_kz * it.stems_qty / total_stems_kz)) allocated += it.boxes_qty else: it.boxes_qty = max(0, box_count_kz - allocated) # Метод 9p: DAVINCIROSES — "<bunches> <spb> <total_stems> <VARIETY> <CM> CMS $<price> $<total>" # Пример: "12 25 300 CANDELIGHT 70 CMS $0.70 $210.00" # Пример: "1 25 25 BE SWEET 60 CMS $0.40 $10.00" if not items and 'DAVINCIROSES' in text_upper: pattern9p = re.compile( r'^\s*(\d+)\s+\d+\s+(\d+)\s+([A-Z][A-Z \'\-]+?)\s+\d{2,3}\s+CMS\s+\$[\d.]+\s+\$[\d.]+\s*$', re.MULTILINE ) p_matches = list(pattern9p.finditer(text)) for m in p_matches: bunches_p = int(m.group(1)) stems_p = int(m.group(2)) variety_p = m.group(3).strip().upper() if variety_p in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL'): continue raw_p = f"ROSE {variety_p}" if raw_p in found_flowers: for it in items: if it.raw_name == raw_p: it.stems_qty += stems_p break else: found_flowers.add(raw_p) items.append(InvoiceItem(raw_name=raw_p, boxes_qty=0, stems_qty=stems_p)) # Извлекаем боксы из "Full Boxes: N.NN" fb_match = re.search(r'Full\s+Boxes:\s*([\d.]+)', text) if fb_match and items: full_boxes_p = float(fb_match.group(1)) total_boxes_p = max(1, round(full_boxes_p * 2)) # HB = 0.5 full for it in items: if it.boxes_qty == 0: it.boxes_qty = max(1, total_boxes_p) break # Метод 9q: STAROSES / EC BLOOMS / EL CAMPANARIO — "[N] H|Q SUN FLORA <VARIETY> <CM> <BUNCHES> <STEMS>" # Пример: "1 Q SUN FLORA RUBY GLOW STAR 70 1 25 1,00 25,00" # Пример: "H SUN FLORA CANDLELIGHT 80 7 175 0,80 140,00" if not items and ('staroses.com' in text.lower() or 'CAMPANARIO' in text_upper or 'EC BLOOMS' in text_upper): # Сначала собираем все совпадения без box code, затем вычисляем box code как общий префикс pattern9q_raw = re.compile( r'^\s*(?:\d+\s+)?([HQFE])\s+(.+?)\s+(\d{2,3})\s+(\d{1,3})\s+(\d+)\s+[\d,]+\s+[\d,]+\s*$', re.MULTILINE ) raw_matches = list(pattern9q_raw.finditer(text)) if raw_matches: # Определяем BOX CODE как общий префикс всех group(2) all_middles = [m.group(2).strip() for m in raw_matches] # Найти общие начальные слова first_words = all_middles[0].split() common_prefix_words = [] for i, w in enumerate(first_words): if all(m.split()[i] == w if i < len(m.split()) else False for m in all_middles): common_prefix_words.append(w) else: break box_code_prefix = ' '.join(common_prefix_words) prefix_len = len(box_code_prefix) + 1 if box_code_prefix else 0 # +1 for trailing space for m in raw_matches: middle = m.group(2).strip() variety_q = middle[prefix_len:].strip().upper() if prefix_len > 0 else middle.upper() stems_q = int(m.group(5)) if not variety_q or variety_q in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL'): continue if not any(c.isalpha() for c in variety_q): continue raw_q = f"ROSE {variety_q}" if raw_q in found_flowers: for it in items: if it.raw_name == raw_q: it.stems_qty += stems_q break else: found_flowers.add(raw_q) items.append(InvoiceItem(raw_name=raw_q, boxes_qty=0, stems_qty=stems_q)) # Извлекаем боксы из "Total Full F H Q E" → "N.NN 0 N N 0" boxes_match = re.search(r'Total\s+Full.*?\n\s*([\d,.]+)', text) if boxes_match and items: total_full_q = float(boxes_match.group(1).replace(',', '.')) total_boxes_q = max(1, round(total_full_q * 2)) # каждый H = 0.5 full # Распределяем пропорционально total_stems_q = sum(it.stems_qty for it in items if it.boxes_qty == 0) if total_stems_q > 0: allocated_q = 0 zero_items = [it for it in items if it.boxes_qty == 0] for i, it in enumerate(zero_items): if i < len(zero_items) - 1: it.boxes_qty = max(1, round(total_boxes_q * it.stems_qty / total_stems_q)) allocated_q += it.boxes_qty else: it.boxes_qty = max(1, total_boxes_q - allocated_q) # Метод 9r: ROYALFLOWERS / MONTEROSAS — "N - N <BX> <HBn> <VARIETY> <CM> <SPB> <BUNCH> <STEMS> <PRICE> <TOTAL>" # Пример: "1 - 1 1 HB3 CANDLELIGHT 60 25 12 300 0.60 180.00" # Пример: "1 - 1 1 HB 2 FULL MONTY 60 25 4 100 0.45 45.00" # Continuation: "PINK FLOYD 70 25 4 100 0.50 50.00" if not items: # Detect format: "ORDER BOX CODE BX" header or "N - N N HB" pattern has_royalflowers = bool(re.search(r'ORDER\s+BOX\s+CODE\s+BX', text)) if has_royalflowers: # Head: "N - N BX HB[n| n] VARIETY CM SPB BUNCH STEMS PRICE TOTAL" pattern9r_head = re.compile( r'^\s*\d+\s+-\s+\d+\s+(\d+)\s+HB\s*\d*\s+([A-Z][A-Z \'\-]+?)\s+(\d{2,3})\s+\d+\s+(\d+)\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) # Cont: "VARIETY CM SPB BUNCH STEMS PRICE TOTAL" pattern9r_cont = re.compile( r'^\s*([A-Z][A-Z \'\-]+?)\s+(\d{2,3})\s+\d+\s+(\d+)\s+(\d+)\s+[\d.]+\s+[\d.]+\s*$', re.MULTILINE ) r_head_positions = set() total_boxes_r = 0 for m in pattern9r_head.finditer(text): r_head_positions.add(m.start()) boxes_r = int(m.group(1)) total_boxes_r += boxes_r variety_r = m.group(2).strip().upper() stems_r = int(m.group(5)) if variety_r in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'TOTAL FOB', 'TOTAL FCA'): continue raw_r = f"ROSE {variety_r}" if raw_r in found_flowers: for it in items: if it.raw_name == raw_r: it.stems_qty += stems_r break else: found_flowers.add(raw_r) items.append(InvoiceItem(raw_name=raw_r, boxes_qty=0, stems_qty=stems_r)) # Continuation lines for m in pattern9r_cont.finditer(text): if m.start() in r_head_positions: continue variety_r = m.group(1).strip().upper() stems_r = int(m.group(4)) if variety_r in ('BOX', 'TYPE', 'STEMS', 'VARIETY', 'CODE', 'TOTAL', 'TOTAL FOB', 'TOTAL FCA'): continue if 'BUNCH' in variety_r or 'PRICE' in variety_r or 'ORDER' in variety_r: continue raw_r = f"ROSE {variety_r}" if raw_r in found_flowers: for it in items: if it.raw_name == raw_r: it.stems_qty += stems_r break else: found_flowers.add(raw_r) items.append(InvoiceItem(raw_name=raw_r, boxes_qty=0, stems_qty=stems_r)) # Распределяем боксы пропорционально if total_boxes_r > 0 and items: zero_items_r = [it for it in items if it.boxes_qty == 0 and it.stems_qty > 0] total_stems_r = sum(it.stems_qty for it in zero_items_r) if zero_items_r and total_stems_r > 0: allocated_r = 0 for i, it in enumerate(zero_items_r): if i < len(zero_items_r) - 1: it.boxes_qty = max(1, round(total_boxes_r * it.stems_qty / total_stems_r)) allocated_r += it.boxes_qty else: it.boxes_qty = max(1, total_boxes_r - allocated_r) # Метод 9s: FLORUNNER / Colombia proforma — "PIECES DESCRIPTION UNITS PRICE TOTAL FOB" # Пример: "21 FRESH CUT FLOWERS ALSTROEMERIA 3360 1.00 3,360.00" # boxes=21, name=ALSTROEMERIA, stems=3360 if not items and 'FRESH CUT FLOWERS' in text_upper: pattern9s = re.compile( r'^\s*(\d+)\s+FRESH\s+CUT\s+FLOWERS\s+([A-Z][A-Z ]+?)\s+(\d{2,6})\s+[\d.,]+\s+[\d.,]+\s*$', re.MULTILINE | re.IGNORECASE ) for m in pattern9s.finditer(text): boxes_s = int(m.group(1)) variety_s = m.group(2).strip().upper() stems_s = int(m.group(3)) # Фильтр итоговых строк и заголовков if variety_s in ('TOTAL', 'PIECES', 'DESCRIPTION', 'UNITS', 'PRICE'): continue if variety_s in found_flowers: for it in items: if it.raw_name == variety_s: it.boxes_qty += boxes_s it.stems_qty += stems_s break else: found_flowers.add(variety_s) items.append(InvoiceItem(raw_name=variety_s, boxes_qty=boxes_s, stems_qty=stems_s)) # === Метод 10: PJ Dave Flowers / Flora === # Деталь: "Athena-40cms B.SWAIZH X BESTFLORA 2 1 250 USD 0.0816 USD 20.4000" # Деталь: "Rhodos-60cms 4.5 cm 420 Zim-99x40x25 3 1,260 YRP USD 0.1700 USD 214.20" # Сводка: "Pink Athena-40cms 250 USD 0.0816 USD 20.4000" if not items: # Детальные строки с B-CLIENT или B.: используем [^\n]*? вместо .*? pattern10a = r'([A-Z][\w ]*?)-(\d+)[ \t]*cms?[^\n]*?B[-.][\w.:]+[^\n]*?(\d[\d,]*)[ \t]+(?:[\d,]+[ \t]+)?(?:YRP[ \t]+)?USD[ \t]+[\d.]+[ \t]+USD' for match in re.finditer(pattern10a, text, re.IGNORECASE | re.MULTILINE): variety = match.group(1).strip().title() length = match.group(2) boxes_str = match.group(3).replace(',', '') boxes = int(boxes_str) raw_name = f"ROSE {variety} {length}CM" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Если детали не нашли — сводные строки if not items: pattern10b = r'([A-Z][\w ]*?)-(\d+)[ \t]*cms?[ \t]+(\d[\d,]*)[ \t]+USD[ \t]+[\d.]+[ \t]+USD' for match in re.finditer(pattern10b, text, re.IGNORECASE | re.MULTILINE): variety = match.group(1).strip().title() length = match.group(2) raw_name = f"ROSE {variety} {length}CM" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=1)) # === Метод 11: Baraka Roses / UHURU Spray Roses === # Baraka: "1 Garden Spray Roses Mix 60cm ... 180 1 box 180 $ 0.750 stem ... $ 135.000" # UHURU: "1 Mixed: Elite Spray Rose Groove B-SARESS 50 1 160 0,66 118,30" if not items: # Baraka: "N Garden Spray Roses VARIETY LENGTHcm ... STEMS N box STEMS $ PRICE stem ... $ TOTAL" pattern11a = r'(\d+)\s+Garden\s+Spray\s+Roses?\s+(\w+)\s+(\d+)\s*cm' for match in re.finditer(pattern11a, text, re.IGNORECASE | re.MULTILINE): qty = int(match.group(1)) variety = match.group(2).strip().title() length = match.group(3) raw_name = f"SPRAY ROSE {variety} {length}CM" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=qty)) # UHURU: "N Mixed: Elite Spray Rose VARIETY B-CLIENT STEMS BOXES STEMS PRICE TOTAL" # Формат строки: "2 Mixed: Elite Spray Rose Groove B-BTC. 100 1 200 0,66 132,00" # Где: <box#> Mixed: Elite Spray Rose <Variety> B-<PO> <stems/box> <boxes> <total_stems> <price> <amount> # Первое число = Box # (номер строки), НЕ количество коробок pattern11b = r'\d+\s+Mixed:\s+Elite\s+Spray\s+Rose\s+(\w+)\s+B-\S+\s+(\d+)\s+(\d+)\s+(\d+)\s+[\d,]+\s+[\d,.]+' for match in re.finditer(pattern11b, text, re.IGNORECASE | re.MULTILINE): variety = match.group(1).strip().title() # stems_per_box = match.group(2) — не используем boxes = int(match.group(3)) stems_total = int(match.group(4)) raw_name = f"SPRAY ROSE {variety}" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes, stems_qty=stems_total)) # === Метод 12: Flores San Juan QUARTE === # "1 QUARTE 200 200 25 B-SWARTON-IB-SWARTON- 0.1900 38.0000" # "CARNATION ASSORTED SELECT CO 0603.12.90.00" if not items: # Ищем QUARTE/HALF/FULL boxes pattern12 = r'(\d+)\s+(QUARTE|HALF|FULL)\s+(\d+)\s+(\d+)\s+(\d+)\s+B-\w+' for match in re.finditer(pattern12, text, re.IGNORECASE | re.MULTILINE): qty = int(match.group(1)) box_type = match.group(2).upper() # Попробовать найти название цветка в предыдущих строках raw_name = f"CARNATION {box_type} BOX" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=qty)) # === Метод 13: XPRESSION FLOWERS === # "Customer Box # Boxes Type Variety Bunchs Length Stems" if not items: # Ищем строки с B-CLIENT и количеством коробок pattern13 = r'(\d+)\s+HB\s+(\d+)\s+(\d+)\s+(\w[\w\s]*?)\s+B-\w+' for match in re.finditer(pattern13, text, re.IGNORECASE | re.MULTILINE): boxes = int(match.group(1)) variety = match.group(4).strip().upper() if variety and len(variety) > 2: raw_name = f"ROSE {variety}" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # === Метод 14: Shalimar (Mahee) / East African Growers === # ВСЕГДА запускаем (дополняет Method 8a для mixed roses) # Mixed Roses с B-CLIENT: "N Mixed Roses LENcm [KE/code] B-CLIENT Stems BOXES STEMS RATE TOTAL" pattern14a = r'(\d+)[ \t]+Mixed[ \t]+Roses?[ \t]+(\d+)[ \t]*cm[ \t]+(?:KE/[\w/]+[ \t]+)?B-\w+[ \t]+Stems[ \t]+([\d,]+)' for match in re.finditer(pattern14a, text, re.IGNORECASE | re.MULTILINE): boxes = int(match.group(3).replace(',', '')) length = match.group(2) raw_name = f"MIXED ROSES {length}CM" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Mixed Spray Roses: "N Mixed Spray Roses [KE/code] [B XXX] Stems BOXES RATE TOTAL" pattern14b = r'(\d+)[ \t]+Mixed[ \t]+Spray[ \t]+Roses?[ \t]+(?:KE/[\w/]+[ \t]+)?(?:B[ \t]+\w+[ \t]+)?Stems[ \t]+([\d,]+)' for match in re.finditer(pattern14b, text, re.IGNORECASE | re.MULTILINE): boxes = int(match.group(2).replace(',', '')) raw_name = "MIXED SPRAY ROSES" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Einzelne Rosen Shalimar — ВСЕГДА запускаем (даже если items уже найдены) # "N Variety LENcm KE/code [B CLIENT] Stems BOXES STEMS RATE TOTAL" pattern14c = r'(\d+)[ \t]+([A-Z][\w !]+?)[ \t]+(\d+)[ \t]*cm[ \t]+KE/[\w/]+(?:[ \t]+B[ \t]+\w+)?[ \t]+Stems[ \t]+([\d,]+)[ \t]+([\d,]+)[ \t]+[\d.]+[ \t]+[\d.]+' for match in re.finditer(pattern14c, text, re.MULTILINE): variety = match.group(2).strip().title() boxes = int(match.group(4).replace(',', '')) raw_name = f"ROSE {variety}" if raw_name.upper() not in found_flowers and len(variety) > 2: found_flowers.add(raw_name.upper()) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # === Метод 15: MZURRIE / Winchester Farm (pivot table) === # Формат: строка с названием цветка, затем строка с дробными числами (0.75, 0.84...) if not items: # Исключаемые слова (не цветы) mzurrie_exclude = { 'NAIROBI', 'KENYA', 'EFLOWERS', 'CONSIGNMENT', 'SHIPPING', 'CONTACT', 'CURRENCY', 'TOTAL', 'LABELS', 'COLUMN', 'ROW', 'GRAND', 'DATE', 'INFO', 'ACCOUNT', 'KEPHIS', 'CODE', 'PETER', 'EDDAH', 'NO', 'OF', 'BOXES', 'PREPARED', } lines = text.split('\n') total_boxes_mzurrie = 0 for i, line in enumerate(lines): line_stripped = line.strip() # Строка с названием — только буквы/пробелы/!, без цифр if re.match(r'^[A-Za-z][A-Za-z !©®]+$', line_stripped) and 3 < len(line_stripped) < 40: flower_name = line_stripped.upper().replace('!', '').replace('©', '').replace('®', '').strip() # Исключить не-цветы words = flower_name.split() if all(w in mzurrie_exclude for w in words): continue if flower_name in mzurrie_exclude: continue # Следующая строка должна содержать дробные числа типа 0.75 if i + 1 < len(lines): next_line = lines[i + 1].strip() # Проверяем наличие дробных чисел (признак данных pivot table) if re.search(r'0\.\d+', next_line): # Формат Winchester Farm/MZURRIE: столбцы "No of boxes | packrate | Confirmed Stems | Amount" # повторяются для 50cm/60cm/Total. Box-фракции — только значения <= 1.5 # (0.25, 0.5, 0.75, 1.0). Большие десятичные (60.00, 50.4, 166.8) — это Amount. nums = re.findall(r'(\d+\.\d+)', next_line) if nums: # Берём максимальную box-фракцию (<=1.5) как Total_boxes варьете box_fracs = [float(n) for n in nums if float(n) <= 1.5] if box_fracs: val = max(box_fracs) boxes_qty = int(val) if val == int(val) else int(val) + 1 else: boxes_qty = 1 if flower_name not in found_flowers: found_flowers.add(flower_name) items.append(InvoiceItem(raw_name=flower_name, boxes_qty=max(1, boxes_qty))) total_boxes_mzurrie += max(1, boxes_qty) # === Метод 16: KAZBAZA 4SEZONA (Star Roses / El Campanario) === # Invoice: "ROSE WHITE CANDLELIGHT 50-60CM 31,00 Kg KAZBAZA 1 B 400 $0.400 $160.000" # Packing: "1 H KAZBAZA FULL MONTY 50 16 400 0,30 120,00" if not items: # Invoice format: "ROSE COLOR VARIETY LENGTHCM WEIGHT Kg KAZBAZA BOXES TYPE STEMS $PRICE $TOTAL" pattern16a = r'ROSE\s+[\w\s]+?\s+(\d+(?:-\d+)?)\s*[Cc][Mm]\s+[\d,]+\s+Kg\s+KAZ[A-Z]*\s+(\d+)\s+([A-Z])\s+(\d+)\s+\$' for match in re.finditer(pattern16a, text, re.MULTILINE): boxes = int(match.group(2)) box_type = match.group(3) raw_name = f"ROSE {box_type} BOX {match.group(1)}CM" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Packing format: "N TYPE KAZBAZA VARIETY LEN BUNCHES STEMS PRICE TOTAL" pattern16b = r'(\d+)[ \t]+([A-Z])[ \t]+KAZ[A-Z]*[ \t]+([\w\s©®]+?)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+[\d,]+[ \t]+[\d,]+' for match in re.finditer(pattern16b, text, re.MULTILINE): boxes = int(match.group(1)) box_type = match.group(2) variety = match.group(3).strip().upper() # Убрать ® и © из variety variety = re.sub(r'[©®]', '', variety).strip() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Также "H KAZBAZA VARIETY LEN BUNCHES STEMS PRICE TOTAL" (без номера коробки) pattern16c = r'^\s*([A-Z])\s+KAZ[A-Z]*\s+([\w\s©®]+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+[\d,]+\s+[\d,]+' for match in re.finditer(pattern16c, text, re.MULTILINE): variety = match.group(2).strip().upper() variety = re.sub(r'[©®]', '', variety).strip() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=1)) # === Метод 17: Rosas del Corazon (pipe-separated) === # "1 IXJHB I 150IB-CAMETON I EXPLORER 70CMI $0.450000I $67.50" # Фактически: BOXES TYPE STEMS CLIENT VARIETY LENGTH PRICE TOTAL if not items: # Формат с номером коробки: "1 IXJHB I 150IB-CAMETON I EXPLORER 70CMI $0.450000I $67.50" pattern17 = r'(\d+)[ \t]*I?X?(?:J)?HB[ \t]*I?[ \t]*(\d+)[ \t]*[-\w]+[ \t]*I?[ \t]*([\w ]+?)[ \t]+(\d+)[ \t]*CM[ \t]*I?' for match in re.finditer(pattern17, text, re.IGNORECASE | re.MULTILINE): boxes = int(match.group(1)) stems = int(match.group(2)) variety = match.group(3).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Без номера (continuation): "IXJHB I 150IB-CAMETON I EXPLORER 70CMI" pattern17b = r'I?X?(?:J)?HB[ \t]*I?[ \t]*(\d+)[ \t]*[-\w]+[ \t]*I?[ \t]*([\w ]+?)[ \t]+(\d+)[ \t]*CM[ \t]*I?' for match in re.finditer(pattern17b, text, re.IGNORECASE | re.MULTILINE): stems = int(match.group(1)) variety = match.group(2).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=1)) # === Метод 18: MARIKAZ / Rosas del Corazon (compact format) === # F_MARIKAZ old: "1 IJHB I 400IMARIKAZ I EXPLORER 60CMI $0.500000I $200.00" # F_MARIKAZ new: "IJB2 I 12IMARIKAZ I SPSUMMERROSE 40CMI $0.800000I $9.60" # P_MARIKAZ old: "1 -1 14 1 MARIKAZ EXPLORER 60 25 400 16" # P_MARIKAZ new: "1 -1 JB2 1 MARIKAZ SPSUMMERROSE 40 12 12 1" if not items: # F_MARIKAZ invoice (все форматы): "[N] I[J]HB[N] I STEMS IMARIKAZ I VARIETY LENCMI" pattern18a = r'(?:\d+[ \t]*)?I?[JH]?B\w*[ \t]*I?[ \t]*(\d+)[ \t]*I?MARIKAZ[ \t]*I?[ \t]*([\w]+)[ \t]+(\d+)[ \t]*CM[ \t]*I?' for match in re.finditer(pattern18a, text, re.IGNORECASE | re.MULTILINE): stems = int(match.group(1)) variety = match.group(2).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=1)) # P_MARIKAZ packing (с MARIKAZ): "N -N TYPE BOXES MARIKAZ VARIETY LEN BUNCH STEMS BN" pattern18b = r'(\d+)[ \t]+-\d+[ \t]+(?:\w+[ \t]+)?(\d+)[ \t]+MARIKAZ[ \t]+(\w+)[ \t]+(\d+)[ \t]+\d+[ \t]+\d+' for match in re.finditer(pattern18b, text, re.MULTILINE): boxes = int(match.group(2)) variety = match.group(3).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # P_MARIKAZ old format (без MARIKAZ): "N -N HB BOXES VARIETY LEN BUNCH STEMS TOTAL" pattern18c = r'(\d+)[ \t]+-\d+[ \t]+(?:HB|JB\d+|XJHB)[ \t]+(\d+)[ \t]+(\w+)[ \t]+(\d+)[ \t]+\d+[ \t]+\d+' for match in re.finditer(pattern18c, text, re.MULTILINE): boxes = int(match.group(2)) variety = match.group(3).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # === Метод 19: ALAIA FLOWERS === # "1 0.50 HB 1 1 EXPLORER 25 60 4 4 100 0,330000 33,00" # "- - HB 1 1 NINA 25 60 4 4 100 0,330000 33,00" if not items: # Первая строка с номером: "N DECIMAL HB N N VARIETY LEN BUNCHES ..." pattern19 = r'(\d+)[ \t]+([\d.]+)[ \t]+HB[ \t]+(\d+)[ \t]+(\d+)[ \t]+([\w ]+?)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+[\d,]+[ \t]+[\d,]+' for match in re.finditer(pattern19, text, re.MULTILINE): full_boxes = float(match.group(2)) variety = match.group(5).strip().upper() tb = int(match.group(8)) # TB column raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) boxes = max(1, int(full_boxes)) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Continuation: "- - HB 1 1 VARIETY ..." pattern19b = r'-[ \t]+-[ \t]+HB[ \t]+(\d+)[ \t]+(\d+)[ \t]+([\w ]+?)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+[\d,]+[ \t]+[\d,]+' for match in re.finditer(pattern19b, text, re.MULTILINE): variety = match.group(3).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=1)) # === Метод 20: Khad Roses === # "1 - 1 B-CAMETON (SO) 1 HALF BE SWEET 60 25 1 25 0.450 11.250" # Continuation: "COUNTRY BLUES 60 25 4 100 0.450 45.000" if not items: # Основной формат: "ORDER - ORDER B-CLIENT (XX) N HALF/FULL VARIETY LEN BUNCHES BUNCHES STEMS UNIT TOTAL" pattern20a = r'(\d+)\s+-\s+\d+\s+B-\w+\s+\(\w+\)\s+(\d+)\s+(HALF|FULL)\s+([\w\s]+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+[\d.]+\s+[\d.]+' for match in re.finditer(pattern20a, text, re.IGNORECASE | re.MULTILINE): half_full_boxes = int(match.group(2)) box_type = match.group(3).upper() variety = match.group(4).strip().upper() bunches_per_box = int(match.group(6)) stems_total = int(match.group(8)) # Для HALF box: 1 HALF box = 0.5 full box full_equiv = half_full_boxes * (0.5 if box_type == 'HALF' else 1.0) boxes = max(1, int(full_equiv)) if full_equiv > 0 else half_full_boxes raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Continuation lines (без order prefix): "VARIETY LEN BUNCHES STEMS UNIT TOTAL" pattern20b = r'^([A-Z][\w\s]+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+[\d.]+\s+[\d.]+$' for match in re.finditer(pattern20b, text, re.MULTILINE): variety = match.group(1).strip().upper() if variety and len(variety) > 2 and variety not in ('TOTAL', 'BOX TYPE', 'DESCRIPTION'): stems = int(match.group(4)) raw_name = f"ROSE {variety}" if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=1)) # === Метод 21: Tessa Corporation (детальные строки) === # "Boxes Order BoxT. Loc. Description Len Bun/Box Stems Price Total Label" # "HYDRANGEA GREEN QB TESSA- 1 911814177 EMERALD 60 35 35 $1.00 $35.00" if not items: # Ищем QB с числами и цветами pattern21 = r'(\d+)\s+\d+\s+QB\s+\w+-\s*\n?\s*(\d+)\s+(\w+)\s+(\d+)\s+(\d+)\s+(\d+)\s+\$' for match in re.finditer(pattern21, text, re.MULTILINE): boxes = int(match.group(1)) variety = match.group(3).strip().upper() raw_name = variety if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Альтернативный формат Tessa: ищем названия цветов рядом с QB if not items and 'QB' in text_upper and 'TESSA' in text_upper: for keyword in self.flower_keywords: if keyword in text_upper: # Ищем "N ... QB ... FLOWER ... LEN BUNCHES STEMS $PRICE $TOTAL" pat = rf'(\d+)\s+\d+\s+QB.*?{keyword}.*?(\d+)\s+(\d+)\s+(\d+)\s+\$' for match in re.finditer(pat, text, re.IGNORECASE | re.MULTILINE): boxes = int(match.group(1)) raw_name = keyword if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # === Метод 23: SOJANMI SPRINGFIELDS / Amor and Omang (Кения) === # "1 70101940 mix inA Box;Stem length 35 4 600 2,400 0.080 0.00 192.00 B-SKLADULI" # Структура: SN ITEMCODE(8 цифр) DESC BOXES PACKRATE QUANTITY PRICE TAX% TOTAL FARMCODE # QUANTITY = stems. Описание оканчивается через ";Stem length" / ":Stem [Ll]ength" if not items and 'SOJANMI' in text.upper(): pattern23 = r'^\s*\d+\s+\d{8}\s+(.+?)\s+(?:Stem\s+[Ll]ength(?:\s+\d+\s*(?:cm)?)?\s+)?(\d+)\s+(\d+)\s+([\d,]+)\s+[\d.]+\s+[\d.]+\s+[\d.]+\s+[A-Z]' for match in re.finditer(pattern23, text, re.MULTILINE): desc = match.group(1).strip() boxes = int(match.group(2)) stems = int(match.group(3).replace(',', '')) # позиционно packrate, но часто = 500/600 как stems/box quantity = int(match.group(4).replace(',', '')) # QUANTITY = фактические стебли # Очистить описание от ";Stem length ..." / ":Stem length ..." variety = re.split(r'[;:]?\s*Stem\s+[Ll]ength', desc)[0].strip() if not variety or len(variety) < 2: continue # Пропустить documentation charge и подобные не-цветы if 'DOCUMENTATION' in variety.upper() or 'CHARGE' in variety.upper(): continue raw_name = variety.upper() if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes, stems_qty=quantity)) # === Метод 22: XPRESSION FLOWERS === # "1-1 1.00 HB MANDARIN XPRESSION 4 60 25 100 $ 0,55 $ 55,00" # ВСЕГДА запускаем (может дополнять другие методы) pattern22 = r'(\d+-\d+)[ \t]+([\d.]+)[ \t]+HB[ \t]+([\w ]+?)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+\$' for match in re.finditer(pattern22, text, re.MULTILINE): full_boxes = float(match.group(2)) variety = match.group(3).strip().upper() raw_name = f"ROSE {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) boxes = max(1, int(full_boxes)) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes)) # Continuation (MIXTO): "1-1 MIXTO GARDENS 10 60 25 250 $ 0,50 $ 125,00" pattern22b = r'(\d+-\d+)[ \t]+MIXTO[ \t]+([\w][\w ]*?)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+(\d+)[ \t]+\$' for match in re.finditer(pattern22b, text, re.MULTILINE): variety = match.group(2).strip().upper() raw_name = f"MIXTO {variety}" if raw_name not in found_flowers and len(variety) > 2: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=1)) # Проверка: есть ли реальные позиции (не фейковые заголовки) _has_real_items = any(it.stems_qty > 0 for it in items) # === Метод 23: Формат eFlowers B.V. (Голландия) === # Инвойс содержит итоговую строку формата: # "06031400 Chrysanthemum 2280 209.6 224.6 1240.00" # И/или детальные строки: "6 CHR T ALTAJ 100g Linflowers 70 100 840 Export box ..." # Якоря: "eFlowers B.V." или "Export box" в тексте if not _has_real_items and 'EFLOWERS' in text_upper and ('EXPORT BOX' in text_upper or 'TOTAL BOXES LOADED' in text_upper): # Очищаем фейковые items перед заполнением items = [] found_flowers = set() # Метод А: итоговая строка "<HSCode8> <FlowerName> <TotalStems> <Net> <Gross> <Value>" efl_summary = re.compile( r'^(\d{8})\s+([A-Za-z]+(?:\s+[A-Za-z]+)?)\s+(\d+)\s+[\d.]+\s+[\d.]+\s+[\d,.]+\s*$', re.MULTILINE ) efl_boxes = 0 # Извлекаем Total boxes loaded tb_m = re.search(r'Total\s+boxes\s+loaded:\s*(\d+)', text, re.IGNORECASE) if tb_m: efl_boxes = int(tb_m.group(1)) for m in efl_summary.finditer(text): flower_name = m.group(2).strip() total_stems = int(m.group(3)) if flower_name and total_stems > 0 and flower_name.upper() not in found_flowers: found_flowers.add(flower_name.upper()) items.append(InvoiceItem(raw_name=flower_name, boxes_qty=efl_boxes, stems_qty=total_stems)) # Метод Б: детальные строки (если итог не найден) if not items: # "<boxes> <description...> <grower> <length> <weight> <stems> Export box ..." efl_detail = re.compile( r'^(\d+)\s+([A-Z][A-Z\s]+?)\s+\w+\s+(\d{2,3})\s+\d+\s+(\d+)\s+Export\s+box', re.MULTILINE | re.IGNORECASE ) efl_agg: dict[str, dict] = {} # flower -> {boxes, stems} for m in efl_detail.finditer(text): boxes_v = int(m.group(1)) descr = m.group(2).strip() stems_v = int(m.group(4)) # Нормализуем: CHR T -> CHRYSANTHEMUM, ROS -> ROSA, и т.п. if descr.startswith('CHR'): key = 'CHRYSANTHEMUM' elif descr.startswith('ROS'): key = 'ROSA' elif descr.startswith('ALT') or descr.startswith('ALS'): key = 'ALSTROEMERIA' elif descr.startswith('GYP'): key = 'GYPSOPHILA' elif descr.startswith('LIL'): key = 'LILIUM' else: key = descr.split()[0] if key not in efl_agg: efl_agg[key] = {'boxes': 0, 'stems': 0} efl_agg[key]['boxes'] += boxes_v efl_agg[key]['stems'] += stems_v for fname, data in efl_agg.items(): if data['stems'] > 0 and fname.upper() not in found_flowers: found_flowers.add(fname.upper()) items.append(InvoiceItem(raw_name=fname, boxes_qty=data['boxes'], stems_qty=data['stems'])) # === Метод 24: R&M Roses / Ecuador small farms === # Формат: "1 H PINK FLOY 50cm 25 16 400 0.17 68.00" # Якоря: "Farm Information" + "Variety" + "cm" в таблице if not _has_real_items and 'FARM INFORMATION' in text_upper and 'VARIETY' in text_upper and re.search(r'\d{2,3}\s*cm', text, re.IGNORECASE): items = [] found_flowers = set() # Pattern: <Boxes> <BoxType> <Variety> <Length>cm <StemsPerBunch> <Bunches> <Stems> <PVP> <Amount> rm_pattern = re.compile( r'^(\d+)\s+([HQFE]B?)\s+(.+?)\s+(\d{2,3})\s*cm\s+(\d+)\s+(\d+)\s+(\d+)\s+[\d.]+\s+[\d.]+', re.MULTILINE ) rm_agg: dict[str, dict] = {} # "ROSA NN" -> {boxes, stems} for m in rm_pattern.finditer(text): boxes_v = int(m.group(1)) length_cm = int(m.group(4)) stems_v = int(m.group(7)) cm_rounded = (length_cm // 10) * 10 size_key = f'ROSA {cm_rounded}' if size_key not in rm_agg: rm_agg[size_key] = {'boxes': 0, 'stems': 0} rm_agg[size_key]['boxes'] += boxes_v rm_agg[size_key]['stems'] += stems_v for fname, data in rm_agg.items(): if data['stems'] > 0 and fname.upper() not in found_flowers: found_flowers.add(fname.upper()) items.append(InvoiceItem(raw_name=fname, boxes_qty=data['boxes'], stems_qty=data['stems'])) # === Метод 25: COMMERCIAL INVOICE Ecuador (Sberbank/Panatlantic) === # Якоря: "COMMERCIAL INVOICE" + "HTS" + "TOTAL PIECES" # Итоговая строка: "<Flower> <ScientificGenus> <ScientificSpecies> <CommonName> <Pieces> <Stems> <Price> <Value>" if not _has_real_items and 'COMMERCIAL INVOICE' in text_upper and 'HTS' in text_upper and 'TOTAL PIECES' in text_upper: items = [] found_flowers = set() # Извлекаем из строки данных: <Pcs> <BoxType> <Description...> <Weight> <CM> <Bunch> <StemsPerBunch> <Price> <TotalStems> <TotalPrice> ci_pattern = re.compile( r'^(\d+)\s+[A-Z]{2}\s+(.+?)\s+[A-Z]+\s+\d+\s+\d+\s+\d+\s+\d+\s+[\d.]+\s+(\d+)\s+[\d.]+\s*$', re.MULTILINE ) for m in ci_pattern.finditer(text): boxes_v = int(m.group(1)) descr = m.group(2).strip() stems_v = int(m.group(3)) # Извлекаем первое слово как flower name flower_name = descr.split()[0] if descr else 'FLOWER' if flower_name.upper() not in found_flowers: found_flowers.add(flower_name.upper()) items.append(InvoiceItem(raw_name=flower_name, boxes_qty=boxes_v, stems_qty=stems_v)) # Фолбэк: секция Single Flowers с научной классификацией if not items: sf_pattern = re.compile( r'^([A-Z][a-z]+(?:\s+[a-z]+)?)\s+[A-Z][a-z]+\s+[A-Za-zóáéíú]+\s+[A-Z][a-z]+\s+(\d+)\s+(\d+)\s+[\d.]+\s+[\d.]+', re.MULTILINE ) for m in sf_pattern.finditer(text): flower_name = m.group(1).strip() pieces = int(m.group(2)) stems_v = int(m.group(3)) if flower_name.upper() not in found_flowers and stems_v > 0: found_flowers.add(flower_name.upper()) items.append(InvoiceItem(raw_name=flower_name, boxes_qty=pieces, stems_qty=stems_v)) # === Метод 26: Cantiza Floral Group === # Якорь: "CANTIZA FLORAL" # Строка: "1 HB CAN-120X32X30.8 EXPLORER 50CM N 25ST CZ ROSES C2 12 $6.2500 300 $0.2500 $75.00" # Итого: "1 TOTALS 12 300 $75.00" if not _has_real_items and 'CANTIZA FLORAL' in text_upper: items = [] found_flowers = set() # Ищем строку TOTALS для извлечения boxes и stems ct_totals = re.search( r'^(\d+)\s+TOTALS\s+\d+\s+(\d+)\s+\$[\d.]+', text, re.MULTILINE ) if ct_totals: boxes_v = int(ct_totals.group(1)) stems_v = int(ct_totals.group(2)) # Определяем тип цветка из строки SPECIES species_m = re.search(r'ROSES|CARNATION|CHRYSANTHEMUM|GYPSOPHILA|ALSTROEMERIA', text_upper) flower_name = species_m.group(0).capitalize() if species_m else 'ROSA' # Определяем ростовку из PRODUCT ("50CM", "60CM") cm_m = re.search(r'(\d{2,3})\s*CM', text_upper) if cm_m and flower_name.upper() in ('ROSES', 'ROSA'): cm_val = int(cm_m.group(1)) cm_rounded = (cm_val // 10) * 10 raw_name = f'ROSA {cm_rounded}' else: raw_name = flower_name.upper() if raw_name == 'ROSES': raw_name = 'ROSA' if raw_name not in found_flowers: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes_v, stems_qty=stems_v)) # === Метод 27: BLESS FLOWER / MILROSE / Ecuador simple QB/HB lines === # Формат: "1 QB CANDLELIGHT 70 4 25 100 0.240 24.000$" # Формат: "1 HB MIX 60 12 12 25 300 0.15 45.00" # Якоря: строки начинаются с "N QB/HB/FB <VARIETY> <2-3 digit length>" # НЕ совпадает с R&M Roses (у него "cm" суффикс) и eFlowers if not _has_real_items: m27_pattern = re.compile( r'^(\d+)\s+(QB|HB|FB)\s+([A-Z][A-Z ]*?)\s+(\d{2,3})\s+([\d\s]+?)\s+[\d.]+\s+[\d.,]+\$?\s*$', re.MULTILINE ) m27_matches = list(m27_pattern.finditer(text)) if m27_matches: items = [] found_flowers = set() for m in m27_matches: boxes_v = int(m.group(1)) box_type = m.group(2) # QB=0.25, HB=0.5, FB=1.0 variety = m.group(3).strip() length_cm = int(m.group(4)) # Извлекаем total_stems = последний integer в группе 5 nums_in_middle = [int(x) for x in m.group(5).split() if x.isdigit()] total_stems = nums_in_middle[-1] if nums_in_middle else 0 # Округляем длину до ближайших 10 cm_rounded = (length_cm // 10) * 10 raw_name = f'ROSA {cm_rounded}' if raw_name in found_flowers: for it in items: if it.raw_name == raw_name: it.stems_qty += total_stems it.boxes_qty += boxes_v break else: found_flowers.add(raw_name) items.append(InvoiceItem(raw_name=raw_name, boxes_qty=boxes_v, stems_qty=total_stems)) # === Метод 28: Condor Andino (Colombia) === # Формат строки: "3.00 QB 18 30015 HYD LIGHT BLUE JUMBO 0603199010 B-IGORIVA 54 1.1 59.40" # <Boxes> <BoxType> <StemsPerBox> <ItemCode> <Description> <HTS 10 digits> <B-Mark> <TotalStems> <Price> <Total> if not _has_real_items and 'CONDOR ANDINO' in text_upper: m28_pattern = re.compile( r'^(\d+\.?\d*)\s+(?:QB|HB|FB)\s+\d+\s+(?:\d{4,5}\s+)?(.+?)\s+(\d{10})\s+B-\S+\s+(\d+)\s+[\d.]+\s+[\d.]+', re.MULTILINE ) m28_matches = list(m28_pattern.finditer(text)) if m28_matches: items = [] found_flowers = set() for m in m28_matches: boxes_v = int(float(m.group(1))) descr = m.group(2).strip().upper() stems_v = int(m.group(4)) # Определяем тип цветка по сокращению if descr.startswith('HYD'): flower_name = 'HYDRANGEA' elif 'ROSE' in descr or descr.startswith('ROS'): flower_name = 'ROSA' elif descr.startswith('CARN'): flower_name = 'CARNATION' elif descr.startswith('ALSTR'): flower_name = 'ALSTROEMERIA' elif descr.startswith('GYP'): flower_name = 'GYPSOPHILA' elif descr.startswith('CHRY'): flower_name = 'CHRYSANTHEMUM' else: flower_name = descr.split()[0] if descr.split() else 'FLOWER' # Агрегируем по типу цветка if flower_name in found_flowers: for it in items: if it.raw_name == flower_name: it.stems_qty += stems_v it.boxes_qty += boxes_v break else: found_flowers.add(flower_name) items.append(InvoiceItem(raw_name=flower_name, boxes_qty=boxes_v, stems_qty=stems_v)) # === Пост-обработка: удалить полные строки с boxes_qty=0 если есть нормальные items === proper_names = {it.raw_name.upper() for it in items if it.boxes_qty > 0} if proper_names: cleaned = [] for it in items: if it.boxes_qty == 0: # Удалить если есть proper item начинающийся с этого имени if any(pn.startswith(it.raw_name.upper()) for pn in proper_names): continue # Удалить длинные строки (полные строки из Метод 6) if len(it.raw_name) > 40: continue cleaned.append(it) items = cleaned # === Пост-обработка: удалить фейк-items (заголовки таблиц/мета) === # Фейки возникают в BellaRosa/ANNE/Qualisa/Muchflowers из строк вида "# BOX PRODUCT SPECIES LABEL", "Species Stems Price", "Country Destination Federation" fake_tokens = { 'SPECIES', 'STEMS', 'STEM', 'PRICE', 'DESTINATION', 'LOCATION', 'BUNCH', 'BUN', 'TOTAL', 'LABEL', 'SUBTOTAL', 'HAWB', 'MAWB', 'AWB', 'FEDERATION', 'KAZAKHSTAN', 'COUNTRY', 'FINAL', 'CARGO', 'CODE', 'PRODUCT', 'VARIETY', 'ORDER', 'DAE', 'AIRLINE', 'TYPE', 'BOX', 'NUMBER', 'UNIT', 'GROSS', 'NET', 'AUXILIAR', 'USD', 'EUR', 'AMOUNT', 'QTY', 'PACKS', 'DESCRIPTION', 'INCOTERM', 'MID', 'TRM', 'CONSTANCIA', 'FCA', 'FREIGHT', 'FORWARDER', 'INVOICE', 'PACKING', 'CAJAS', 'LONGITUD', 'RAMO', 'TALLO', 'TALLOS', 'PRECIO', 'PIECES', 'FULLS', } # Reserved-метаданные — все токены такие → фейк def _is_fake(name: str) -> bool: up = name.upper().strip() tokens = up.split() if not tokens: return True # Все токены в чёрном списке? if all(t in fake_tokens for t in tokens): return True # Сочетание двух+ фейк-токенов подряд без осмысленного варьете (BOX HB, HB XXL, HB TOTAL FB) first = tokens[0] if first in {'HB', 'FB', 'QB', 'JB', 'EB', 'BOX'} and len(tokens) >= 2: if tokens[1] in fake_tokens or tokens[1] in {'XXL', 'XL', 'JUMBO'}: return True # Префикс цветка (ROSE/CARN/ALSTRO/HYD/GYP) + только размер-токен → фейк flower_prefixes = {'ROSE', 'ROSA', 'ROSES', 'CARNATION', 'ALSTROEMERIA', 'HYDRANGEA', 'GYPSOPHILA'} if first in flower_prefixes and len(tokens) >= 2: if all(t in {'XXL', 'XL', 'JUMBO', 'HB', 'FB', 'QB', 'EB'} for t in tokens[1:]): return True return False items = [it for it in items if not _is_fake(it.raw_name)] print(f"[DEBUG] Извлечено позиций: {len(items)}") for item in items: print(f"[DEBUG] - {item.raw_name}: {item.boxes_qty} коробок") return items def _extract_total_boxes(self, text: str) -> int: """Извлечь общее количество коробок из инвойса.""" # Паттерны для разных форматов (в порядке приоритета): patterns = [ # "BOXES POR TYPE: Full: 0 Half: 0 Quar: 5 Octv: 0" — физические коробки (r'BOXES\s+POR\s+TYPE[:\s]*Full[:\s]*(\d+)\s+Half[:\s]*(\d+)\s+Quar[:\s]*(\d+)\s+Octv[:\s]*(\d+)', -1), # "5 TOTAL PIECES" (Superior Blooms / Tessa) — ЧИСЛО ПЕРЕД TOTAL (r'(\d+)\s+TOTAL\s+PIECES', 1), # "4 < TOTAL PIECES" (LA CONCHITA with < symbol) (r'(\d+)\s*<\s*TOTAL\s+PIECES', 1), # "1.25 TOTAL EQUIVALENT FULL BOXES" (Superior Blooms) — эквивалент, округляем вверх (r'([\d.]+)\s+TOTAL\s+EQUIVALENT\s+FULL\s+BOXES', 1), # "TOTALFULL 2.000" (MARIKAZ / Rosas del Corazon) (r'TOTALFULL\s+([\d.]+)', 1), # "Total boxes 5" (r'Total\s+boxes\s*[:\s]*(\d+)', 1), # "Total 9.00" (PJ Dave - total boxes) (r'Total\s+([\d.]+)\s+[\d,]+\s*$', 1), # "TOTAL 92 1850 772,50" (KAZBAZA packing - boxes, stems, total) (r'TOTAL\s+(\d+)\s+(\d+)\s+[\d,]+', 1), # "Grand Total 0.5 300 150 126 1.5 450 328.5 2 300 600 454.5" (MZURRIE) (r'Grand\s+Total.*?(\d+\.\d+)\s+\d+\s+\d+\s+[\d.]+\s+(\d+\.\d+)\s+\d+\s+\d+\s+[\d.]+\s+(\d+)', 3), # "3 TOTALS" (Tessa Corporation) (r'(\d+)\s+TOTALS', 1), # "Total pieces 3" (NATURAL FLOWERS) — ЧИСЛО ПОСЛЕ Total pieces (r'Total\s+pieces\s*[:\s]*(\d+)', 1), # "Total Piezas: 1" (ALAIA) (r'Total\s+Piezas:\s*(\d+)', 1), # "BXS ... 1 30" (SJ Flowers - первое число после BXS) (r'BXS\s+.*?(\d+)\s+\d+\s*$', 1), # QB-R3 или QB-R 3 (r'QB-R(\d+)', 1), # "1 Full Equivalent" (GLOW FLOWERS) (r'(\d+)\s+Full\s+Equivalent', 1), # "TOTAL BOX 2" (RASUL Excel) (r'TOTAL\s+BOX\s+(\d+)', 1), # "HALF 1.000" (Khad Roses box type qtty) (r'(?:HALF|FULL)\s+([\d.]+)', 1), # "Page Total 10 4,700 694.20" (Shalimar) (r'Page\s+Total\s+(\d+)', 1), ] for pattern, group in patterns: match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) if match: try: if group == -1: # Специальный случай BOXES POR TYPE: суммируем Full + Half + Quar + Octv total = sum(int(match.group(i)) for i in range(1, 5)) return total # Для дробных чисел типа 1.25 округляем вверх val = float(match.group(group)) return int(val) if val == int(val) else int(val) + 1 except (ValueError, IndexError): continue return 0 def _extract_total_stems(self, text: str) -> int: """Извлечь общее количество стеблей.""" # Паттерны: "TOTAL STEMS 105", "105 Stems", "Total stems: 105" # ВАЖНО: [ \t]* вместо \s* — чтобы не захватить число из соседней строки # (например, "NIF: 504409760749\nSTEMS" — NIF не должен попасть в стебли) patterns = [ r'TOTAL\s+STEMS[ \t]*[:\s]*([\d,]+)', r'TOT\.?\s+STEMS[ \t]*[:\s]*([\d,]+)', r'(\d+)[ \t]+Stems\b', r'Total\s+stems[ \t]*[:\s]*([\d,]+)', # "TOTAL 31 12020 1,202.00" (Ethamfresh Kenya OCR) — boxes stems amount # Позиционный: TOTAL<sep>\d+<sep>\d+<sep>\d+.\d… в конце строки/блока r'^TOTAL[ \t\n\r]+\d+[ \t\n\r]+([\d,]+)[ \t\n\r]+[\d,]+\.\d', ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) if match: try: val = int(match.group(1).replace(',', '')) # Санити-чек: реальные инвойсы не бывают > 1 млн стеблей. # Отсекает случаи, когда в число попал NIF/RUT/штрихкод. if val > 1_000_000: continue return val except ValueError: continue return 0 class FolderScanner: """Сканер папок с инвойсами.""" def __init__(self, flower_db: FlowerDatabase): self.parser = InvoiceParser(flower_db) def scan_directory(self, directory: str) -> dict[str, list[InvoiceData]]: """ Сканировать директорию и парсить все PDF инвойсы. Поддерживает два режима: 1. Если в папке есть PDF файлы - парсит их как одного клиента 2. Если в папке есть подпапки - парсит каждую подпапку как клиента Args: directory: Путь к директории с инвойсами Returns: Словарь {client_name: [InvoiceData, ...]} """ results = {} # Сначала проверим - есть ли PDF файлы прямо в выбранной папке pdf_files = self._find_pdf_files(directory) if pdf_files: # Режим 1: PDF файлы в выбранной папке folder_name = os.path.basename(directory) client_name = extract_client_from_folder(folder_name) print(f"[DEBUG] Найдено {len(pdf_files)} PDF в папке {folder_name}, клиент: {client_name}") invoices = [] for pdf_path in pdf_files: print(f"[DEBUG] Парсинг: {pdf_path}") invoice = self.parser.parse_file(pdf_path, folder_name) invoices.append(invoice) print(f"[DEBUG] Результат: {len(invoice.items)} позиций, {invoice.total_boxes} коробок") if invoices: results[client_name] = invoices # Также проверим подпапки for item in os.listdir(directory): item_path = os.path.join(directory, item) if os.path.isdir(item_path): # Это подпапка - возможно клиент sub_pdf_files = self._find_pdf_files(item_path) if sub_pdf_files: folder_name = item client_name = extract_client_from_folder(folder_name) print(f"[DEBUG] Найдено {len(sub_pdf_files)} PDF в подпапке {folder_name}, клиент: {client_name}") invoices = [] for pdf_path in sub_pdf_files: print(f"[DEBUG] Парсинг: {pdf_path}") invoice = self.parser.parse_file(pdf_path, folder_name) invoices.append(invoice) print(f"[DEBUG] Результат: {len(invoice.items)} позиций, {invoice.total_boxes} коробок") if invoices: # Если клиент уже существует (из основной папки), объединяем if client_name in results: results[client_name].extend(invoices) else: results[client_name] = invoices print(f"[DEBUG] Итого найдено клиентов: {len(results)}") return results def _find_pdf_files(self, directory: str) -> list[str]: """Найти все PDF/XLS/XLSX файлы в директории.""" pdf_files = [] try: for filename in os.listdir(directory): file_path = os.path.join(directory, filename) if os.path.isfile(file_path): ext = os.path.splitext(filename)[1].lower() if ext in ('.pdf', '.xls', '.xlsx', '.doc'): pdf_files.append(file_path) except Exception as e: print(f"[DEBUG] Ошибка чтения директории {directory}: {e}") return pdf_files def save_invoices_to_excel(invoices: list, output_path: str) -> bool: """ Сохранить список InvoiceData в Excel файл для просмотра/редактирования. Args: invoices: Список InvoiceData объектов output_path: Путь для сохранения Excel файла Returns: True если успешно, False иначе """ try: wb = openpyxl.Workbook() ws = wb.active ws.title = "Распознанные инвойсы" # Заголовки headers = [ "Файл", "Клиент", "Поставщик", "Номер инвойса", "Дата", "Название (исходное)", "Название (распознанное)", "Коробок", "Стеблей", "Тип коробки", "Сопоставлено" ] for col, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=header) cell.font = openpyxl.styles.Font(bold=True) cell.fill = openpyxl.styles.PatternFill( start_color="E0E0E0", end_color="E0E0E0", fill_type="solid" ) # Данные row = 2 for invoice in invoices: file_name = os.path.basename(invoice.file_path) if not invoice.items: # Если нет позиций, записать пустую строку с информацией об инвойсе ws.cell(row=row, column=1, value=file_name) ws.cell(row=row, column=2, value=invoice.client_name) ws.cell(row=row, column=3, value=invoice.supplier) ws.cell(row=row, column=4, value=invoice.invoice_number) ws.cell(row=row, column=5, value=invoice.date) ws.cell(row=row, column=11, value="Нет позиций") row += 1 else: # Записать каждую позицию for item in invoice.items: ws.cell(row=row, column=1, value=file_name) ws.cell(row=row, column=2, value=invoice.client_name) ws.cell(row=row, column=3, value=invoice.supplier) ws.cell(row=row, column=4, value=invoice.invoice_number) ws.cell(row=row, column=5, value=invoice.date) ws.cell(row=row, column=6, value=item.raw_name) ws.cell(row=row, column=7, value=item.matched_flower or "") ws.cell(row=row, column=8, value=item.boxes_qty) ws.cell(row=row, column=9, value=item.stems_qty) ws.cell(row=row, column=10, value=item.box_type) ws.cell(row=row, column=11, value="Да" if item.is_matched else "Нет") row += 1 # Автоматическая ширина колонок for col in ws.columns: max_length = max((len(str(c.value)) if c.value else 0) for c in col) ws.column_dimensions[col[0].column_letter].width = max(max_length + 2, 12) wb.save(output_path) print(f"[DEBUG] Сохранено в Excel: {output_path} ({len(invoices)} инвойсов)") return True except Exception as e: print(f"[ERROR] Ошибка сохранения в Excel: {e}") import traceback traceback.print_exc() return False def parse_invoices_from_excel(file_path: str) -> list: """ Парсить данные из Excel файла (после редактирования пользователем). Args: file_path: Путь к Excel файлу Returns: Список InvoiceData объектов """ from models import InvoiceData, InvoiceItem try: wb = openpyxl.load_workbook(file_path) ws = wb.active # Пропустить заголовки (строка 1) invoices_dict = {} # file_path -> InvoiceData for row in ws.iter_rows(min_row=2, values_only=True): if not row or not row[0]: continue file_name = str(row[0] or "") client_name = str(row[1] or "") supplier = str(row[2] or "") invoice_number = str(row[3] or "") date = str(row[4] or "") raw_name = str(row[5] or "") matched_flower = str(row[6] or "") or None boxes_qty = int(row[7] or 0) if row[7] else 0 stems_qty = int(row[8] or 0) if row[8] else 0 box_type = str(row[9] or "") is_matched_str = str(row[10] or "") is_matched = is_matched_str.lower() in ("да", "yes", "true", "1") # Создать или получить InvoiceData key = f"{file_name}_{invoice_number}" if key not in invoices_dict: invoices_dict[key] = InvoiceData( file_path=file_name, invoice_number=invoice_number, date=date, client_name=client_name, supplier=supplier ) # Добавить позицию if raw_name or matched_flower: item = InvoiceItem( raw_name=raw_name, matched_flower=matched_flower if matched_flower else None, boxes_qty=boxes_qty, stems_qty=stems_qty, box_type=box_type, is_matched=is_matched ) invoices_dict[key].items.append(item) invoices_dict[key].total_boxes += boxes_qty invoices_dict[key].total_stems += stems_qty result = list(invoices_dict.values()) print(f"[DEBUG] Распознано из Excel: {len(result)} инвойсов") return result except Exception as e: print(f"[ERROR] Ошибка парсинга Excel: {e}") import traceback traceback.print_exc() return []