/
Paff
/
declarant
Обзор
Документация
Войти
/
Paff
/
declarant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
web/reports_api.py
2 820 строк
116 KB
1pash1985-gif
chore: commit deployed source changes before Yandex OCR deploy
09 авг 2026, 23:56
09 авг 2026, 23:56
9777dcd
Код
Авторство
О чём код?
""" Декларант — API «Отчёты по номеру машины». Идея: оператор вводит номер машины (например, 444KZE), система создаёт папку отчёта по дате, принимает 1..N сканов (PDF/Excel) любых типов (invoice / packing / certificate), автоматически переименовывает их с номером машины, распознаёт содержимое через DeepSeek, сохраняет JSON, позволяет открыть исходник для сверки и запускает fill_mtl.py для формирования 4 итоговых Excel-отчётов. Хранение: web/uploads/reports/<YYYY-MM-DD>/<CAR>/ meta.json — карточка отчёта sources/<TYPE>_<CAR>_<N>.<ext> — исходные сканы (переименованные) parsed/<TYPE>_<CAR>_<N>.json — результат DeepSeek output/… — сформированные fill_mtl отчёты """ from __future__ import annotations import io import json import os import re import sys import shutil import zipfile import subprocess from datetime import datetime from pathlib import Path from typing import Any from flask import ( Blueprint, current_app, jsonify, request, send_file, send_from_directory, ) from werkzeug.utils import secure_filename from config import ( ALLOWED_EXTENSIONS, UPLOAD_FOLDER, ENSEMBLE_ENABLED, ENSEMBLE_ENABLE_PARSING, ENSEMBLE_ENABLE_FILL, ) # --- Пути / константы --------------------------------------------------------- REPORTS_ROOT = Path(UPLOAD_FOLDER) / 'reports' REPORTS_ROOT.mkdir(parents=True, exist_ok=True) PROJECT_ROOT = Path(__file__).resolve().parent.parent # Проектные модули (europa_writer / packing_parser) лежат в корне проекта, # а не в web/. Добавляем корень в sys.path, чтобы import работал внутри-процессно # (помимо того, что fill_mtl.py вызывается как subprocess). if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) # Расширения, которые пускаем в /upload (сканы = PDF или изображения). # Excel-шаблоны сюда не грузим — они генерируются fill_mtl'ом. SCAN_EXTENSIONS = {'.pdf', '.jpg', '.jpeg', '.png', '.tif', '.tiff'} # Расширения PDF, которые можно прогонять через DeepSeek/pdfplumber PARSEABLE_PDF = {'.pdf'} # Регэксп для нормализации номера машины — оставляем только буквы/цифры, # приводим к верхнему регистру. _CAR_NORM_RE = re.compile(r'[^A-Za-z0-9]+') # Ключевые слова для угадывания типа документа по имени файла _TYPE_HINTS = [ ('certificate', re.compile( r'sertif|certif|\bcert\b|eur\.?1|\bcoo\b|origin|происхожд', re.I)), ('packing', re.compile( r'packing|pack[-_ ]?list|\bpl\b|упаков|упак\.|склад', re.I)), ('invoice', re.compile( r'invoice|инвойс|\binv\b|commercial|specif|специф|фактур|' r'счет|счёт|\bсчет\b|\bсчёт\b', re.I)), ] # Excel-расширения, для которых при неопознанном имени по умолчанию считаем invoice _XLSX_EXTS = {'.xls', '.xlsx', '.xlsm'} # --- Утилиты ------------------------------------------------------------------ def normalize_car_number(car: str) -> str: """`444KZE`, `KZE-444`, `444kze ` → `444KZE`.""" if not car: return '' return _CAR_NORM_RE.sub('', car).upper() def _today() -> str: return datetime.now().strftime('%Y-%m-%d') def _report_dir(date: str, car: str) -> Path: return REPORTS_ROOT / date / car def _report_paths(date: str, car: str) -> dict[str, Path]: base = _report_dir(date, car) return { 'base': base, 'sources': base / 'sources', 'parsed': base / 'parsed', 'output': base / 'output', 'meta': base / 'meta.json', } def _ensure_dirs(paths: dict[str, Path]) -> None: for k in ('base', 'sources', 'parsed', 'output'): paths[k].mkdir(parents=True, exist_ok=True) def _load_meta(paths: dict[str, Path]) -> dict[str, Any]: if paths['meta'].is_file(): try: return json.loads(paths['meta'].read_text(encoding='utf-8')) except Exception: pass return {} def _save_meta(paths: dict[str, Path], meta: dict[str, Any]) -> None: paths['meta'].write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8', ) def _report_id(date: str, car: str) -> str: return f'{date}__{car}' def _parse_report_id(report_id: str) -> tuple[str, str] | None: if '__' not in report_id: return None date, car = report_id.split('__', 1) if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', date): return None car = normalize_car_number(car) if not car: return None return date, car def _guess_type(filename: str) -> str: """Пытаемся угадать тип документа по имени. Возвращает 'invoice' | 'packing' | 'certificate' | 'other'. Fallback: если ничего не угадалось, но это Excel-файл — считаем его инвойсом (Excel-инвойсы — самый частый случай для «неговорящих» имён). """ for t, pat in _TYPE_HINTS: if pat.search(filename): return t ext = os.path.splitext(filename)[1].lower() if ext in _XLSX_EXTS: return 'invoice' return 'other' def _next_index(sources_dir: Path, doc_type: str) -> int: """Следующий свободный номер для файла типа `doc_type` в sources/.""" used = set() prefix = f'{doc_type}_' for p in sources_dir.glob(f'{doc_type}_*'): m = re.match(rf'{re.escape(prefix)}[A-Z0-9]+_(\d+)\.', p.name) if m: used.add(int(m.group(1))) n = 1 while n in used: n += 1 return n # Паттерн для файлов, загруженных пользователем в отчёт (после нашего переименования): # `<type>_<CAR>_<N>.<ext>`, например `invoice_345_1.pdf`, `packing_444_2.xlsx`. # Авто-копированные fill_mtl шаблоны (`16.07.2026-345.xlsx`, # `Таблица европа …`, `florunner-specification …`) сюда НЕ попадают — # их скрываем из «Загруженных документов». _USER_UPLOADED_RE = re.compile( r'^(invoice|packing|certificate|other)_[A-Z0-9]+_\d+\.', re.IGNORECASE, ) def _list_files(paths: dict[str, Path]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] if not paths['sources'].is_dir(): return items for f in sorted(paths['sources'].iterdir()): if not f.is_file(): continue # Игнорируем всё, что не похоже на загруженный пользователем файл: # шаблоны (мастер, europa, import, florunner), темпорарные файлы office (~$), # `_autofilled` (если вдруг не успели переместить) и т.п. if not _USER_UPLOADED_RE.match(f.name): continue parsed_json = paths['parsed'] / (f.stem + '.json') parsed_data = None parsed_error = None if parsed_json.is_file(): try: data = json.loads(parsed_json.read_text(encoding='utf-8')) if isinstance(data, dict) and data.get('error'): parsed_error = data['error'] else: parsed_data = data except Exception as e: parsed_error = f'JSON: {e}' m = re.match(r'([a-z]+)_', f.name) doc_type = m.group(1) if m else 'other' items.append({ 'name': f.name, 'doc_type': doc_type, 'size': f.stat().st_size, 'has_parsed': parsed_data is not None, 'parse_error': parsed_error, 'parsed': parsed_data, 'is_parsing': _is_parsing_active(paths, f.stem), }) return items def _list_output(paths: dict[str, Path]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] if not paths['output'].is_dir(): return items # Подгружаем meta чтобы получить признаки is_empty/reason по имени meta = _load_meta(paths) ofm = meta.get('output_files_meta') or {} for f in sorted(paths['output'].iterdir()): if f.is_file(): entry: dict[str, Any] = {'name': f.name, 'size': f.stat().st_size} info = ofm.get(f.name) or {} if info.get('is_empty'): entry['is_empty'] = True if info.get('reason'): entry['reason'] = info['reason'] items.append(entry) return items def _safe_report_paths(report_id: str) -> tuple[dict[str, Path], str, str] | None: parsed = _parse_report_id(report_id) if not parsed: return None date, car = parsed paths = _report_paths(date, car) if not paths['base'].is_dir(): return None return paths, date, car def _merge_certificate_results(blocks: list[dict[str, Any]]) -> dict[str, Any]: """Объединить результаты парсинга нескольких сертификатов в одном PDF. Первый блок становится основой; items/cert_numbers/totals/master_invoices объединяются со всех блоков, чтобы downstream-потребители видели полный набор данных, а не только первый блок. """ if not blocks: return {} first = blocks[0] merged: dict[str, Any] = dict(first) all_items: list[dict[str, Any]] = [] cert_numbers: list[str] = [] master_invoices: list[str] = [] total_boxes = 0.0 total_stems = 0 seen_certs: set[str] = set() seen_masters: set[str] = set() for br in blocks: if br.get('items'): all_items.extend(br['items']) total_boxes += br.get('total_boxes') or 0 total_stems += br.get('total_stems') or 0 for n in br.get('cert_numbers', []): key = str(n).strip().lower() if key and key not in seen_certs: seen_certs.add(key) cert_numbers.append(n) for mi in br.get('master_invoices', []): key = str(mi).strip().lower() if key and key not in seen_masters: seen_masters.add(key) master_invoices.append(mi) # Если в блоках не было master_invoices — собираем из позиций. if not master_invoices: for it in all_items: mi = it.get('master_invoice') if mi: key = str(mi).strip().lower() if key and key not in seen_masters: seen_masters.add(key) master_invoices.append(mi) merged['items'] = all_items merged['total_boxes'] = total_boxes merged['total_stems'] = total_stems merged['cert_numbers'] = cert_numbers merged['master_invoices'] = master_invoices return merged def _cert_items_to_awb_groups(cert_items: list[dict[str, Any]]) -> list[dict[str, Any]]: """Группирует flat cert_items по AWB для иерархического UI.""" groups: list[dict[str, Any]] = [] seen: dict[str, dict[str, Any]] = {} for ci in cert_items: awb = ci.get('awb') or '—' if awb not in seen: seen[awb] = { 'awb': awb, 'exporter': ci.get('exporter') or '', 'certs': [], } groups.append(seen[awb]) seen[awb]['certs'].append(ci) return groups # --- Парсинг через DeepSeek / Kimi K3 ------------------------------------------ def _parse_file_with_ai( src: Path, doc_type: str, hints: dict | None = None, ) -> dict[str, Any]: """Запуск AI-парсинга. Если включен ensemble (`ENSEMBLE_ENABLED` + `ENSEMBLE_ENABLE_PARSING`), DeepSeek и Kimi решают задачу вместе, сравнивают результаты и ведут дебаты при расхождениях. Иначе используется один провайдер по типу документа: - invoice / packing / other → DeepSeek - certificate → Kimi K3 Возвращает dict (может содержать 'error'). hints — доп. контекст (folder_hint / user_hint / orig_name) из upload. Сохраняется в JSON как `_source_filename`, `_folder_hint`, `_user_hint` — `ai_bridge.resolve_marking` их читает при выборе маркировки. """ # Check if API keys are configured from config import DEEPSEEK_API_KEY, KIMI_API_KEY if not DEEPSEEK_API_KEY and not KIMI_API_KEY: return { 'error': 'AI-ключи не настроены. Укажите DEEPSEEK_API_KEY и/или KIMI_API_KEY в .env или переменных окружения.', '_doc_type': doc_type, } try: from ai_parser import ( extract_text_from_pdf, extract_text_from_excel, extract_certificate_numbers_with_ocr, parse_invoice_with_ai, parse_packing_with_ai, parse_certificate_with_kimi, ) except Exception as e: return {'error': f'ai_parser недоступен: {e}'} ext = src.suffix.lower() text = '' ocr_cert_numbers: list[str] = [] try: if ext == '.pdf': text = extract_text_from_pdf(str(src)) # Для сертификатов OCR-ом достаём EUR1/DIAN со сканов и # подкладываем AI в начало текста — иначе номера теряются, # потому что pdfplumber не видит печать на сканах. # Эквадорские PDF имеют нормальный текстовый слой, поэтому # тяжёлый Paddle OCR пропускаем (split_cert_blocks_by_awb тоже # пропускает OCR для Эквадора). Таймаут 60 с на всякий случай. if doc_type == 'certificate': _orig_name = (hints or {}).get('_source_filename') or src.name _hay = (text + ' ' + _orig_name).lower() _is_ecuador = ( 'ecuador' in _hay or re.search(r'\bec\b', _hay) is not None ) if not _is_ecuador: import concurrent.futures as _cf with _cf.ThreadPoolExecutor(max_workers=1) as _ex: _fut = _ex.submit( extract_certificate_numbers_with_ocr, str(src), text, _orig_name, ) try: ocr_cert_numbers = _fut.result(timeout=60) except Exception: ocr_cert_numbers = [] elif ext in ('.xls', '.xlsx', '.xlsm'): text = extract_text_from_excel(str(src)) else: return {'error': f'Тип файла {ext} не поддерживается для AI-парсинга'} except Exception as e: return {'error': f'Извлечение текста: {e}'} if ocr_cert_numbers: text = '[OCR-номера сертификатов: ' + ', '.join(ocr_cert_numbers) + ']\n\n' + text if not text.strip(): return {'error': 'Пустой текст в файле'} # === Разбивка сертификата на блоки (один блок = один сертификат) === cert_blocks: list[str] = [text] # по умолчанию — один блок if doc_type == 'certificate' and ext == '.pdf': try: import sys as _sys from pathlib import Path as _P _proj = str(_P(__file__).resolve().parent.parent) if _proj not in _sys.path: _sys.path.insert(0, _proj) from cert_parser import split_cert_blocks_by_awb, split_cert_blocks ocr_prefix = ('[OCR-номера сертификатов: ' + ', '.join(ocr_cert_numbers) + ']\n\n') if ocr_cert_numbers else '' awb_blocks = split_cert_blocks_by_awb(str(src)) if awb_blocks: # Flatten: one block per certificate, prefixed with its AWB for AI context flat_blocks: list[str] = [] for awb, blocks in awb_blocks.items(): awb_header = f'[AWB: {awb}]\n\n' for b in blocks: # Include all blocks, even short ones (scanned pages may have minimal text) if len(b.strip()) >= 20: flat_blocks.append(ocr_prefix + awb_header + b) if flat_blocks: cert_blocks = flat_blocks else: # Fallback to old flat splitting raw_blocks = split_cert_blocks(str(src)) if len(raw_blocks) > 1: meaningful = [b for b in raw_blocks if len(b.strip()) >= 50] if meaningful: raw_blocks = meaningful cert_blocks = [ocr_prefix + b for b in raw_blocks] except Exception: pass # если split упал — парсим целиком use_ensemble = ENSEMBLE_ENABLED and ENSEMBLE_ENABLE_PARSING if use_ensemble: try: from ai_ensemble import ( parse_invoice_with_ensemble, parse_packing_with_ensemble, parse_certificate_with_ensemble, ) except Exception as e: return {'error': f'ai_ensemble недоступен: {e}'} if doc_type == 'certificate': if len(cert_blocks) == 1: result = parse_certificate_with_ensemble(cert_blocks[0]) else: # Парсим блоки параллельно (max 3 потока) с общим дедлайном, # чтобы несколько сертификатов в одном PDF не растягивались # последовательно на минуты. import concurrent.futures as _cf from time import monotonic _deadline = monotonic() + 240 _block_results: list[dict[str, Any]] = [] with _cf.ThreadPoolExecutor(max_workers=3) as _ex: _futures = [ _ex.submit(parse_certificate_with_ensemble, block) for block in cert_blocks ] for _fut in _futures: _remaining = _deadline - monotonic() if _remaining <= 0: _block_results.append({'error': 'timeout'}) continue try: _block_results.append(_fut.result(timeout=_remaining)) except Exception as _e: _block_results.append({'error': str(_e)}) cert_item_results: list[dict[str, Any]] = [] first_result: dict[str, Any] = {} for block_idx, br in enumerate(_block_results): if not first_result: first_result = br cert_item_results.append({ 'cert_index': block_idx + 1, 'cert_type': br.get('cert_type'), 'country': br.get('country'), 'cert_numbers': br.get('cert_numbers', []), 'awb': br.get('awb'), 'exporter': br.get('exporter'), 'consignee': br.get('consignee'), 'date': br.get('date'), 'items': br.get('items', []), 'total_stems': br.get('total_stems'), 'total_boxes': br.get('total_boxes'), 'error': br.get('error'), '_ai_model': br.get('_ai_model'), }) result = _merge_certificate_results( [br for br in _block_results if not br.get('error')] ) or first_result result['cert_items'] = cert_item_results result['awb_groups'] = _cert_items_to_awb_groups(cert_item_results) elif doc_type == 'packing': result = parse_packing_with_ensemble(text) else: result = parse_invoice_with_ensemble(text) else: # Legacy single-model path if doc_type == 'certificate': if len(cert_blocks) == 1: result = parse_certificate_with_kimi(cert_blocks[0]) else: cert_item_results_leg: list[dict[str, Any]] = [] first_result_leg: dict[str, Any] = {} for block_idx, block_text in enumerate(cert_blocks): br = parse_certificate_with_kimi(block_text) if not first_result_leg: first_result_leg = br cert_item_results_leg.append({ 'cert_index': block_idx + 1, 'cert_type': br.get('cert_type'), 'country': br.get('country'), 'cert_numbers': br.get('cert_numbers', []), 'awb': br.get('awb'), 'exporter': br.get('exporter'), 'consignee': br.get('consignee'), 'date': br.get('date'), 'items': br.get('items', []), 'total_stems': br.get('total_stems'), 'total_boxes': br.get('total_boxes'), 'error': br.get('error'), '_ai_model': br.get('_ai_model'), }) result = _merge_certificate_results( [ci for ci in cert_item_results_leg if not ci.get('error')] ) or first_result_leg result['cert_items'] = cert_item_results_leg result['awb_groups'] = _cert_items_to_awb_groups(cert_item_results_leg) elif doc_type == 'packing': result = parse_packing_with_ai(text) else: result = parse_invoice_with_ai(text) result['_doc_type'] = doc_type # Post-validation: filter AI-hallucinated EUR1 numbers not confirmed by regex OCR if ocr_cert_numbers and doc_type == 'certificate': _ocr_norm = {n.upper().replace(' ', '') for n in ocr_cert_numbers} _ai_cn = result.get('cert_numbers', []) _validated_cn = [] for _n in _ai_cn: _bare = _n.upper().replace(' ', '') for _pfx in ('EUR1-', 'DIAN-'): if _bare.startswith(_pfx): _bare = _bare[len(_pfx):] break _keep = any(_bare in _o or _o in _bare for _o in _ocr_norm) if not _keep and re.fullmatch(r'(?:[A-Z]{0,3})?03\d{5}', _bare): _keep = True # Ecuador EUR1 format: 03xxxxx if _keep: _validated_cn.append(_n) if _validated_cn != _ai_cn: result['cert_numbers'] = _validated_cn # Also validate per-item cert_numbers for _it in result.get('items', []): _icn = _it.get('cert_number', '') if _icn: _ib = _icn.upper().replace(' ', '') for _pfx in ('EUR1-', 'DIAN-'): if _ib.startswith(_pfx): _ib = _ib[len(_pfx):] break _ikeep = any(_ib in _o or _o in _ib for _o in _ocr_norm) if not _ikeep and re.fullmatch(r'(?:[A-Z]{0,3})?03\d{5}', _ib): _ikeep = True if not _ikeep: _it['cert_number'] = None # Also validate cert_items in awb_groups/cert_items if ocr_cert_numbers and doc_type == 'certificate': _ocr_norm2 = {n.upper().replace(' ', '') for n in ocr_cert_numbers} for _ci in result.get('cert_items', []): _cns = _ci.get('cert_numbers', []) _vcns = [] for _n in _cns: _bare = _n.upper().replace(' ', '') for _pfx in ('EUR1-', 'DIAN-'): if _bare.startswith(_pfx): _bare = _bare[len(_pfx):] break _keep = any(_bare in _o or _o in _bare for _o in _ocr_norm2) if not _keep and re.fullmatch(r'(?:[A-Z]{0,3})?03\d{5}', _bare): _keep = True if _keep: _vcns.append(_n) if _vcns != _cns: _ci['cert_numbers'] = _vcns for _it in _ci.get('items', []): _icn = _it.get('cert_number', '') if _icn: _ib = _icn.upper().replace(' ', '') for _pfx in ('EUR1-', 'DIAN-'): if _ib.startswith(_pfx): _ib = _ib[len(_pfx):] break _ikeep = any(_ib in _o or _o in _ib for _o in _ocr_norm2) if not _ikeep and re.fullmatch(r'(?:[A-Z]{0,3})?03\d{5}', _ib): _ikeep = True if not _ikeep: _it['cert_number'] = None # Прокидываем подсказки в JSON — их читает ai_bridge.resolve_marking. if hints: for k in ('_source_filename', '_folder_hint', '_user_hint'): v = hints.get(k) if v: result[k] = str(v) return result # --- Blueprint --------------------------------------------------------------- reports_bp = Blueprint('reports', __name__, url_prefix='/api/reports') def _require_auth(): """Импорт login_required через blueprint-level before_request.""" from flask import session if not session.get('authenticated'): return jsonify({'error': 'Не авторизован'}), 401 return None @reports_bp.before_request def _check_auth(): resp = _require_auth() if resp is not None: return resp # --- Эндпоинты --------------------------------------------------------------- @reports_bp.route('', methods=['GET']) @reports_bp.route('/', methods=['GET']) def list_reports(): """Список всех отчётов, сгруппированных по датам (свежие сверху).""" grouped: dict[str, list[dict[str, Any]]] = {} if REPORTS_ROOT.is_dir(): for date_dir in sorted(REPORTS_ROOT.iterdir(), reverse=True): if not date_dir.is_dir(): continue date = date_dir.name if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', date): continue for car_dir in sorted(date_dir.iterdir()): if not car_dir.is_dir(): continue car = car_dir.name paths = _report_paths(date, car) meta = _load_meta(paths) grouped.setdefault(date, []).append({ 'report_id': _report_id(date, car), 'car_number': car, 'date': date, 'created': meta.get('created'), 'files_count': sum( 1 for p in paths['sources'].iterdir() if p.is_file() and _USER_UPLOADED_RE.match(p.name) ) if paths['sources'].is_dir() else 0, 'output_count': len(list(paths['output'].glob('*'))) if paths['output'].is_dir() else 0, 'built': bool(meta.get('built_at')), 'built_at': meta.get('built_at'), 'output': _list_output(paths), 'build_warnings': meta.get('build_warnings') or [], }) # плоский список для удобства days = [ {'date': d, 'items': items} for d, items in grouped.items() ] return jsonify({'days': days}) @reports_bp.route('/create', methods=['POST']) def create_report(): """Создать отчёт по номеру машины. `{car_number}` → `{report_id}`.""" data = request.get_json(silent=True) or {} car = normalize_car_number(data.get('car_number', '')) if not car: return jsonify({'error': 'Не указан номер машины'}), 400 if len(car) > 20: return jsonify({'error': 'Слишком длинный номер машины'}), 400 date = _today() paths = _report_paths(date, car) # Если отчёт по этому номеру уже есть на сегодня — просто возвращаем его. already = paths['base'].is_dir() _ensure_dirs(paths) meta = _load_meta(paths) if not meta: meta = { 'car_number': car, 'date': date, 'created': datetime.now().isoformat(timespec='seconds'), } _save_meta(paths, meta) return jsonify({ 'ok': True, 'report_id': _report_id(date, car), 'car_number': car, 'date': date, 'existed': already, }) @reports_bp.route('/<report_id>', methods=['GET']) def get_report(report_id: str): """Данные отчёта: карточка + файлы + распарсенные JSON + список output.""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, date, car = got meta = _load_meta(paths) return jsonify({ 'report_id': report_id, 'car_number': car, 'date': date, 'meta': meta, 'files': _list_files(paths), 'output': _list_output(paths), }) @reports_bp.route('/<report_id>', methods=['DELETE']) def delete_report(report_id: str): """Полностью удалить отчёт.""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got try: shutil.rmtree(paths['base']) except Exception as e: return jsonify({'error': f'Не удалось удалить: {e}'}), 500 return jsonify({'ok': True}) @reports_bp.route('', methods=['DELETE']) def delete_all_reports(): """Удалить ВСЕ отчёты (все папки <date>/<car>).""" deleted = 0 errors: list[str] = [] if not REPORTS_ROOT.is_dir(): return jsonify({'ok': True, 'deleted': 0}) for date_dir in sorted(REPORTS_ROOT.iterdir()): if not date_dir.is_dir(): continue for car_dir in sorted(date_dir.iterdir()): if not car_dir.is_dir(): continue try: shutil.rmtree(car_dir) deleted += 1 except Exception as e: errors.append(f'{date_dir.name}/{car_dir.name}: {e}') # Удаляем пустую date-папку try: if date_dir.is_dir() and not any(date_dir.iterdir()): date_dir.rmdir() except Exception: pass return jsonify({'ok': True, 'deleted': deleted, 'errors': errors}) @reports_bp.route('/<report_id>/upload', methods=['POST']) def upload_to_report(report_id: str): """Загрузить один или несколько файлов в отчёт (без AI-парсинга). Парсинг делается отдельным запросом /reparse/<name> — так каждый HTTP-запрос короткий, и прокси (serveo/cloudflare) не рубят соединение по таймауту. Опциональный параметр формы `doc_type` (invoice/packing/certificate/other) перекрывает автоугадывание. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, date, car = got if 'files' not in request.files: return jsonify({'error': 'Нет файлов'}), 400 files = request.files.getlist('files') if not files: return jsonify({'error': 'Пустой список файлов'}), 400 forced_type = (request.form.get('doc_type') or '').strip().lower() if forced_type and forced_type not in ('invoice', 'packing', 'certificate', 'other'): forced_type = '' # Опциональная маркировка — один код на всю пачку загружаемых файлов. # Причина: все инвойсы одного клиента (напр. «B-AQUA») обычно лежат # в одной папке, но AI может путать маркировку с именем компании-получателя. marking_hint = (request.form.get('marking_hint') or '').strip() saved: list[dict[str, Any]] = [] hints_map: dict[str, dict[str, str]] = {} # будет сохранён в meta for f in files: if not f.filename: continue # Сохраняем исходное имя (вкл. webkitRelativePath для drop папки). raw_rel = f.filename.replace('\\', '/') orig = os.path.basename(raw_rel) # Если в filename есть папка («1561 AQUA/Invoice.pdf») — берём в folder_hint. folder_hint = '' if '/' in raw_rel: # Берём папку ближайшую к файлу (последнюю перед basename). folder_hint = raw_rel.rsplit('/', 1)[0].rsplit('/', 1)[-1] ext = os.path.splitext(orig)[1].lower() if ext not in ALLOWED_EXTENSIONS and ext not in SCAN_EXTENSIONS: saved.append({'name': orig, 'error': f'Расширение {ext} не разрешено'}) continue doc_type = forced_type or _guess_type(orig) idx = _next_index(paths['sources'], doc_type) new_name = f'{doc_type}_{car}_{idx}{ext}' dest = paths['sources'] / new_name try: f.save(dest) except Exception as e: saved.append({'name': orig, 'error': f'Сохранение: {e}'}) continue # Пометим, нужен ли парсинг (только для PDF/Excel) needs_parse = ext in PARSEABLE_PDF or ext in ('.xls', '.xlsx', '.xlsm') # Сохраним хинты (нужны при последующем reparse). hint_entry = {} if orig: hint_entry['orig_name'] = orig if folder_hint: hint_entry['folder_hint'] = folder_hint if marking_hint: hint_entry['user_hint'] = marking_hint if hint_entry: hints_map[new_name] = hint_entry saved.append({ 'name': new_name, 'orig': orig, 'doc_type': doc_type, 'needs_parse': needs_parse, 'parsed': False, # пока не спарсен }) # обновим meta meta = _load_meta(paths) meta['last_upload'] = datetime.now().isoformat(timespec='seconds') if hints_map: existing = meta.get('hints') or {} existing.update(hints_map) meta['hints'] = existing _save_meta(paths, meta) return jsonify({'ok': True, 'saved': saved, 'files': _list_files(paths)}) @reports_bp.route('/<report_id>/file/<path:name>', methods=['GET']) def get_report_file(report_id: str, name: str): """Отдать исходный файл (inline — для PDF-preview в браузере).""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got safe = secure_filename(name) or name # secure_filename может убить кириллицу fpath = paths['sources'] / safe if not fpath.is_file(): # fallback без secure_filename — файлы у нас в ASCII-именах, но на всякий случай fpath = paths['sources'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 resp = send_from_directory( str(fpath.parent), fpath.name, as_attachment=False, ) # Явно запрещаем браузеру угадывать MIME — иначе расширения типа Яндекс.Диска # могут перехватить xlsx/pdf-поток и увести пользователя в свой вьювер. resp.headers['X-Content-Type-Options'] = 'nosniff' resp.headers['Content-Disposition'] = f'inline; filename="{fpath.name}"' return resp def _xlsx_to_html(fpath: Path) -> str: """Превратить книгу Excel в самодостаточный HTML с таблицами по листам. Мы намеренно обрезаем полностью пустой хвост строк/колонок и ставим жёсткий лимит на число строк — в реальных инвойсах бывает лист на несколько сотен тысяч логически пустых ячеек, из-за чего HTML вырастал бы до сотен МБ и iframe вешал бы браузер. Включает фоновые цвета ячеек (fill) для корректной раскраски строк. """ import openpyxl from openpyxl.styles.colors import Color # read_only=False чтобы получить стили (fill/colors) wb = openpyxl.load_workbook(str(fpath), data_only=True, read_only=False) MAX_ROWS_PER_SHEET = 2000 MAX_COLS = 40 def esc(v: Any) -> str: if v is None: return '' s = str(v) return ( s.replace('&', '&') .replace('<', '<') .replace('>', '>') .replace('"', '"') ) def _color_to_hex(color: Color) -> str: """Извлечь hex-цвет из openpyxl Color. Возвращает '' если нет цвета.""" if color is None: return '' try: if color.type == 'rgb' and color.rgb: rgb = str(color.rgb) # Формат AARRGGBB или RRGGBB if len(rgb) == 8: # Пропускаем если полностью прозрачный или чёрный (дефолт) if rgb == '00000000': return '' return '#' + rgb[2:] # убираем alpha elif len(rgb) == 6: return '#' + rgb elif color.type == 'indexed' and color.indexed is not None: # Стандартная палитра Excel (основные цвета) _INDEXED = { 0: '#000000', 1: '#FFFFFF', 2: '#FF0000', 3: '#00FF00', 4: '#0000FF', 5: '#FFFF00', 6: '#FF00FF', 7: '#00FFFF', 8: '#000000', 9: '#FFFFFF', 10: '#FF0000', 11: '#00FF00', 12: '#0000FF', 13: '#FFFF00', 14: '#FF00FF', 15: '#00FFFF', 16: '#800000', 17: '#008000', 18: '#000080', 19: '#808000', 20: '#800080', 21: '#008080', 22: '#C0C0C0', 23: '#808080', 24: '#9999FF', 25: '#993366', 26: '#FFFFCC', 27: '#CCFFFF', 28: '#660066', 29: '#FF8080', 30: '#0066CC', 31: '#CCCCFF', 32: '#000080', 33: '#FF00FF', 34: '#FFFF00', 35: '#00FFFF', 36: '#800080', 37: '#800000', 38: '#008080', 39: '#0000FF', 40: '#00CCFF', 41: '#CCFFFF', 42: '#CCFFCC', 43: '#FFFF99', 44: '#99CCFF', 45: '#FF99CC', 46: '#CC99FF', 47: '#FFCC99', 48: '#3366FF', 49: '#33CCCC', 50: '#99CC00', 51: '#FFCC00', 52: '#FF9900', 53: '#FF6600', 54: '#666699', 55: '#969696', 56: '#003366', 57: '#339966', 58: '#003300', 59: '#333300', 60: '#993300', 61: '#993366', 62: '#333399', 63: '#333333', 64: None, # system foreground (default) } idx = int(color.indexed) return _INDEXED.get(idx, '') or '' elif color.type == 'theme' and color.theme is not None: # Тема — дефолтные цвета Office _THEME = { 0: '#FFFFFF', 1: '#000000', 2: '#E7E6E6', 3: '#44546A', 4: '#4472C4', 5: '#ED7D31', 6: '#A5A5A5', 7: '#FFC000', 8: '#5B9BD5', 9: '#70AD47', } base = _THEME.get(int(color.theme), '') if not base: return '' # Применяем tint (осветление > 0, затемнение < 0) tint = float(color.tint or 0) if tint == 0: return base r_val = int(base[1:3], 16) g_val = int(base[3:5], 16) b_val = int(base[5:7], 16) if tint < 0: factor = 1 + tint # e.g. tint=-0.25 → factor=0.75 r_val = int(r_val * factor) g_val = int(g_val * factor) b_val = int(b_val * factor) else: r_val = int(r_val + (255 - r_val) * tint) g_val = int(g_val + (255 - g_val) * tint) b_val = int(b_val + (255 - b_val) * tint) return f'#{r_val:02X}{g_val:02X}{b_val:02X}' except Exception: pass return '' def _row_bg(row_cells) -> str: """Определить фоновый цвет строки по первой ячейке с заливкой. (Оставлено для обратной совместимости — используем НЕ везде.)""" for cell in row_cells: fill = cell.fill if fill and fill.patternType and fill.patternType != 'none': hex_color = _color_to_hex(fill.fgColor) if hex_color and hex_color.upper() not in ('#000000', '#FFFFFF'): return hex_color return '' def _cell_bg(cell) -> str: """Извлечь цвет заливки конкретной ячейки. Пусто — не красим.""" fill = cell.fill if not fill or not fill.patternType or fill.patternType == 'none': return '' hex_color = _color_to_hex(fill.fgColor) if not hex_color: return '' up = hex_color.upper() if up in ('#000000', '#FFFFFF'): return '' return hex_color def _contrast_font(bg_hex: str) -> str: """Белый шрифт на тёмном фоне, чёрный на светлом.""" if not bg_hex or len(bg_hex) < 7: return '' try: r = int(bg_hex[1:3], 16) g = int(bg_hex[3:5], 16) b = int(bg_hex[5:7], 16) except Exception: return '' # Rec.709 luminance y = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0 return '#FFFFFF' if y < 0.55 else '#000000' def _read_sheet(ws): """Читаем строки с объектами ячеек (для стилей).""" rows = [] for row in ws.iter_rows(max_col=MAX_COLS): rows.append(list(row)) if len(rows) >= MAX_ROWS_PER_SHEET: break # Обрезаем пустые trailing rows while rows and all(c.value is None or c.value == '' for c in rows[-1]): rows.pop() if not rows: return [], 0 # Максимально значимое число колонок max_col = 0 for r in rows: for i in range(len(r) - 1, -1, -1): if r[i].value is not None and r[i].value != '': if i + 1 > max_col: max_col = i + 1 break max_col = min(max_col, MAX_COLS) return [r[:max_col] for r in rows], max_col parts: list[str] = [] for sheet_name in wb.sheetnames: ws = wb[sheet_name] # Chartsheet и прочие не-табличные листы данных не содержат — пропускаем if not hasattr(ws, 'iter_rows'): continue rows, max_col = _read_sheet(ws) parts.append(f'<h2>{esc(sheet_name)} <small style="color:#888;font-weight:normal">— {len(rows)} строк(и)</small></h2>') if not rows: parts.append('<p style="color:#888"><i>Пусто</i></p>') continue parts.append('<div class="tbl-wrap"><table><thead>') # Header row — красим каждую ячейку по её собственному fill parts.append('<tr>') for cell in rows[0]: bg = _cell_bg(cell) if bg: fg = _contrast_font(bg) st = f' style="background-color:{bg};color:{fg};font-weight:bold"' else: st = '' parts.append(f'<th{st}>{esc(cell.value)}</th>') parts.append('</tr></thead><tbody>') for row in rows[1:]: cells_html = [] for c in row: bg = _cell_bg(c) if bg: fg = _contrast_font(bg) st = f' style="background-color:{bg};color:{fg}"' else: st = '' cells_html.append(f'<td{st}>{esc(c.value)}</td>') parts.append('<tr>' + ''.join(cells_html) + '</tr>') parts.append('</tbody></table></div>') if len(rows) >= MAX_ROWS_PER_SHEET: parts.append( f'<p style="color:#c5221f;font-size:12px">' f'⚠ Показаны первые {MAX_ROWS_PER_SHEET} строк. ' f'Полный файл — по ссылке «Скачать оригинал».</p>' ) wb.close() tables_html = '\n'.join(parts) or '<p><i>Пусто</i></p>' return f"""<!doctype html> <html lang="ru"><head> <meta charset="utf-8"> <title>{esc(fpath.name)}</title> <style> body {{ font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 12px; color: #222; background: #fff; }} h2 {{ margin: 12px 0 6px; font-size: 14px; color: #444; border-bottom: 1px solid #ddd; padding-bottom: 3px; }} .tbl-wrap {{ overflow-x: auto; margin-bottom: 20px; }} table {{ border-collapse: collapse; font-size: 12px; }} th, td {{ border: 1px solid #ccc; padding: 4px 8px; vertical-align: top; white-space: pre-wrap; text-align: left; }} th {{ background: #f4f6fa; position: sticky; top: 0; }} /* Зебра только для ячеек БЕЗ собственного цвета (у окрашенных inline-стиль победит) */ tr:nth-child(even) td:not([style]) {{ background: #fafbfc; }} </style> </head><body> {tables_html} </body></html>""" def _xls_to_html(fpath: Path) -> str: """Старый .xls (BIFF) — через xlrd, если есть; иначе показываем текст.""" from flask import Response # noqa: F401 try: import xlrd # type: ignore except Exception: # xlrd нет — просто отдадим текст через существующий парсер from ai_parser import extract_text_from_excel text = extract_text_from_excel(str(fpath)) safe = (text or '(пусто)').replace('<', '<').replace('>', '>') return f"""<!doctype html><meta charset="utf-8"> <style>body{{font-family:Segoe UI,sans-serif;padding:12px;font-size:13px;white-space:pre-wrap;background:#fff;color:#222}}</style> <body>{safe}</body>""" wb = xlrd.open_workbook(str(fpath)) def esc(v: Any) -> str: if v is None: return '' return str(v).replace('&', '&').replace('<', '<').replace('>', '>') parts: list[str] = [] for sh in wb.sheets(): parts.append(f'<h2>{esc(sh.name)}</h2><div class="tbl-wrap"><table>') for ri in range(sh.nrows): tag = 'th' if ri == 0 else 'td' parts.append('<tr>' + ''.join( f'<{tag}>{esc(sh.cell_value(ri, ci))}</{tag}>' for ci in range(sh.ncols) ) + '</tr>') parts.append('</table></div>') return f"""<!doctype html> <html lang="ru"><head><meta charset="utf-8"><title>{esc(fpath.name)}</title> <style> body {{ font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 12px; color: #222; background: #fff; }} h2 {{ margin: 12px 0 6px; font-size: 14px; color: #444; border-bottom: 1px solid #ddd; padding-bottom: 3px; }} .tbl-wrap {{ overflow-x: auto; margin-bottom: 20px; }} table {{ border-collapse: collapse; font-size: 12px; }} th, td {{ border: 1px solid #ccc; padding: 4px 8px; vertical-align: top; white-space: pre-wrap; text-align: left; }} th {{ background: #f4f6fa; position: sticky; top: 0; }} tr:nth-child(even) td {{ background: #fafbfc; }} </style></head><body> {''.join(parts) or '<p><i>Пусто</i></p>'} </body></html>""" @reports_bp.route('/<report_id>/cert_parse/<path:name>', methods=['GET']) def cert_parse_file(report_id: str, name: str): """Regex-парсинг сертификата через cert_parser.py (для панели «Сравнить»). Возвращает CertificateData как JSON: country, cert_number, eur1_numbers, dian_numbers, eur1_by_master, eur1_by_cert, awb_mismatches, master_invoices, awb, exporter, consignee, date, items, total_boxes, total_stems. OCR включён — DIAN/EUR1-номера есть только на сканах сертификатов; результаты кэшируются, повторные вызовы работают быстро. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['sources'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 # --- Fast path: serve the background-parsed JSON if it is fresh --- paths['parsed'].mkdir(parents=True, exist_ok=True) source_mtime = fpath.stat().st_mtime parsed_json_path = paths['parsed'] / (fpath.stem + '.json') try: if parsed_json_path.is_file(): cached = json.loads(parsed_json_path.read_text(encoding='utf-8')) if ( parsed_json_path.stat().st_mtime >= source_mtime and isinstance(cached, dict) and not cached.get('error') and (cached.get('items') or cached.get('cert_items')) ): return jsonify(cached) except Exception: pass # --- Fallback cache: reuse parsed regex result if source file hasn't changed --- cache_path = paths['parsed'] / f"{name}.regex_cache.json" try: if cache_path.is_file(): cached = json.loads(cache_path.read_text(encoding='utf-8')) if cached.get('source_mtime') == source_mtime: return jsonify(cached['result']) except Exception: pass # If background parsing is still running, don't block the worker with # synchronous OCR (Ecuador OCR can take 15+ minutes and trips the proxy # timeout). Return 202 so the frontend can keep the spinner / retry. if _is_parsing_active(paths, fpath.stem): return jsonify({ 'status': 'parsing', 'message': 'Распознавание в процессе, подождите', }), 202 try: import sys as _sys _proj = str(Path(__file__).resolve().parent.parent) if _proj not in _sys.path: _sys.path.insert(0, _proj) from cert_parser import parse_certificate cd = parse_certificate(str(fpath)) result = { 'country': cd.country, 'cert_number': cd.cert_number, 'eur1_numbers': cd.eur1_numbers, 'dian_numbers': cd.dian_numbers, 'eur1_by_master': cd.eur1_by_master, 'eur1_by_cert': cd.eur1_by_cert, 'awb_mismatches': cd.awb_mismatches, 'awb_groups': cd.awb_groups, 'master_invoices': cd.master_invoices, 'awb': cd.awb, 'all_awbs': cd.all_awbs, 'exporter': cd.exporter, 'consignee': cd.consignee, 'date': cd.date, 'items': [ { 'flower': it.flower, 'farm': it.farm, 'marking': it.marking, 'invoice_nr': it.invoice_nr, 'cert_number': it.cert_number, 'master_invoice': it.master_invoice, 'weight_kg': it.weight_kg, 'boxes': it.boxes, 'hs_code': it.hs_code, 'stems': it.stems, 'price_unit': it.price_unit, 'total_price': it.total_price, } for it in cd.items ], 'total_boxes': cd.total_boxes, 'total_stems': cd.total_stems, 'raw_pages': cd.raw_pages, 'source': cd.source, } try: cache_path.write_text( json.dumps({'source_mtime': source_mtime, 'result': result}, ensure_ascii=False, indent=2), encoding='utf-8' ) except Exception: pass return jsonify(result) except Exception as e: return jsonify({'error': f'cert_parser: {e}'}), 500 @reports_bp.route('/<report_id>/preview/<path:name>', methods=['GET']) def preview_report_file(report_id: str, name: str): """Универсальный inline-предпросмотр: - .pdf → сам PDF (браузер отрендерит), - .xlsx/.xlsm → HTML-таблицы по листам (openpyxl), - .xls → HTML через xlrd или fallback-текст, - остальное → извлечённый текст. Всегда возвращаем как «безопасный» inline-контент, чтобы никакие расширения браузера (Яндекс.Диск и т.п.) не уводили пользователя на свои вьюверы. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['sources'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 return _render_file_preview(fpath) @reports_bp.route('/<report_id>/output-preview/<path:name>', methods=['GET']) def preview_output_file(report_id: str, name: str): """То же, что /preview/, но читает из output/ — для просмотра сформированных файлов (включая пустые шаблоны).""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['output'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден в output/'}), 404 return _render_file_preview(fpath) def _render_file_preview(fpath: Path): """Общий рендерер предпросмотра по абсолютному пути.""" from flask import Response ext = fpath.suffix.lower() if ext == '.pdf': resp = send_from_directory( str(fpath.parent), fpath.name, as_attachment=False, mimetype='application/pdf', ) resp.headers['X-Content-Type-Options'] = 'nosniff' resp.headers['Content-Disposition'] = f'inline; filename="{fpath.name}"' return resp try: if ext in ('.xlsx', '.xlsm'): html = _xlsx_to_html(fpath) elif ext == '.xls': html = _xls_to_html(fpath) else: if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.tif', '.tiff'): resp = send_from_directory( str(fpath.parent), fpath.name, as_attachment=False, ) resp.headers['X-Content-Type-Options'] = 'nosniff' resp.headers['Content-Disposition'] = f'inline; filename="{fpath.name}"' return resp try: text = fpath.read_text(encoding='utf-8', errors='replace') except Exception: text = '(не удалось прочитать файл как текст)' safe = ( text.replace('&', '&').replace('<', '<').replace('>', '>') ) html = ( '<!doctype html><meta charset="utf-8">' '<style>body{font-family:Segoe UI,sans-serif;padding:12px;' 'font-size:13px;white-space:pre-wrap;background:#fff;color:#222}</style>' f'<body>{safe}</body>' ) except Exception as e: html = ( '<!doctype html><meta charset="utf-8">' '<body style="font-family:sans-serif;padding:12px;color:#a00">' f'Не удалось построить предпросмотр: {e}</body>' ) resp = Response(html, mimetype='text/html') resp.headers['Content-Type'] = 'text/html; charset=utf-8' resp.headers['X-Content-Type-Options'] = 'nosniff' resp.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline'" return resp # --- Редактирование сформированных Excel-документов -------------------------- def _classify_output_doc(name: str) -> str: """Определить тип сформированного документа по имени файла.""" n = name.lower() if 'европа' in n or 'europa' in n: return 'europa' if 'импорт' in n or 'import' in n: return 'import' if 'florunner' in n or 'flo-runner' in n: return 'florunner' return 'other' def _xlsx_to_sheets_json(fpath: Path) -> list[dict]: """Прочитать все листы XLSX/XLSM в JSON-структуру для jspreadsheet. Формат: [{name, cells: [[v, v, ...], ...], merges: [{s:{r,c},e:{r,c}}]}] Пустые ячейки → "". Формулы → строкой '=...' чтобы не терять при round-trip. """ from openpyxl import load_workbook wb = load_workbook(str(fpath), data_only=False, keep_vba=fpath.suffix.lower() == '.xlsm') result = [] for ws in wb.worksheets: max_row = ws.max_row or 1 max_col = ws.max_column or 1 cells: list[list] = [] for r in range(1, max_row + 1): row_vals = [] for c in range(1, max_col + 1): cell = ws.cell(row=r, column=c) v = cell.value if v is None: row_vals.append('') elif isinstance(v, (int, float, str, bool)): row_vals.append(v) else: row_vals.append(str(v)) cells.append(row_vals) merges = [] for mr in ws.merged_cells.ranges: merges.append({ 's': {'r': mr.min_row - 1, 'c': mr.min_col - 1}, 'e': {'r': mr.max_row - 1, 'c': mr.max_col - 1}, }) result.append({ 'name': ws.title, 'cells': cells, 'merges': merges, 'rows': max_row, 'cols': max_col, }) return result def _header_for_cell(cells: list[list], row: int, col: int) -> tuple[str, str]: """Найти (row_header, col_header) для ячейки. row_header — левейшая непустая ячейка в той же строке (левее текущей). col_header — верхняя непустая ячейка в той же колонке (выше текущей). Позволяет AI понять контекст правки даже после перегенерации файла. """ row_header = '' col_header = '' try: # Левейшая (от края) непустая ячейка в строке if 0 <= row < len(cells): row_cells = cells[row] for c in range(0, min(col, len(row_cells))): v = row_cells[c] if v not in (None, ''): row_header = str(v).strip() break # Верхняя (от края) непустая ячейка в колонке for r in range(0, row): if r >= len(cells): break row_cells = cells[r] if col < len(row_cells): v = row_cells[col] if v not in (None, ''): col_header = str(v).strip() break except (IndexError, TypeError): pass return row_header, col_header @reports_bp.route('/<report_id>/output-editable/<path:name>', methods=['GET']) def output_editable(report_id: str, name: str): """Вернуть сформированный Excel-файл в виде JSON для jspreadsheet. Поддерживает .xlsx/.xlsm. Для .xls — 400 (легаси). """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['output'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден в output/'}), 404 if fpath.suffix.lower() not in ('.xlsx', '.xlsm'): return jsonify({ 'error': 'Редактирование поддерживается только для .xlsx / .xlsm', }), 400 try: sheets = _xlsx_to_sheets_json(fpath) except Exception as e: return jsonify({'error': f'Чтение Excel: {e}'}), 500 return jsonify({ 'ok': True, 'name': name, 'document_type': _classify_output_doc(name), 'sheets': sheets, }) @reports_bp.route('/<report_id>/save-output/<path:name>', methods=['POST']) def save_output(report_id: str, name: str): """Сохранить отредактированный сформированный документ + записать диффы. Body: { "sheets": [{name, cells: [[...]]}], # полное состояние после правок "diffs": [{sheet, row, col, original, corrected}] } Запись XLSX — через openpyxl (сохраняем стили/форматы оригинала; меняем только value в ячейках из diffs). """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['output'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден в output/'}), 404 if fpath.suffix.lower() not in ('.xlsx', '.xlsm'): return jsonify({'error': 'Поддерживается только .xlsx / .xlsm'}), 400 body = request.get_json(silent=True) or {} sheets_after = body.get('sheets') or [] diffs = body.get('diffs') or [] if not isinstance(sheets_after, list): return jsonify({'error': 'sheets должен быть списком'}), 400 # 1. Применяем правки к XLSX через openpyxl — стили сохраняются try: from openpyxl import load_workbook keep_vba = fpath.suffix.lower() == '.xlsm' wb = load_workbook(str(fpath), keep_vba=keep_vba) sheet_by_name = {ws.title: ws for ws in wb.worksheets} for s in sheets_after: sname = s.get('name') cells = s.get('cells') or [] ws = sheet_by_name.get(sname) if ws is None: continue for r_idx, row in enumerate(cells, start=1): for c_idx, val in enumerate(row, start=1): if val == '': val = None try: cur = ws.cell(row=r_idx, column=c_idx).value except Exception: cur = None # Не перезаписываем формулы, если в входе такое же число if isinstance(cur, str) and cur.startswith('=') and val == cur: continue if cur != val: ws.cell(row=r_idx, column=c_idx).value = val wb.save(str(fpath)) except Exception as e: return jsonify({'error': f'Сохранение Excel: {e}'}), 500 # 2. Записываем диффы в corrections.output_history saved = 0 if diffs: try: project_root = Path(__file__).resolve().parent.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from corrections import load_corrections, save_corrections as _save_corr corr = load_corrections() doc_type = _classify_output_doc(name) # Соберём контекст из актуальных sheets_after для header-лукапа sheets_map = {s.get('name'): (s.get('cells') or []) for s in sheets_after} # Мета отчёта — клиент/страна если есть meta = _load_meta(paths) client = (meta.get('client') or meta.get('customer') or '').strip() country = (meta.get('country') or '').strip() for d in diffs: sheet = d.get('sheet') or '' r = int(d.get('row') or 0) c = int(d.get('col') or 0) orig = d.get('original', '') new = d.get('corrected', '') if str(orig) == str(new): continue cells = sheets_map.get(sheet) or [] row_h, col_h = _header_for_cell(cells, r, c) if cells else ('', '') ctx = { 'row_header': row_h, 'col_header': col_h, 'client': client, 'country': country, } corr.add_output_correction( document_type=doc_type, sheet=sheet, row=r, col=c, original_value=orig, corrected_value=new, context=ctx, source_file=name, ) saved += 1 _save_corr(corr) except Exception as e: return jsonify({ 'ok': True, 'file_saved': True, 'diffs_saved': 0, 'corrections_error': str(e), }) return jsonify({'ok': True, 'file_saved': True, 'diffs_saved': saved}) @reports_bp.route('/<report_id>/file/<path:name>', methods=['DELETE']) def delete_report_file(report_id: str, name: str): """Удалить исходный файл и связанный JSON.""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['sources'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 try: fpath.unlink() jpath = paths['parsed'] / (fpath.stem + '.json') if jpath.is_file(): jpath.unlink() except Exception as e: return jsonify({'error': f'Удаление: {e}'}), 500 return jsonify({'ok': True}) def _parsing_lock_path(paths: dict[str, Path], stem: str) -> Path: return paths['parsed'] / (stem + '.parsing') def _is_parsing_active(paths: dict[str, Path], stem: str, max_seconds: int = 1800) -> bool: lock = _parsing_lock_path(paths, stem) if not lock.is_file(): return False try: data = json.loads(lock.read_text(encoding='utf-8')) started = datetime.fromisoformat(data.get('started', '')) return (datetime.now() - started).total_seconds() < max_seconds except Exception: return False @reports_bp.route('/<report_id>/reparse/<path:name>', methods=['POST']) def reparse_file(report_id: str, name: str): """Перепарсить один файл через AI в фоновом процессе. Долгие AI-запросы не блокируют gunicorn-worker и не обрываются прокси. Возвращает 202 Accepted со статусом 'started' / 'parsing'. Фронтенд опрашивает отчёт, пока не появится parsed JSON. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, car = got fpath = paths['sources'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 # Одноразовый override маркировки через request body/query. _body = request.get_json(silent=True) or {} override_mark = (request.args.get('marking_hint') or _body.get('marking_hint') or '').strip() stem = fpath.stem paths['parsed'].mkdir(parents=True, exist_ok=True) if _is_parsing_active(paths, stem): return jsonify({'ok': True, 'status': 'parsing'}), 202 # Удаляем старый/зависший лок, чтобы не мешал новому запуску. lock = _parsing_lock_path(paths, stem) try: lock.unlink(missing_ok=True) except Exception: pass # Удаляем старый результат, чтобы фронт не показывал прошлые данные как готовые. old_json = paths['parsed'] / (stem + '.json') if old_json.is_file(): try: old_json.unlink() except Exception: pass try: args = [sys.executable, '-m', 'web.parse_worker', report_id, name] if override_mark: args.append(override_mark) subprocess.Popen( args, cwd=str(PROJECT_ROOT), close_fds=True, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except Exception as e: return jsonify({'error': f'Не удалось запустить парсер: {e}'}), 500 return jsonify({'ok': True, 'status': 'started'}), 202 @reports_bp.route('/<report_id>/retype/<path:name>', methods=['POST']) def retype_file(report_id: str, name: str): """Сменить тип документа: переименовать файл (и его JSON) + перепарсить. Тело: `{"doc_type": "invoice" | "packing" | "certificate" | "other"}`. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, car = got data = request.get_json(silent=True) or {} new_type = (data.get('doc_type') or '').strip().lower() if new_type not in ('invoice', 'packing', 'certificate', 'other'): return jsonify({'error': 'Недопустимый doc_type'}), 400 fpath = paths['sources'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 ext = fpath.suffix.lower() idx = _next_index(paths['sources'], new_type) new_name = f'{new_type}_{car}_{idx}{ext}' new_path = paths['sources'] / new_name if new_path.exists(): return jsonify({'error': f'Файл {new_name} уже существует'}), 409 # Снимаем старый лок, если вдруг висел. try: _parsing_lock_path(paths, fpath.stem).unlink(missing_ok=True) except Exception: pass try: fpath.rename(new_path) except Exception as e: return jsonify({'error': f'Переименование: {e}'}), 500 # Удалим старый JSON, если был old_json = paths['parsed'] / (fpath.stem + '.json') if old_json.is_file(): try: old_json.unlink() except Exception: pass # Мигрируем хинты на новое имя meta_now = _load_meta(paths) hints_all = (meta_now.get('hints') or {}) hints = hints_all.get(name) or {} if hints: hints_all.pop(name, None) hints_all[new_name] = hints meta_now['hints'] = hints_all _save_meta(paths, meta_now) # Удалим зависший лок для нового имени, если есть. try: _parsing_lock_path(paths, new_path.stem).unlink(missing_ok=True) except Exception: pass # Запускаем фоновый парсинг с новым типом — не блокируем gunicorn. try: subprocess.Popen( [sys.executable, '-m', 'web.parse_worker', report_id, new_name], cwd=str(PROJECT_ROOT), close_fds=True, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except Exception as e: return jsonify({'error': f'Не удалось запустить парсер: {e}'}), 500 return jsonify({'ok': True, 'status': 'started', 'new_name': new_name}), 202 @reports_bp.route('/<report_id>/parsed/<path:name>', methods=['PATCH']) def patch_parsed(report_id: str, name: str): """Ручная правка распарсенного JSON (оператор скорректировал поля).""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got payload = request.get_json(silent=True) if not isinstance(payload, dict): return jsonify({'error': 'Ожидается JSON-объект'}), 400 stem = os.path.splitext(name)[0] jpath = paths['parsed'] / (stem + '.json') try: jpath.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8', ) except Exception as e: return jsonify({'error': f'Сохранение JSON: {e}'}), 500 return jsonify({'ok': True}) @reports_bp.route('/<report_id>/save-corrections/<path:name>', methods=['POST']) def save_corrections_endpoint(report_id: str, name: str): """Сохранить отредактированный JSON + записать диффы в корректировки. Body JSON: { "edited": { ... полный отредактированный parsed-объект ... }, "diffs": [ {"field": "stems", "original": "10400", "corrected": "10200", "item_index": 0}, {"field": "country", "original": "EC", "corrected": "CO"} ] } """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got body = request.get_json(silent=True) or {} edited = body.get('edited') diffs = body.get('diffs') or [] if not isinstance(edited, dict): return jsonify({'error': 'Ожидается edited: {} в теле запроса'}), 400 stem = os.path.splitext(name)[0] jpath = paths['parsed'] / (stem + '.json') # 1. Сохраняем отредактированный JSON try: jpath.write_text( json.dumps(edited, ensure_ascii=False, indent=2), encoding='utf-8', ) except Exception as e: return jsonify({'error': f'Сохранение JSON: {e}'}), 500 # 2. Читаем source text для контекста source_text = '' txt_path = paths['parsed'] / (stem + '.txt') if txt_path.is_file(): try: source_text = txt_path.read_text(encoding='utf-8')[:2000] except Exception: pass # 3. Записываем корректировки if diffs: import sys from pathlib import Path as P project_root = P(__file__).resolve().parent.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from corrections import load_corrections, save_corrections as _save_corr corr = load_corrections() supplier = edited.get('supplier') or '' source_file = name for d in diffs: field = d.get('field', '') original = str(d.get('original', '')) corrected = str(d.get('corrected', '')) if not field or original == corrected: continue # Извлекаем релевантный фрагмент из source_text (поиск по original value) snippet = '' if source_text and original: # Попробуем найти строку содержащую original value for line in source_text.split('\n'): if original.lower() in line.lower(): snippet = line.strip()[:200] break if not snippet: # Берём первые 300 симв. как общий контекст snippet = source_text[:300] corr.add_correction( source_file=source_file, supplier=supplier, correction_type='recognition', field_name=field, original_value=original, corrected_value=corrected, source_text=snippet, ) _save_corr(corr) return jsonify({'ok': True, 'diffs_saved': len(diffs)}) # --- AI Ensemble review: side-by-side DeepSeek / Kimi ------------------------ PathToken = str | int def _path_to_str(path: list[PathToken]) -> str: """Convert path list to dot-separated string (indices kept as numbers).""" return '.'.join(str(p) for p in path) def _str_to_path(path_str: str) -> list[PathToken]: """Convert dot-separated path string back to list of str/int tokens.""" out: list[PathToken] = [] for part in path_str.split('.'): try: out.append(int(part)) except ValueError: out.append(part) return out def _get_at_path(obj: Any, path: list[PathToken]) -> Any: for p in path: if isinstance(obj, dict): obj = obj.get(p) elif isinstance(obj, list) and isinstance(p, int) and p < len(obj): obj = obj[p] else: return None return obj def _set_at_path(obj: Any, path: list[PathToken], value: Any) -> bool: """Set value at path in-place. Returns True on success.""" if not path: return False for p in path[:-1]: if isinstance(obj, dict): obj = obj.setdefault(p, {} if isinstance(path[path.index(p) + 1], str) else []) elif isinstance(obj, list) and isinstance(p, int) and p < len(obj): obj = obj[p] else: return False last = path[-1] if isinstance(obj, dict): obj[last] = value return True if isinstance(obj, list) and isinstance(last, int) and last < len(obj): obj[last] = value return True return False def _build_ai_review_for_file(name: str, parsed: dict[str, Any]) -> dict[str, Any] | None: """Build review payload for one parsed file. Returns None if not ensemble.""" meta = parsed.get('_ensemble_meta') if isinstance(parsed, dict) else None if not meta: return None ds = meta.get('deepseek_output') or {} km = meta.get('kimi_output') or {} disputed_paths = meta.get('disputed_paths') or [] # arbitrator_reasoning is stored inside result['_ensemble']['arbitrator_reasoning'] # or top-level result key depending on the version — look in both places. arb_reasoning: dict[str, Any] = {} ens_inner = parsed.get('_ensemble') or {} raw_reasoning = ens_inner.get('arbitrator_reasoning') or {} if isinstance(raw_reasoning, dict): arb_reasoning = raw_reasoning disputed_items: list[dict[str, Any]] = [] for p in disputed_paths: path_str = _path_to_str(p) # arbitrator_reasoning keys may match the last segment or the full path last_key = p[-1] if p else '' reasoning = arb_reasoning.get(path_str) or arb_reasoning.get(str(last_key)) or '' disputed_items.append({ 'path': path_str, 'deepseek_value': _get_at_path(ds, p), 'kimi_value': _get_at_path(km, p), 'current_value': _get_at_path(parsed, p), 'arbitrator_reasoning': reasoning, }) return { 'name': name, 'doc_type': parsed.get('_doc_type', 'other'), 'ensemble_status': meta.get('status'), 'debate_rounds': meta.get('debate_rounds', 0), 'total_tokens': meta.get('total_tokens'), 'total_latency_sec': meta.get('total_latency_sec'), 'disputed_items': disputed_items, 'has_error': bool(parsed.get('error') or meta.get('arbitrator') == 'error'), } @reports_bp.route('/<report_id>/ai-review', methods=['GET']) def ai_review_list(report_id: str): """Return all parsed files with ensemble review data. Response: { "ok": true, "documents": [ { "name": "invoice_444_1.pdf", "doc_type": "invoice", "ensemble_status": "arbitrated", "debate_rounds": 2, "disputed_items": [ { "path": "items.0.stems", "deepseek_value": 10400, "kimi_value": 10200, "current_value": 10400 } ] } ] } """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got documents: list[dict[str, Any]] = [] for f in _list_files(paths): parsed = f.get('parsed') if not isinstance(parsed, dict): continue review = _build_ai_review_for_file(f['name'], parsed) if review: documents.append(review) return jsonify({'ok': True, 'documents': documents}) @reports_bp.route('/<report_id>/ai-review/<path:name>', methods=['POST']) def ai_review_apply(report_id: str, name: str): """Apply operator choices for disputed paths and save corrections. Body JSON: { "choices": { "items.0.stems": 10200, "supplier": "FRESH FLOWERS" }, "source_text": "optional snippet for corrections" } """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got stem = os.path.splitext(name)[0] jpath = paths['parsed'] / (stem + '.json') if not jpath.is_file(): return jsonify({'error': 'Распознанный JSON не найден'}), 404 body = request.get_json(silent=True) or {} choices = body.get('choices') or {} if not isinstance(choices, dict): return jsonify({'error': 'Ожидается choices: {} в теле запроса'}), 400 try: parsed = json.loads(jpath.read_text(encoding='utf-8')) except Exception as e: return jsonify({'error': f'Чтение JSON: {e}'}), 500 if not isinstance(parsed, dict): return jsonify({'error': 'Невалидный parsed JSON'}), 500 meta = parsed.get('_ensemble_meta') or {} ds = meta.get('deepseek_output') or {} km = meta.get('kimi_output') or {} diffs: list[dict[str, Any]] = [] applied = 0 for path_str, value in choices.items(): path = _str_to_path(str(path_str)) old_value = _get_at_path(parsed, path) if _set_at_path(parsed, path, value): applied += 1 diffs.append({ 'path': path_str, 'original': old_value, 'corrected': value, 'deepseek_value': _get_at_path(ds, path), 'kimi_value': _get_at_path(km, path), }) # Save updated parsed JSON try: jpath.write_text( json.dumps(parsed, ensure_ascii=False, indent=2), encoding='utf-8', ) except Exception as e: return jsonify({'error': f'Сохранение JSON: {e}'}), 500 # Record corrections if diffs: import sys from pathlib import Path as P project_root = P(__file__).resolve().parent.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from corrections import load_corrections, save_corrections as _save_corr corr = load_corrections() supplier = parsed.get('supplier') or '' source_text = (body.get('source_text') or '')[:500] for d in diffs: original = str(d.get('original', '')) corrected = str(d.get('corrected', '')) if original == corrected: continue corr.add_correction( source_file=name, supplier=supplier, correction_type='recognition', field_name=d['path'], original_value=original, corrected_value=corrected, source_text=source_text, ) _save_corr(corr) return jsonify({'ok': True, 'applied': applied, 'diffs_saved': len(diffs)}) # --- Europa AI: авто-фолбэк для «Таблица европа» ----------------------------- def _find_europa_output(paths: dict[str, Path]) -> Path | None: """Найти сгенерированный europa-файл в output/ (после build_report).""" out = paths['output'] if not out.is_dir(): return None # приоритет .xlsm с 'европа' в имени cands = [p for p in out.iterdir() if p.is_file() and 'европа' in p.name.lower() and p.suffix.lower() in ('.xlsm', '.xlsx')] if not cands: return None cands.sort(key=lambda p: p.stat().st_mtime, reverse=True) return cands[0] def _load_europa_packing_sheets(paths: dict[str, Path]): """Пересобрать список PackingSheet из sources/ (используем packing_parser). Импорт lazily — модуль запускается через sys.path. """ from packing_parser import parse_folder as parse_packing_folder return parse_packing_folder(str(paths['sources'])) def _compute_europa_proposals(paths: dict[str, Path]) -> dict[str, Any] | None: """Полный pipeline propose: найти файл → выявить unmapped → спросить ИИ. Возвращает JSON-совместимый dict или None, если nothing to do. Ошибки складываются в поле 'meta.error' — не поднимаем наверх. """ from europa_writer import compute_europa_unmapped from ai_europa_mapper import propose_europa_mappings europa_file = _find_europa_output(paths) if europa_file is None: return { 'markings': [], 'families': [], 'meta': {'error': 'Файл «Таблица европа» не найден в output/'}, } try: sheets = _load_europa_packing_sheets(paths) except Exception as e: return { 'markings': [], 'families': [], 'meta': {'error': f'Ошибка packing_parser: {e}', 'europa_file': europa_file.name}, } try: summary = compute_europa_unmapped(sheets, str(europa_file)) except Exception as e: return { 'markings': [], 'families': [], 'meta': {'error': f'Ошибка анализа шаблона: {e}', 'europa_file': europa_file.name}, } unmapped_marks = summary.get('unmapped_markings') or [] unmapped_fams = summary.get('unmapped_families') or [] tpl_cols = summary.get('template_columns') or [] tpl_rows = summary.get('template_rows') or [] if not unmapped_marks and not unmapped_fams: return { 'markings': [], 'families': [], 'meta': { 'europa_file': europa_file.name, 'note': 'Всё пристроено первым проходом — ИИ не нужен', }, } result = propose_europa_mappings( unmapped_marks, unmapped_fams, tpl_cols, tpl_rows, ) result.setdefault('meta', {})['europa_file'] = europa_file.name result['meta']['template_columns_count'] = len(tpl_cols) result['meta']['template_rows_count'] = len(tpl_rows) return result @reports_bp.route('/<report_id>/europa-ai/propose', methods=['POST', 'GET']) def europa_ai_propose(report_id: str): """Прогнать AI-фолбэк для «Таблица европа» и вернуть предложения (без применения). Результат кэшируется в meta['europa_ai_proposals']. Query-параметр `force=1` — пересчитать даже при наличии кэша. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got force = request.args.get('force') in ('1', 'true', 'yes') meta = _load_meta(paths) if not force and meta.get('europa_ai_proposals'): return jsonify({'ok': True, 'cached': True, 'proposals': meta['europa_ai_proposals']}) proposals = _compute_europa_proposals(paths) if proposals is None: return jsonify({'ok': False, 'error': 'Europa-файл не готов'}), 400 meta['europa_ai_proposals'] = proposals meta['europa_ai_proposed_at'] = datetime.now().isoformat(timespec='seconds') _save_meta(paths, meta) return jsonify({'ok': True, 'cached': False, 'proposals': proposals}) @reports_bp.route('/<report_id>/europa-ai/apply', methods=['POST']) def europa_ai_apply(report_id: str): """Применить выбранные пользователем AI-предложения к europa-файлу. Тело запроса: { "markings": {"KAZBAZA-777": "KAZBAZA", ...}, # marking → column "families": {"HYPERICUM/70": "HYPERICUM", ...} # family/len → row family } Возвращает отчёт AIWriteReport (сколько позиций/стеблей добавлено). Пока НЕ сохраняем в corrections.json — только к файлу (по требованию пользователя «пока только в сессии»). """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got body = request.get_json(silent=True) or {} marks_ov = {str(k): str(v) for k, v in (body.get('markings') or {}).items() if v} fams_ov = {str(k): str(v) for k, v in (body.get('families') or {}).items() if v} if not marks_ov and not fams_ov: return jsonify({'error': 'Нечего применять: пустой список overrides'}), 400 europa_file = _find_europa_output(paths) if europa_file is None: return jsonify({'error': 'Europa-файл не найден в output/'}), 400 from europa_writer import apply_ai_overrides try: sheets = _load_europa_packing_sheets(paths) except Exception as e: return jsonify({'error': f'packing_parser: {e}'}), 500 try: wr = apply_ai_overrides( str(europa_file), sheets, marks_ov, fams_ov, ) except Exception as e: return jsonify({'error': f'apply_ai_overrides: {e}'}), 500 # Обновим meta — сохраним последний применённый набор meta = _load_meta(paths) meta['europa_ai_applied_at'] = datetime.now().isoformat(timespec='seconds') meta['europa_ai_applied'] = { 'markings': marks_ov, 'families': fams_ov, 'items_placed': wr.items_placed, 'stems_added': wr.stems_added, } _save_meta(paths, meta) return jsonify({ 'ok': True, 'europa_file': europa_file.name, 'items_placed': wr.items_placed, 'stems_added': wr.stems_added, 'markings_used': sorted(wr.markings_used), 'families_used': sorted(wr.families_used), 'skipped_items': wr.skipped_items, 'warnings': wr.warnings, }) # --- Import AI: авто-фолбэк для «Таблица импорт» ------------------------------ def _find_import_output(paths: dict[str, Path]) -> Path | None: """Найти сгенерированный import-файл в output/ (после build_report).""" out = paths['output'] if not out.is_dir(): return None cands = [p for p in out.iterdir() if p.is_file() and 'импорт' in p.name.lower() and p.suffix.lower() == '.xlsx'] if not cands: return None cands.sort(key=lambda p: p.stat().st_mtime, reverse=True) return cands[0] def _find_import_template(paths: dict[str, Path]) -> Path | None: """Найти исходный шаблон «Таблица импорт *.xls» в sources/.""" src = paths['sources'] if not src.is_dir(): return None for p in src.iterdir(): low = p.name.lower() if p.is_file() and low.startswith('таблица импорт') and low.endswith('.xls'): return p return None def _compute_import_proposals(paths: dict[str, Path]) -> dict[str, Any] | None: """Полный pipeline propose для import: unmapped → спросить ИИ. Ошибки складываются в поле 'meta.error' — не поднимаем наверх. """ from import_writer import compute_import_unmapped from ai_import_mapper import propose_import_mappings tpl = _find_import_template(paths) if tpl is None: return { 'markings': [], 'meta': {'error': 'Шаблон «Таблица импорт» не найден в sources/'}, } import_file = _find_import_output(paths) if import_file is None: return { 'markings': [], 'meta': {'error': 'Файл «Таблица импорт» не найден в output/'}, } try: sheets = _load_europa_packing_sheets(paths) # те же packing-листы except Exception as e: return { 'markings': [], 'meta': {'error': f'Ошибка packing_parser: {e}', 'import_file': import_file.name}, } try: summary = compute_import_unmapped(sheets, str(tpl)) except Exception as e: return { 'markings': [], 'meta': {'error': f'Ошибка анализа шаблона: {e}', 'import_file': import_file.name}, } unmapped_marks = summary.get('unmapped_markings') or [] columns_by_sheet = summary.get('columns_by_sheet') or {} if not unmapped_marks: return { 'markings': [], 'meta': { 'import_file': import_file.name, 'note': 'Всё пристроено первым проходом — ИИ не нужен', }, } result = propose_import_mappings(unmapped_marks, columns_by_sheet) result.setdefault('meta', {})['import_file'] = import_file.name result['meta']['columns_by_sheet_count'] = { sn: len(cols) for sn, cols in columns_by_sheet.items() } return result @reports_bp.route('/<report_id>/import-ai/propose', methods=['POST', 'GET']) def import_ai_propose(report_id: str): """Прогнать AI-фолбэк для «Таблица импорт» и вернуть предложения (без применения). Результат кэшируется в meta['import_ai_proposals']. Query-параметр `force=1` — пересчитать даже при наличии кэша. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got force = request.args.get('force') in ('1', 'true', 'yes') meta = _load_meta(paths) if not force and meta.get('import_ai_proposals'): return jsonify({'ok': True, 'cached': True, 'proposals': meta['import_ai_proposals']}) proposals = _compute_import_proposals(paths) if proposals is None: return jsonify({'ok': False, 'error': 'Import-файл не готов'}), 400 meta['import_ai_proposals'] = proposals meta['import_ai_proposed_at'] = datetime.now().isoformat(timespec='seconds') _save_meta(paths, meta) return jsonify({'ok': True, 'cached': False, 'proposals': proposals}) @reports_bp.route('/<report_id>/import-ai/apply', methods=['POST']) def import_ai_apply(report_id: str): """Применить выбранные пользователем AI-предложения к import-файлу. Тело запроса: { "markings": {"HARDIN": {"sheet": "Эквадор", "column": "B-FLORIDA"}, ...} } Как и у europa: НЕ сохраняем в corrections.json — только к файлу («пока только в сессии» по требованию пользователя). """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got body = request.get_json(silent=True) or {} marks_ov = {str(k): v for k, v in (body.get('markings') or {}).items() if isinstance(v, dict) and v.get('sheet') and v.get('column')} if not marks_ov: return jsonify({'error': 'Нечего применять: пустой список overrides'}), 400 import_file = _find_import_output(paths) if import_file is None: return jsonify({'error': 'Import-файл не найден в output/'}), 400 from import_writer import apply_import_ai_overrides try: sheets = _load_europa_packing_sheets(paths) except Exception as e: return jsonify({'error': f'packing_parser: {e}'}), 500 try: wr = apply_import_ai_overrides(str(import_file), sheets, marks_ov) except Exception as e: return jsonify({'error': f'apply_import_ai_overrides: {e}'}), 500 # Обновим meta — сохраним последний применённый набор meta = _load_meta(paths) meta['import_ai_applied_at'] = datetime.now().isoformat(timespec='seconds') meta['import_ai_applied'] = { 'markings': marks_ov, 'items_placed': wr.items_placed, 'stems_added': wr.stems_added, } _save_meta(paths, meta) return jsonify({ 'ok': True, 'import_file': import_file.name, 'items_placed': wr.items_placed, 'stems_added': wr.stems_added, 'cells_written': wr.cells_written, 'markings_used': sorted(wr.markings_used), 'skipped_items': wr.skipped_items, 'warnings': wr.warnings, }) @reports_bp.route('/<report_id>/build', methods=['POST']) def build_report(report_id: str): """Собрать 4 итоговых Excel-отчёта из загруженных сканов. Запускаем существующий fill_mtl.py в режиме частичного заполнения: что распознано — то и заполнится, чего нет — оставим пустым. Формируем список предупреждений. """ got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, date, car = got if not any(paths['sources'].iterdir() if paths['sources'].is_dir() else []): return jsonify({'error': 'В отчёт не загружено ни одного файла'}), 400 # fill_mtl принимает папку-«рейс», где лежат сами файлы. Мы прокидываем sources/. fill_mtl_path = PROJECT_ROOT / 'fill_mtl.py' if not fill_mtl_path.is_file(): return jsonify({'error': f'fill_mtl.py не найден: {fill_mtl_path}'}), 500 # Очистим прошлый output if paths['output'].is_dir(): for p in paths['output'].iterdir(): if p.is_file(): try: p.unlink() except Exception: pass # Подчистим старые aux-шаблоны в sources/ (оставшиеся от прошлых сборок): # `_ensure_templates` в fill_mtl не копирует шаблон, если в папке уже есть # файл с таким префиксом — без этой очистки правки шаблона не будут # применяться в существующих отчётах. Пользовательские файлы (invoice_*, # packing_*, certificate_*, other_*) СОХРАНЯЕМ. _AUX_TPL_PREFIXES = ('таблица европа', 'таблица импорт', 'florunner-specification') for p in list(paths['sources'].iterdir()): if not p.is_file(): continue low = p.name.lower() if _USER_UPLOADED_RE.match(p.name): continue # пользовательский файл — не трогаем if '_autofilled' in low or any(low.startswith(pref) for pref in _AUX_TPL_PREFIXES): try: p.unlink() except Exception as e: log_lines.append(f'[cleanup aux fail] {p.name}: {e}') env = dict(os.environ) # OCR включён: достаёт EUR1/DIAN-номера со штампов-сканов; # результаты кэшируются (declarant_ocr_cache), повторные сборки быстрые env.setdefault('SKIP_OCR', '0') env['PYTHONIOENCODING'] = 'utf-8' # AI ensemble fill (Таблица европа / Таблица импорт) env['ENSEMBLE_ENABLE_FILL'] = '1' if ENSEMBLE_ENABLE_FILL else '0' # Какие документы собирать (из JSON body) body = request.get_json(silent=True) or {} selected_docs = body.get('documents') # list or None if selected_docs and isinstance(selected_docs, list): env['BUILD_DOCS'] = ','.join(selected_docs) # else: не ставим BUILD_DOCS — fill_mtl соберёт все log_lines: list[str] = [] try: proc = subprocess.run( [sys.executable, str(fill_mtl_path), str(paths['sources'])], capture_output=True, text=True, timeout=840, env=env, cwd=str(PROJECT_ROOT), encoding='utf-8', errors='replace', ) log_lines.append(proc.stdout or '') if proc.stderr: log_lines.append('--- STDERR ---') log_lines.append(proc.stderr) rc = proc.returncode except subprocess.TimeoutExpired: return jsonify({'error': 'fill_mtl.py: таймаут (>10 мин)'}), 504 except Exception as e: return jsonify({'error': f'Запуск fill_mtl.py: {e}'}), 500 # Собираем сформированные *_autofilled* файлы в output/ src_p = paths['sources'] produced: list[str] = [] output_files_meta: dict[str, dict[str, Any]] = {} for p in src_p.rglob('*_autofilled*'): if not p.is_file(): continue dst = paths['output'] / p.name try: shutil.move(str(p), str(dst)) produced.append(dst.name) output_files_meta[dst.name] = {'is_empty': False} except Exception as e: log_lines.append(f'[move fail] {p.name}: {e}') # Простые предупреждения на основе того, что мы вообще имели на входе # и что реально просил пользователь (selected_docs). warnings: list[str] = [] have_inv = any(f.name.startswith('invoice_') for f in src_p.iterdir()) have_pk = any(f.name.startswith('packing_') for f in src_p.iterdir()) have_ct = any(f.name.startswith('certificate_') for f in src_p.iterdir()) _sel = set(selected_docs) if (selected_docs and isinstance(selected_docs, list)) else None _need_pk = _sel is None or bool(_sel & {'europa', 'import'}) _need_ct = _sel is None or 'florunner' in _sel if not have_inv: warnings.append('Не загружены инвойсы — некоторые поля будут пусты') if _need_pk and not have_pk: warnings.append('Не загружены упаковочные листы (.xls/.xlsx) — таблицы «европа» и «импорт» останутся пустыми шаблонами') if _need_ct and not have_ct: warnings.append('Не загружены сертификаты (EUR1/CoO) — «florunner» останется пустым шаблоном') if not produced: warnings.append('fill_mtl не создал ни одного *_autofilled файла — см. журнал') # Второй проход: aux-шаблоны (europa / импорт / florunner), которые # fill_mtl НЕ смог заполнить (нет packing-листов или сертификатов), # тоже перемещаем в output/ как «пустые» — чтобы пользователь их видел # и мог открыть на предпросмотр, поняв, каких данных не хватило. # # ВАЖНО: если пользователь выбрал только часть документов (selected_docs), # пустые шаблоны НЕ выбранных документов НЕ показываем — иначе результат # засоряется файлами, которые пользователь не заказывал. # Аналогично: если *_autofilled уже создан для этого prefix — пустой # шаблон-«заглушку» пропускаем (не дублируем). empty_specs = [ ('таблица европа', 'europa', 'Нет упаковочных листов (.xls/.xlsx) — данные не подставлены'), ('таблица импорт', 'import', 'Нет упаковочных листов (.xls/.xlsx) — данные не подставлены'), ('florunner-specification', 'florunner', 'Нет сертификатов происхождения — данные не подставлены'), ] _selected_set = set(selected_docs) if (selected_docs and isinstance(selected_docs, list)) else None _produced_lower = {p.lower() for p in produced} for prefix, kind, reason in empty_specs: # Пользователь не выбирал этот тип — пропускаем. if _selected_set is not None and kind not in _selected_set: continue # Заполненный вариант уже есть в output — не тащим пустышку. if any(prefix in p and '_autofilled' in p for p in _produced_lower): continue for f in src_p.iterdir(): if not f.is_file(): continue low = f.name.lower() if not low.startswith(prefix): continue if '_autofilled' in low: continue if f.suffix.lower() not in ('.xls', '.xlsx', '.xlsm'): continue dst = paths['output'] / f.name try: shutil.move(str(f), str(dst)) produced.append(dst.name) output_files_meta[dst.name] = {'is_empty': True, 'reason': reason} except Exception as e: log_lines.append(f'[move empty-tpl fail] {f.name}: {e}') break # по одному файлу на каждый prefix # Обновим meta meta = _load_meta(paths) meta['built_at'] = datetime.now().isoformat(timespec='seconds') meta['built_files'] = produced meta['output_files_meta'] = output_files_meta meta['build_warnings'] = warnings # Сбросим кэш AI-предложений: файл только что пересобран meta.pop('europa_ai_proposals', None) meta.pop('europa_ai_proposed_at', None) meta.pop('europa_ai_applied', None) meta.pop('europa_ai_applied_at', None) meta.pop('import_ai_proposals', None) meta.pop('import_ai_proposed_at', None) meta.pop('import_ai_applied', None) meta.pop('import_ai_applied_at', None) _save_meta(paths, meta) # Авто-propose AI для europa (только если файл сгенерирован С данными) europa_proposals = None europa_output = _find_europa_output(paths) europa_is_empty = bool(europa_output and output_files_meta.get(europa_output.name, {}).get('is_empty')) if europa_output is not None and not europa_is_empty: try: europa_proposals = _compute_europa_proposals(paths) if europa_proposals is not None: meta['europa_ai_proposals'] = europa_proposals meta['europa_ai_proposed_at'] = datetime.now().isoformat(timespec='seconds') _save_meta(paths, meta) except Exception as e: log_lines.append(f'[europa-ai auto-propose fail] {e}') # Авто-AI для «Таблица импорт»: propose + автоприменение валидированных # предложений (status=ok — лист/колонка проверены по шаблону). # В веб-UI нет отдельного диалога для импорта, поэтому применяем сразу. import_ai_applied = None import_output = _find_import_output(paths) import_is_empty = bool(import_output and output_files_meta.get(import_output.name, {}).get('is_empty')) if import_output is not None and not import_is_empty: try: proposals = _compute_import_proposals(paths) if proposals is not None: meta['import_ai_proposals'] = proposals meta['import_ai_proposed_at'] = datetime.now().isoformat(timespec='seconds') marks_ov = { str(p['input']): {'sheet': p['suggested_sheet'], 'column': p['suggested_column']} for p in (proposals.get('markings') or []) if p.get('status') == 'ok' } if marks_ov: from import_writer import apply_import_ai_overrides sheets = _load_europa_packing_sheets(paths) wr = apply_import_ai_overrides(str(import_output), sheets, marks_ov) meta['import_ai_applied_at'] = datetime.now().isoformat(timespec='seconds') meta['import_ai_applied'] = { 'markings': marks_ov, 'items_placed': wr.items_placed, 'stems_added': wr.stems_added, } import_ai_applied = meta['import_ai_applied'] warnings.append( f'AI-импорт: автоматически пристроено маркировок: ' f'{len(marks_ov)} (+{wr.stems_added} стеблей)') meta['build_warnings'] = warnings _save_meta(paths, meta) except Exception as e: log_lines.append(f'[import-ai auto fail] {e}') # Сохраним лог try: (paths['output'] / '_fill_mtl.log').write_text( ''.join(log_lines), encoding='utf-8', ) except Exception: pass return jsonify({ 'ok': True, 'report_id': report_id, 'produced': produced, 'warnings': warnings, 'returncode': rc, 'log_tail': ''.join(log_lines)[-4000:], 'europa_ai_proposals': europa_proposals, 'import_ai_applied': import_ai_applied, }) @reports_bp.route('/<report_id>/download', methods=['GET']) def download_report(report_id: str): """ZIP со всеми файлами из output/ данного отчёта.""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, date, car = got out = paths['output'] if not out.is_dir(): return jsonify({'error': 'Отчёты ещё не сформированы'}), 404 files = [p for p in out.iterdir() if p.is_file()] if not files: return jsonify({'error': 'В output пусто — запустите сборку'}), 404 buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: for p in files: zf.write(p, arcname=p.name) buf.seek(0) fname = f'reports_{car}_{date}.zip' return send_file( buf, mimetype='application/zip', as_attachment=True, download_name=fname, ) @reports_bp.route('/<report_id>/output/<path:name>', methods=['GET']) def download_output_file(report_id: str, name: str): """Скачать один конкретный файл из output/.""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['output'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 return send_from_directory( str(fpath.parent), fpath.name, as_attachment=True, ) @reports_bp.route('/<report_id>/output/<path:name>', methods=['DELETE']) def delete_output_file(report_id: str, name: str): """Удалить один файл из output/.""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got fpath = paths['output'] / name if not fpath.is_file(): return jsonify({'error': 'Файл не найден'}), 404 try: fpath.unlink() except Exception as e: return jsonify({'error': f'Не удалось удалить: {e}'}), 500 # Если output теперь пуст — сбросим built_at remaining = [f for f in paths['output'].iterdir() if f.is_file() and f.name != '_fill_mtl.log'] if paths['output'].is_dir() else [] if not remaining: meta = _load_meta(paths) meta.pop('built_at', None) _save_meta(paths, meta) return jsonify({'ok': True}) @reports_bp.route('/<report_id>/output', methods=['DELETE']) def clear_output(report_id: str): """Очистить весь output/ (сбросить сформированные отчёты, оставить sources/parsed).""" got = _safe_report_paths(report_id) if not got: return jsonify({'error': 'Отчёт не найден'}), 404 paths, _, _ = got deleted = 0 if paths['output'].is_dir(): for f in list(paths['output'].iterdir()): if f.is_file(): try: f.unlink() deleted += 1 except Exception: pass # Сбросим built_at в meta meta = _load_meta(paths) meta.pop('built_at', None) _save_meta(paths, meta) return jsonify({'ok': True, 'deleted': deleted})