/
Paff
/
declarant
Обзор
Документация
Войти
/
Paff
/
declarant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
import_writer.py
935 строк
41 KB
1pash1985-gif
856MLT: Европа — NL-only колонки без дублей; Импорт — автосоздание колонок
26 июл 2026, 16:14
26 июл 2026, 16:14
0f5bf30
Код
Авторство
О чём код?
# -*- coding: utf-8 -*- """Writer для «Таблица импорт NNNMMM.xls». Формат шаблона: Мульти-листовой .xls (13 листов): Эквадор, Колумбия, Kenia, Израиль, Тайланд, Afrika, Uganda, Эфиопия, Costa Rica, CHILE, Роза (справочник, skip), Т-1 (транзит, skip), Test (skip). Каждый лист-страна имеет структуру: R1 col 2+: маркировки клиентов (заголовки-столбцы) R2 col 1: 'ЭКВАДОР'/'КОЛУМБИЯ'/... (страна) R2 col 2+: фикс. номера (AWB-подобные суммы, шаблонные) R3+ col 1: метки-строки: — Ecuador/Colombia/Kenia: 'Роза NNN см' + названия семейств — Israel/Thailand/Costa Rica: только названия семейств — Kenia: 'Роза 32/35/37/42/43/52/53/62/72 см' (доп. градации) — Uganda/Ethiopia: 'Роза NNN см' + короткий список — Africa: только семейства (широкий список) В ячейку (row, col) пишутся стебли ЦВЕТОК×КЛИЕНТ. Ниже строки-семейства идут 'Брутто авб', 'Мест роза' и т.д. (боксы) — пока не заполняем (v1). Ограничение: openpyxl не пишет .xls → сохраняем как .xlsx (потеря форматирования — компромисс: клиент открывает файл, данные в порядке). Использование: from import_writer import fill_import_table fill_import_table(template_xls, output_xlsx, packing_sheets) """ from __future__ import annotations import os import re from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from openpyxl import Workbook from python_calamine import CalamineWorkbook try: from flower_taxonomy import classify as _classify_taxonomy except ImportError: _classify_taxonomy = lambda v: '' # === Маппинг PackingSheet.country → имя листа === # packing_parser нормализует страну в англ. или "?", тут учитываем оба варианта. COUNTRY_TO_SHEET: dict[str, str] = { 'ecuador': 'Эквадор', 'colombia': 'Колумбия', 'kenya': 'Kenia', 'israel': 'Израиль', 'thailand': 'Тайланд', 'africa': 'Afrika', 'uganda': 'Uganda', 'ethiopia': 'Эфиопия', 'costa rica': 'Costa Rica', 'india': 'CHILE', # шаблонный «CHILE» на самом деле INDIA 'chile': 'CHILE', # Netherlands НЕ пишем — это делает europa_writer } # Служебные листы, которые не пишем SKIP_SHEETS = {'Роза', 'Т-1', 'Test'} # === Нормализация меток-строк (row-labels) === # Каждая метка → каноническое семейство (ROSA/CHRYSANTHEMUM/…) + опц. длина # Порядок правил важен: сначала специальные (Роза NNN см), потом общие. _ROSE_LEN_RE = re.compile(r'(?:роза|rosa|rose)\s*(\d{2,3})(?:\s*см|\s*cm)?', re.IGNORECASE) def _normalize_label(label: str) -> tuple[str, int] | None: """'Роза 70 см' → ('ROSA', 70); 'Хризантема' → ('CHRYSANTHEMUM', 0).""" if not label: return None s = str(label).strip() # Нормализация кириллической 'с' в лат-словах ('Hyperiсum' и т.п.) s_lat = s.replace('С', 'C').replace('с', 'c') m = _ROSE_LEN_RE.search(s) if m: return ('ROSA', int(m.group(1))) s_up = s_lat.upper().strip() # Специальные метки роз без длины if s_up in ('РОЗА БУТОН', 'РОЗА СТЕБЕЛЬ', 'РОЗА КОРОТКАЯ', 'РОЗА ДЛИННАЯ'): return None # пока не заполняем (специализация — редко) # Прямые/маскированные соответствия RULES: list[tuple[str, str]] = [ # (regex on UPPER string, family) (r'\bРОЗА\b', 'ROSA'), (r'\bROSES?\b', 'ROSA'), (r'ХРИЗАНТЕМА', 'CHRYSANTHEMUM'), (r'CHRYSANTHEMUM|CHRYS\b|CHR\b', 'CHRYSANTHEMUM'), (r'АЛЬСТРОМЕРИЯ', 'ALSTROEMERIA'), (r'ALSTRO?E?MERIA|ALSTRO', 'ALSTROEMERIA'), (r'ГВОЗДИ', 'DIANTHUS'), # 'Гвозди одн' (r'ГВОЗДИКА', 'DIANTHUS'), (r'CARNATIONS?|DIANTHUS|CLAVEL', 'DIANTHUS'), (r'ГИПСОФИЛА', 'GYPSOPHILA'), (r'GYPSOPHILA', 'GYPSOPHILA'), (r'ГОРТЕНЗИЯ', 'HYDRANGEA'), (r'HYDRANGEA', 'HYDRANGEA'), (r'ЛИЛИЯ', 'LILIUM'), (r'LILIES?|LILIUM|LILLIUM', 'LILIUM'), (r'PAEONIA|PEONY', 'PEONY'), (r'GERBERA', 'GERBERA'), (r'EUSTOMA|LISIANTHUS', 'EUSTOMA'), (r'MATTHIOLA|MATHIOLA|ALHELY|ALHELI', 'MATTHIOLA'), (r'HYPERI?CUM|HIPERICUM', 'HYPERICUM'), (r'RANUNCULUS', 'RANUNCULUS'), (r'SOLIDAGO', 'SOLIDAGO'), (r'STATICE', 'LIMONIUM'), # Statice = Limonium (r'LIMONIUM', 'LIMONIUM'), (r'HELIANTHUS|SUNFLOWER', 'HELIANTHUS'), (r'DELPHINIUM', 'DELPHINIUM'), (r'TRACHELIUM|TRAHELIUM', 'TRACHELIUM'), (r'VERONICA', 'VERONICA'), (r'CALLA', 'CALLA'), (r'ANEMONE', 'ANEMONE'), (r'ANTIRRHINUM|SNAPDRAGON', 'ANTIRRHINUM'), (r'CRASPEDIA', 'CRASPEDIA'), (r'PHLOX', 'PHLOX'), (r'SCABIOSA', 'SCABIOSA'), (r'BRASSICA', 'BRASSICA'), (r'AMARANTHUS', 'AMARANTHUS'), (r'ASTER\b', 'ASTER'), (r'FREESIA', 'FREESIA'), (r'AGAPANTHUS', 'AGAPANTHUS'), (r'ORNITHOGALUM', 'ORNITHOGALUM'), (r'MOLUCELLA|MOLUCCELLA', 'MOLUCELLA'), (r'ERYNGIUM', 'ERYNGIUM'), (r'MATRICARIA', 'MATRICARIA'), (r'BUPLEURUM|ВОЛОДУШКА', 'BUPLEURUM'), (r'NARCISSUS', 'NARCISSUS'), (r'CAMPANULA', 'CAMPANULA'), (r'CAPSICUM', 'CAPSICUM'), (r'ACHILLEA', 'ACHILLEA'), (r'ANIGOZANT?H?US|ANIGOZANTUS', 'ANIGOZANTHUS'), (r'LAVAND(ULA|E)?|ЛАВАНДА', 'LAVANDULA'), (r'LEUCADEND?RON', 'LEUCADENDRON'), (r'CHAMEL[AU]U?CIUM|WAX', 'CHAMELAUCIUM'), (r'COCCULUS|COCULUS', 'COCCULUS'), (r'PROTEA|GREVILL?I?EA', 'PROTEA'), (r'VIBURNUM', 'VIBURNUM'), (r'TWEEDIA', 'TWEEDIA'), (r'EUCALYPT?[UH]S|EUCALIPTUS|EUCALIPTHUS', 'EUCALYPTUS'), (r'RUSCUS', 'RUSCUS'), (r'ASPARAGUS', 'ASPARAGUS'), (r'ASPIDISTRA', 'ASPIDISTRA'), (r'ARALIA', 'ARALIA'), (r'GREEN\s*BELL', 'GREEN_BELL'), (r'BEARGRASS', 'BEARGRASS'), (r'PITTOSPORUM', 'PITTOSPORUM'), (r'CORDELYNE|CORDYLINE', 'CORDYLINE'), (r'CODIAEUM', 'CODIAEUM'), (r'CHRYSALIDOCARPUS', 'CHRYSALIDOCARPUS'), (r'CHAMAEDOREA|CATARACTARUM', 'CHAMAEDOREA'), (r'ROBEL[IE]NI', 'ROBELINI'), (r'PANDANUS', 'PANDANUS'), (r'PODOCARPUS', 'PODOCARPUS'), (r'SOLANUM', 'SOLANUM'), (r'SANSEVIERIA', 'SANSEVIERIA'), (r'POLIPODIUM|POLYPODIUM', 'POLYPODIUM'), (r'KALANCHOE', 'KALANCHOE'), (r'EPIPHYLLUM', 'EPIPHYLLUM'), (r'BERZELIA', 'BERZELIA'), (r'BANKSIA', 'BANKSIA'), (r'BAUHINIA', 'BAUHINIA'), (r'EUONYMUS|БЕРЕСКЛЕТ', 'EUONYMUS'), (r'COTTON|GOSSYPIUM', 'COTTON'), (r'TROPICAL', 'TROPICAL'), # Дополнительные (Roza template labels / Uganda / Costa Rica / Africa) (r'BOUVARDIA', 'BOUVARDIA'), (r'ARABICUM|ORNITHOGALUM\s*ARAB', 'ORNITHOGALUM'), (r'ARANTHERA|MOKARA|PHALAENOPSIS|DENDROBIUM|CYMBIDIUM|ORCHID', 'ORCHID'), (r'HEDERA', 'HEDERA'), (r'MONSTERA', 'MONSTERA'), (r'SALAL|GAULTHERIA', 'SALAL'), (r'CUMOSUM|ASPARAGUS\s+CUMOSUM', 'ASPARAGUS'), (r'BRUNIA|STAAVIA', 'BRUNIA'), (r'LAGARUS|LAGURUS', 'LAGURUS'), (r'LAVENDER|LAVANDA', 'LAVANDULA'), (r'LEDERVAREN', 'LEDERVAREN'), (r'MENTHA|МЯТА', 'MENTHA'), (r'MYRSINE|МИРСИН', 'MYRSINE'), (r'ORIGANUM|ДУШИЦА', 'ORIGANUM'), (r'ROSMARINUS|РОЗМАРИН', 'ROSMARINUS'), (r'SALVIA|ШАЛФЕЙ', 'SALVIA'), (r'SERRURIA', 'SERRURIA'), (r'THLASPI', 'THLASPI'), (r'THYMUS|ТИМЬЯН', 'THYMUS'), (r'STOEBE', 'STOEBE'), (r'MIROCLAUDIUS', 'MIROCLAUDIUS'), (r'MORAEA|MOREA', 'MORAEA'), (r'MURAYA|MURRAYA', 'MURRAYA'), (r'KOCHIA|БАССИЯ', 'KOCHIA'), (r'HOYA', 'HOYA'), (r'LOTUS', 'LOTUS'), (r'PHILODENDRON', 'PHILODENDRON'), (r'SHARON', 'SHARON'), (r'VIRGATUS', 'VIRGATUS'), (r'GREEN\s*MIX', 'GREEN_MIX'), (r'SAFARI', 'SAFARI'), (r'LEUCADEDRON|LEUCADEND', 'LEUCADENDRON'), (r'BUXIFOLIA', 'BUXIFOLIA'), ] for pat, fam in RULES: if re.search(pat, s_up): return (fam, 0) return None def _classify_packing_item(flower: str, variety: str, length_cm: int) -> tuple[str, int] | None: """PackingItem → ключ (family, length) для поиска строки в шаблоне.""" fam = (flower or '').upper().strip() if not fam and variety: fam = (_classify_taxonomy(variety) or '').upper().strip() if not fam: return None if fam == 'ROSA': return ('ROSA', int(length_cm) if length_cm else 0) return (fam, 0) # === Маркировки / клиенты: fuzzy match колонок === _MARK_SUFFIXES = re.compile(r'[-\s]\s*[AB]$', re.IGNORECASE) _MARK_TAIL_DIGITS = re.compile(r'\d+$') def _norm_client_key(s: str) -> str: """Нормализация ключа: UPPER, убрать пробелы, ',', '/', '.', '+', '(', ')'.""" if not s: return '' x = str(s).upper() for ch in (' ', ',', '/', '.', '+', '(', ')', "'", '"', '-'): x = x.replace(ch, '') return x def _client_variants(client: str) -> list[str]: """'SUN FLORA,SEZON777' → ['SUNFLORA', 'SEZON777', 'SUNFLORASEZON777']. Разбиение по ',' и '/'. Каждый компонент нормализуется через _norm_client_key. Также добавляет весь оригинал (без разбиения) для точного совпадения. """ if not client: return [] s = str(client).strip() out: list[str] = [] full = _norm_client_key(s) if full: out.append(full) for sep in (',', '/'): if sep in s: for part in s.split(sep): p = _norm_client_key(part) if p and p not in out: out.append(p) return out def _mark_variants(marking: str) -> list[str]: """[MARK, MARK-без-суффикса, B-MARK, MARK-без-хвостовых-цифр].""" s0 = str(marking or '').strip().upper().replace(' ', '') if not s0: return [] out = [s0] # Убрать -A/-B суффикс s1 = _MARK_SUFFIXES.sub('', s0).strip() if s1 and s1 != s0: out.append(s1) # Убрать хвостовые цифры (IZUM102 → IZUM) for base in list(out): s2 = _MARK_TAIL_DIGITS.sub('', base).strip() if s2 and s2 != base and len(s2) >= 3: out.append(s2) # Добавить/убрать B- префикс for base in list(out): if base.startswith('B-'): out.append(base[2:]) else: out.append('B-' + base) # Если содержит '/' — разбить и добавить каждый компонент if '/' in s0: for part in s0.split('/'): p = part.strip() if p and p not in out: out.append(p) # dedup, сохранив порядок seen = set() result = [] for x in out: if x not in seen: seen.add(x) result.append(x) return result # === AWB / folder helpers === _AWB_LAST4_RE = re.compile(r'(\d{4})(?:\D|$)') _FOLDER_AWB_CLIENT_RE = re.compile(r'^(\d{3,5})\s+(.+?)(?:\s+\d+кор)?$', re.IGNORECASE) def _awb_last4(awb) -> str: """'145-11921932' → '1932'. Из любого AWB/числа возвращаем последние 4 цифры.""" if awb is None or awb == '': return '' s = str(awb) digits = re.sub(r'\D', '', s) if len(digits) >= 4: return digits[-4:] return '' def _extract_folder_awb_client(source_path: str) -> tuple[str, str]: """Из '.../700ecu/1932 ALEX KAZAN/packing.xls' → ('1932', 'ALEX KAZAN').""" if not source_path: return '', '' # source может содержать '::sheet::format' src = str(source_path).split('::')[0] parent = Path(src).parent.name m = _FOLDER_AWB_CLIENT_RE.match(parent) if m: return m.group(1), m.group(2).strip() return '', '' # === Индекс шаблона / «золотые» маппинги === def _build_template_index( template_path: str, unmapped_labels: set[str] | None = None, ) -> tuple[dict[str, list[list]], dict[str, dict]]: """Прочитать шаблон (.xls) и построить индекс листов-стран. Возвращает (src_sheets, idx): src_sheets — сырые строки всех листов (для копирования в output); idx — {sheet: {rows_count, cols_count, col_by_client, col_by_awb, col_labels, row_by_key}} без SKIP_SHEETS. """ src_wb = CalamineWorkbook.from_path(template_path) src_sheets: dict[str, list[list]] = {} for sn in src_wb.sheet_names: src_sheets[sn] = src_wb.get_sheet_by_name(sn).to_python() idx: dict[str, dict] = {} for sn, rows in src_sheets.items(): if sn in SKIP_SHEETS: continue rows_count = len(rows) cols_count = max((len(r) for r in rows), default=0) # Колонки шаблона: R1 col 2+ — имена клиентов (могут быть комбинированные) col_by_client: dict[str, int] = {} col_labels: dict[int, str] = {} if rows_count >= 1: hdr_row = rows[0] for ci in range(1, len(hdr_row)): v = str(hdr_row[ci]).strip() if not v: continue col_labels[ci + 1] = v # Регистрируем все варианты клиента for variant in _client_variants(v): col_by_client.setdefault(variant, ci + 1) # col_by_awb: последние 4 цифры AWB (из R2) → список колонок (может быть >1) col_by_awb: dict[str, list[int]] = defaultdict(list) if rows_count >= 2: awb_row = rows[1] for ci in range(1, len(awb_row)): v = awb_row[ci] if v is None or v == '': continue last4 = _awb_last4(v) if last4: col_by_awb[last4].append(ci + 1) # row_by_key row_by_key: dict[tuple[str, int], int] = {} # Первая содержательная строка: R2 если col A содержит имя страны заглавные ('ЭКВАДОР') # или семейство. Пропускаем метки-разделители и служебные. for ri in range(1, rows_count): # R2+ (0-index 1) label = str(rows[ri][0]).strip() if rows[ri] else '' if not label: continue up = label.upper() # Пропускаем строки-заголовки страны и служебные суммы if up in ('ЭКВАДОР', 'КОЛУМБИЯ', 'KENIA', 'ИЗРАИЛЬ', 'THAILAND', 'AFRIKA', 'UGANDA', 'ETHIOPIA', 'COSTA RICA', 'CHILE', 'INDIA'): continue if re.match(r'мест\b|мест$|брутто|всего|роза длинная|роза короткая', label, re.IGNORECASE): continue key = _normalize_label(label) if key is None: if unmapped_labels is not None: unmapped_labels.add(label) continue row_by_key.setdefault(key, ri + 1) # 1-based idx[sn] = { 'rows_count': rows_count, 'cols_count': cols_count, 'col_by_client': col_by_client, 'col_by_awb': dict(col_by_awb), 'col_labels': col_labels, 'row_by_key': row_by_key, } return src_sheets, idx def _load_import_gold() -> dict[str, tuple[str, str]]: """«Золотые» маппинги из corrections.output_history (document_type='import'). Возвращает {MARKING_UPPER: (sheet, col_header)} — куда оператор уже размещал такую маркировку. Лист может отличаться от страны packing-листа (случай B-DOMROZ: packing говорит Ecuador, а колонка живёт в Колумбии). """ try: from corrections import load_corrections corr = load_corrections() except Exception: return {} gold: dict[str, tuple[str, str]] = {} for h in getattr(corr, 'output_history', None) or []: if h.get('document_type') != 'import': continue mark = str(h.get('marking') or h.get('original_value') or '').strip().upper() col_header = str(h.get('col_header') or h.get('corrected_value') or '').strip() sheet = str(h.get('country') or h.get('sheet') or '').strip() if mark and col_header: gold[mark] = (sheet, col_header) return gold def _resolve_import_column( idx: dict[str, dict], sheet_name: str, marking: str, client: str, f_awb: str, f_client: str, packing_awb: str, gold: dict[str, tuple[str, str]], ) -> tuple[str, int | None]: """Найти колонку для маркировки. Возвращает (лист, col | None). Лист результата может отличаться от sheet_name: «золотой» маппинг (шаг 0) или точный match в другом листе (шаг 5) перенаправляют запись — страна в packing-листе бывает ошибочной или пустой. """ sinfo = idx.get(sheet_name) # 0) «Золотые» маппинги из corrections (история правок оператора) for cand in _mark_variants(marking): g = gold.get(cand) if not g: continue g_sheet, g_col = g g_sinfo = idx.get(g_sheet) if g_sheet in idx else sinfo tgt = g_sheet if g_sheet in idx else sheet_name if g_sinfo is None: continue for variant in _client_variants(g_col): if variant in g_sinfo['col_by_client']: return tgt, g_sinfo['col_by_client'][variant] if sinfo is not None: # 1) Через client из manifest (marking→client) if client: for cand in _client_variants(client): if cand in sinfo['col_by_client']: return sheet_name, sinfo['col_by_client'][cand] # 2) Через client из имени папки if f_client: for cand in _client_variants(f_client): if cand in sinfo['col_by_client']: return sheet_name, sinfo['col_by_client'][cand] # 3) Прямой match маркировки как «имени клиента» for cand in _mark_variants(marking): key = _norm_client_key(cand) if key and key in sinfo['col_by_client']: return sheet_name, sinfo['col_by_client'][key] # 4) Через AWB (последние 4 цифры): folder-awb → ps.awb for a in dict.fromkeys(x for x in (f_awb, packing_awb) if x): cols_for_awb = sinfo['col_by_awb'].get(a, []) if len(cols_for_awb) == 1: return sheet_name, cols_for_awb[0] elif len(cols_for_awb) > 1: # Неоднозначно — попробуем разрулить через f_client в подмножестве if f_client: variants = set(_client_variants(f_client)) for c in cols_for_awb: lbl = sinfo['col_labels'].get(c, '') for lv in _client_variants(lbl): if lv in variants: return sheet_name, c # Берём первую (наиболее общий столбец, обычно ++ клиент) return sheet_name, cols_for_awb[0] # 5) Точный match маркировки в ДРУГИХ листах (страна packing бывает ошибочной). # Короткие ключи (<4 симв.) не ищем — защита от ложных срабатываний. mark_keys = [k for k in (_norm_client_key(c) for c in _mark_variants(marking)) if len(k) >= 4] for osn, osinfo in idx.items(): if osn == sheet_name: continue for key in mark_keys: if key in osinfo['col_by_client']: return osn, osinfo['col_by_client'][key] return sheet_name, None # === Отчёт === @dataclass class ImportWriteReport: template: str = '' output: str = '' sheets_processed: int = 0 items_placed: int = 0 cells_written: int = 0 cols_created: int = 0 no_column: int = 0 no_row: int = 0 unmapped_markings: set[str] = field(default_factory=set) unmapped_labels: set[str] = field(default_factory=set) skipped_country: set[str] = field(default_factory=set) # === Основная функция === def fill_import_table( template_path: str, output_path: str, packing_sheets: list, marking_to_client: dict[str, str] | None = None, verbose: bool = True, ) -> ImportWriteReport: """Заполнить «Таблица импорт NNNMMM.xls» → сохранить как .xlsx. Работает по packing-list фактам: PackingSheet.country → лист, marking → client (через `marking_to_client` из manifest) → колонка, (flower, length_cm) → строка. Стебли пишутся в ячейку. Netherlands (NL) НЕ пишется — идёт в europa_writer. Параметр `marking_to_client` — маппинг из report.main_rows: MARKING (UPPER) → client_name. Если None или маркировки нет в mapping — fallback к прямому matching marking↔column. """ rep = ImportWriteReport(template=template_path, output=output_path) m2c: dict[str, str] = {} if marking_to_client: for k, v in marking_to_client.items(): if k and v: m2c[str(k).strip().upper()] = str(v).strip() # 1-2. Шаблон + индекс листов (общий хелпер) + «золотые» маппинги src_sheets, idx = _build_template_index(template_path, rep.unmapped_labels) gold = _load_import_gold() # Новые колонки, созданные под маркировки рейса (как делает декларант: # R1 = маркировка, R2 = последние 4 цифры AWB). Без этого встроенный # шаблон с чужими клиентами даёт «Ячеек заполнено: 0» (856MLT). new_headers: dict[tuple[str, int], tuple[str, str]] = {} def _create_column(sheet_name: str, sinfo: dict, marking: str, awb4: str) -> int: used = list(sinfo['col_labels'].keys()) for cols in sinfo['col_by_awb'].values(): used.extend(cols) col = max(used, default=1) + 1 sinfo['col_labels'][col] = marking for v in _client_variants(marking): sinfo['col_by_client'].setdefault(v, col) # ВАЖНО: НЕ регистрируем awb4 в col_by_awb — иначе следующая маркировка # с тем же AWB (FLO-RSFL/SERGEY73/ZAIC при 4971) слипнется в эту колонку # через AWB-fallback. Совпадение маркировки ловится через col_by_client. new_headers[(sheet_name, col)] = (marking, awb4) rep.cols_created += 1 return col # 3. Агрегируем: (sheet, row, col) → stems cells: dict[tuple[str, int, int], int] = defaultdict(int) for ps in packing_sheets: country_norm = str(getattr(ps, 'country', '') or '').strip().lower() # Пропускаем NL — это территория europa_writer if country_norm in ('netherlands', 'nl'): continue sheet_name = COUNTRY_TO_SHEET.get(country_norm, '') if country_norm and not sheet_name: rep.skipped_country.add(country_norm) continue # Пустая страна: не скипаем — шаги 0/5 могут найти лист по маркировке # Определяем колонку (и, возможно, другой лист) через общую цепочку marking = str(getattr(ps, 'marking', '') or '').strip() client = m2c.get(marking.upper(), '') # Хинты из папки: '.../1932 ALEX KAZAN/pack.xls' → awb=1932, client=ALEX KAZAN f_awb, f_client = _extract_folder_awb_client(getattr(ps, 'source', '')) target_sheet, col = _resolve_import_column( idx, sheet_name, marking, client, f_awb, f_client, _awb_last4(getattr(ps, 'awb', '')), gold, ) sinfo = idx.get(target_sheet) if not sinfo: # Страна не определена и маркировка не найдена ни в одном листе n_items = len(getattr(ps, 'items', []) or []) rep.no_column += n_items if marking: rep.unmapped_markings.add(f'{marking} (client={client or "?"})') continue # Колонки для маркировки нет — создаём её в листе страны (декларант # для каждого рейса добавляет колонки под маркировки PART-строк). if col is None and marking: awb4 = next((a for a in (f_awb, _awb_last4(getattr(ps, 'awb', ''))) if a), '') col = _create_column(target_sheet, sinfo, marking.strip().upper(), awb4) for it in getattr(ps, 'items', []): key = _classify_packing_item( getattr(it, 'flower', ''), getattr(it, 'variety', ''), getattr(it, 'length_cm', 0), ) if key is None: # detail-агрегация в diagnostics lbl = f'{getattr(it, "flower", "")}:{getattr(it, "variety", "")}' rep.unmapped_labels.add(lbl.strip(':')) continue row = sinfo['row_by_key'].get(key) if row is None and key[1] > 0: # Роза NNN см не нашлась → пробуем без длины row = sinfo['row_by_key'].get((key[0], 0)) if row is None: rep.no_row += 1 continue if col is None: rep.no_column += 1 rep.unmapped_markings.add(f'{marking} (client={client or "?"})') continue stems = int(getattr(it, 'stems', 0) or 0) cells[(target_sheet, row, col)] += stems rep.items_placed += 1 # 4. Новый .xlsx: копия структуры + запись фактов out_wb = Workbook() out_wb.remove(out_wb.active) for sn, rows in src_sheets.items(): ws = out_wb.create_sheet(sn) for ri, row in enumerate(rows, start=1): for ci, val in enumerate(row, start=1): if val == '' or val is None: continue ws.cell(row=ri, column=ci).value = val for (sn, r, c), stems in cells.items(): if stems <= 0: continue out_wb[sn].cell(row=r, column=c).value = stems rep.cells_written += 1 # Заголовки созданных колонок: R1 = маркировка, R2 = AWB last4 for (sn, c), (mark, awb4) in new_headers.items(): out_wb[sn].cell(row=1, column=c).value = mark if awb4: out_wb[sn].cell(row=2, column=c).value = int(awb4) rep.sheets_processed = len([sn for sn in src_sheets if sn not in SKIP_SHEETS]) out_wb.save(output_path) if verbose: print(f'[import] Листов обработано: {rep.sheets_processed}') print(f'[import] Позиций размещено: {rep.items_placed}') print(f'[import] Ячеек заполнено: {rep.cells_written}') if rep.cols_created: print(f'[import] Колонок создано: {rep.cols_created}') if rep.no_column: print(f'[import] без колонки: {rep.no_column}') if rep.no_row: print(f'[import] без строки: {rep.no_row}') if rep.unmapped_markings: preview = sorted(rep.unmapped_markings)[:6] print(f'[import] Незамапленные маркировки ({len(rep.unmapped_markings)}): ' f'{", ".join(preview)}') if rep.skipped_country: print(f'[import] Пропущено стран: {sorted(rep.skipped_country)}') if rep.unmapped_labels: preview = sorted(rep.unmapped_labels)[:5] print(f'[import] Незамапленные метки ({len(rep.unmapped_labels)}): ' f'{", ".join(preview)}') return rep # === AI-фолбэк: анализ unmapped + второй проход по подсказкам ИИ === def compute_import_unmapped( packing_sheets: list, template_path: str, marking_to_client: dict[str, str] | None = None, ) -> dict: """Сводка нераспознанных маркировок для AI-пропозала (по образцу europa_writer.compute_europa_unmapped). Формат возврата: { "unmapped_markings": [{"input": "HARDIN", "country": "Ecuador", "sheet_hint": "Эквадор", "awb": "...", "items_affected": N, "stems_affected": N}, ...], "columns_by_sheet": {"Эквадор": ["ALEX KAZAN", ...], ...}, } """ m2c: dict[str, str] = {} if marking_to_client: for k, v in marking_to_client.items(): if k and v: m2c[str(k).strip().upper()] = str(v).strip() _, idx = _build_template_index(template_path) gold = _load_import_gold() unmapped: dict[str, dict] = {} for ps in packing_sheets: country_raw = str(getattr(ps, 'country', '') or '').strip() country_norm = country_raw.lower() if country_norm in ('netherlands', 'nl'): continue # территория europa sheet_name = COUNTRY_TO_SHEET.get(country_norm, '') marking = str(getattr(ps, 'marking', '') or '').strip() client = m2c.get(marking.upper(), '') f_awb, f_client = _extract_folder_awb_client(getattr(ps, 'source', '')) _, col = _resolve_import_column( idx, sheet_name, marking, client, f_awb, f_client, _awb_last4(getattr(ps, 'awb', '')), gold, ) if col is not None: continue # колонка найдена — ИИ не нужен items = getattr(ps, 'items', []) or [] key = marking or '(без маркировки)' b = unmapped.setdefault(key, { 'input': key, 'country': country_raw, 'sheet_hint': sheet_name, 'awb': str(getattr(ps, 'awb', '') or ''), 'items_affected': 0, 'stems_affected': 0, }) b['items_affected'] += len(items) b['stems_affected'] += sum(int(getattr(i, 'stems', 0) or 0) for i in items) columns_by_sheet = { sn: [sinfo['col_labels'][c] for c in sorted(sinfo['col_labels'])] for sn, sinfo in idx.items() } return { 'unmapped_markings': sorted(unmapped.values(), key=lambda x: -x['stems_affected']), 'columns_by_sheet': columns_by_sheet, } @dataclass class ImportAIReport: items_placed: int = 0 stems_added: int = 0 cells_written: int = 0 skipped_items: int = 0 markings_used: set[str] = field(default_factory=set) warnings: list[str] = field(default_factory=list) def apply_import_ai_overrides( output_path: str, packing_sheets: list, overrides: dict[str, dict], # {"HARDIN": {"sheet": "Эквадор", "column": "B-FLORIDA"}} ) -> ImportAIReport: """Второй проход: дописать в уже сгенерированный import-файл (.xlsx) позиции, не легшие при первом проходе, — по подсказкам ИИ/оператора. overrides: {marking: {"sheet": имя_листа, "column": заголовок_колонки_R1}}. Значения в ячейках ПРИБАВЛЯЮТСЯ к существующим (не перезаписываются). """ from openpyxl import load_workbook rep = ImportAIReport() ov: dict[str, tuple[str, str]] = {} for k, v in (overrides or {}).items(): if not isinstance(v, dict): continue sn = str(v.get('sheet') or '').strip() cl = str(v.get('column') or '').strip() if k and sn and cl: ov[str(k).strip().upper()] = (sn, cl) if not ov: rep.warnings.append('overrides пустые — ничего не делаем') return rep wb = load_workbook(output_path) # Индекс output-файла: та же структура, что у шаблона idx: dict[str, dict] = {} for sn in wb.sheetnames: if sn in SKIP_SHEETS: continue ws = wb[sn] col_by_client: dict[str, int] = {} for ci in range(2, ws.max_column + 1): v = str(ws.cell(row=1, column=ci).value or '').strip() if not v: continue for variant in _client_variants(v): col_by_client.setdefault(variant, ci) row_by_key: dict[tuple[str, int], int] = {} for ri in range(2, ws.max_row + 1): label = str(ws.cell(row=ri, column=1).value or '').strip() if not label: continue if re.match(r'мест\b|мест$|брутто|всего|роза длинная|роза короткая', label, re.IGNORECASE): continue key = _normalize_label(label) if key is not None: row_by_key.setdefault(key, ri) idx[sn] = {'col_by_client': col_by_client, 'row_by_key': row_by_key} # Агрегация добавок: (sheet, row, col) → stems add_cells: dict[tuple[str, int, int], int] = defaultdict(int) for ps in packing_sheets: marking = str(getattr(ps, 'marking', '') or '').strip().upper() pair = ov.get(marking) if pair is None: continue sn, col_header = pair sinfo = idx.get(sn) if sinfo is None: rep.warnings.append(f'Лист «{sn}» не найден в файле ({marking})') continue col = None for variant in _client_variants(col_header): if variant in sinfo['col_by_client']: col = sinfo['col_by_client'][variant] break if col is None: rep.warnings.append(f'Колонка «{col_header}» не найдена на листе «{sn}» ({marking})') continue for it in getattr(ps, 'items', []): key = _classify_packing_item( getattr(it, 'flower', ''), getattr(it, 'variety', ''), getattr(it, 'length_cm', 0), ) row = None if key is not None: row = sinfo['row_by_key'].get(key) if row is None and key[1] > 0: row = sinfo['row_by_key'].get((key[0], 0)) if row is None: rep.skipped_items += 1 continue stems = int(getattr(it, 'stems', 0) or 0) add_cells[(sn, row, col)] += stems rep.items_placed += 1 rep.stems_added += stems rep.markings_used.add(marking) for (sn, r, c), stems in add_cells.items(): if stems <= 0: continue cell = wb[sn].cell(row=r, column=c) prev = cell.value prev_num = prev if isinstance(prev, (int, float)) else 0 cell.value = int(prev_num) + stems rep.cells_written += 1 wb.save(output_path) return rep if __name__ == '__main__': import sys try: sys.stdout.reconfigure(encoding='utf-8') except Exception: pass from packing_parser import parse_folder as parse_packing_folder from manifest_builder import build_manifest trip = sys.argv[1] if len(sys.argv) > 1 else \ r'c:\Users\0\Desktop\Задача по логистике\03.07.2026\MTL 700' tpl = None for f in os.listdir(trip): low = f.lower() if low.startswith('таблица импорт') and low.endswith(('.xls', '.xlsx')) \ and '_autofilled' not in low: tpl = os.path.join(trip, f) break if not tpl: print(f'ОШИБКА: не найден шаблон «Таблица импорт *.xls» в {trip}') sys.exit(1) out = tpl.rsplit('.', 1)[0] + '_autofilled.xlsx' # Ищем master для справочника marking→client master = None import re as _re for f in os.listdir(trip): if _re.match(r'^\d{2}\.\d{2}\.\d{4}.*\.xlsx$', f) and not f.startswith('~$') \ and '_autofilled' not in f.lower(): master = os.path.join(trip, f) break sheets = parse_packing_folder(trip) print(f'Шаблон: {tpl}') print(f'Выход: {out}') print(f'Packing: {len(sheets)}') m2c = {} if master: report = build_manifest(trip, master, verbose=False) for row in report.main_rows: if row.marking and row.client: m2c[row.marking.strip().upper()] = row.client print(f'Marking→Client: {len(m2c)} pairs (из {master})') else: print('WARN: нет master, marking→client не построен') fill_import_table(tpl, out, sheets, marking_to_client=m2c, verbose=True) print(f'\n✓ Сохранено: {out}')