/
Paff
/
declarant
Обзор
Документация
Войти
/
Paff
/
declarant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
web/app.py
1 354 строки
50 KB
1pash1985-gif
chore: commit deployed source changes before Yandex OCR deploy
09 авг 2026, 23:56
09 авг 2026, 23:56
9777dcd
Код
Авторство
О чём код?
""" Декларант — Веб-сервер (Flask). API для загрузки файлов, сканирования инвойсов и получения результатов. """ import os import sys import uuid import shutil import json import sqlite3 from pathlib import Path from datetime import datetime from functools import wraps from typing import Any # Load .env file if present try: from dotenv import load_dotenv env_path = Path(__file__).parent / '.env' if env_path.exists(): load_dotenv(env_path) except ImportError: pass # python-dotenv not installed, use environment variables only import io import zipfile import subprocess from flask import ( Flask, request, jsonify, session, send_from_directory, send_file, abort, current_app, ) from flask_cors import CORS from werkzeug.utils import secure_filename # Добавляем корневой каталог проекта и web/ в sys.path PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) WEB_DIR = str(Path(__file__).resolve().parent) for _p in (PROJECT_ROOT, WEB_DIR): if _p not in sys.path: sys.path.insert(0, _p) from config import ( ACCESS_PASSWORD, SECRET_KEY, UPLOAD_FOLDER, MAX_CONTENT_LENGTH, ALLOWED_EXTENSIONS, ) app = Flask(__name__, static_folder=None) app.secret_key = SECRET_KEY app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH CORS(app, supports_credentials=True) # Абсолютный путь к frontend FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist') # Папка с шаблонами (общая для CLI/веб) TEMPLATES_DIR = os.path.join(PROJECT_ROOT, 'templates') os.makedirs(TEMPLATES_DIR, exist_ok=True) # Разрешённые расширения для шаблонов ведомостей TEMPLATE_EXTS = {'.xlsx', '.xlsm', '.xls'} os.makedirs(UPLOAD_FOLDER, exist_ok=True) # --- Новый API «Отчёты по номеру машины» --- from reports_api import reports_bp # noqa: E402 app.register_blueprint(reports_bp) # --- API корректировок (CRUD) --- from api_corrections import corrections_bp # noqa: E402 app.register_blueprint(corrections_bp) @app.route('/health') def health_check(): """Endpoint для liveness/readiness проверки Timeweb.""" return jsonify({'status': 'ok'}), 200 @app.route('/api/version') def get_version_api(): """Вернуть версию приложения (git hash + дата).""" ver = 'dev' # 1) Читаем из файла VERSION (создаётся при docker build) version_file = os.path.join(PROJECT_ROOT, 'VERSION') if os.path.isfile(version_file): with open(version_file) as f: content = f.read().strip() if content: ver = content else: # 2) Fallback: git (работает вне Docker) try: result = subprocess.run( ['git', 'log', '-1', '--format=v%h (%cs)'], capture_output=True, text=True, timeout=5, cwd=PROJECT_ROOT, ) if result.returncode == 0 and result.stdout.strip(): ver = result.stdout.strip() except Exception: pass return jsonify({'version': ver}) def login_required(f): """Декоратор: требуется авторизация.""" @wraps(f) def decorated(*args, **kwargs): if not session.get('authenticated'): return jsonify({'error': 'Не авторизован'}), 401 return f(*args, **kwargs) return decorated # --- Changelog & feedback ----------------------------------------------------- # --- Форум: замечания/предложения (только для авторизованных) --- FORUM_DB = Path(UPLOAD_FOLDER).parent / 'forum.db' FORUM_IMAGES_DIR = Path(UPLOAD_FOLDER).parent / 'forum_images' FORUM_IMAGES_DIR.mkdir(parents=True, exist_ok=True) ALLOWED_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 MB MAX_IMAGES_PER_TOPIC = 5 def _forum_db() -> sqlite3.Connection: """Подключение к SQLite-хранилищу форума.""" conn = sqlite3.connect(FORUM_DB) conn.row_factory = sqlite3.Row conn.execute('PRAGMA foreign_keys = ON') return conn def _init_forum_db() -> None: """Создать таблицы форума, если их ещё нет.""" FORUM_DB.parent.mkdir(parents=True, exist_ok=True) conn = _forum_db() try: conn.executescript( ''' CREATE TABLE IF NOT EXISTS forum_topics ( id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS forum_replies ( id TEXT PRIMARY KEY, topic_id TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (topic_id) REFERENCES forum_topics(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS forum_images ( id TEXT PRIMARY KEY, topic_id TEXT NOT NULL, filename TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (topic_id) REFERENCES forum_topics(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_forum_replies_topic ON forum_replies(topic_id); CREATE INDEX IF NOT EXISTS idx_forum_images_topic ON forum_images(topic_id); ''' ) conn.commit() finally: conn.close() _init_forum_db() def _allowed_image(filename: str | None) -> bool: if not filename or '.' not in filename: return False ext = Path(filename).suffix.lower() return ext in ALLOWED_IMAGE_EXTS def _unique_filename(original: str) -> str: stem = Path(original).stem ext = Path(original).suffix.lower() safe = secure_filename(f"{stem}{ext}") if not safe: safe = f"image{ext}" return f"{uuid.uuid4().hex}_{safe}" def _image_url(filename: str) -> str: return f'/api/feedback/images/{filename}' def _topic_image_filenames(conn: sqlite3.Connection, topic_id: str) -> list[str]: rows = conn.execute( 'SELECT filename FROM forum_images WHERE topic_id = ? ORDER BY created_at ASC', (topic_id,), ).fetchall() return [r['filename'] for r in rows] def _delete_image_files(filenames: list[str]) -> None: for name in filenames: try: (FORUM_IMAGES_DIR / name).unlink(missing_ok=True) except Exception as e: current_app.logger.warning('failed to delete forum image %s: %s', name, e) def _save_uploaded_images(files: list, topic_id: str) -> list[str]: """Сохранить загруженные файлы и вернуть список имён файлов.""" saved: list[str] = [] for f in files: if not f or not f.filename: continue if not _allowed_image(f.filename): _delete_image_files(saved) raise ValueError(f'Недопустимый формат файла: {f.filename}') if f.content_length and f.content_length > MAX_IMAGE_SIZE: _delete_image_files(saved) raise ValueError(f'Файл слишком большой (макс. 5 МБ): {f.filename}') new_name = _unique_filename(f.filename) path = FORUM_IMAGES_DIR / new_name f.save(path) # дополнительная проверка после сохранения if path.stat().st_size > MAX_IMAGE_SIZE: path.unlink(missing_ok=True) _delete_image_files(saved) raise ValueError(f'Файл слишком большой (макс. 5 МБ): {f.filename}') saved.append(new_name) return saved def _row_to_topic(row: sqlite3.Row) -> dict[str, Any]: return { 'id': row['id'], 'title': row['title'], 'body': row['body'], 'created_at': row['created_at'], 'reply_count': row['reply_count'] if 'reply_count' in row.keys() else 0, 'last_reply_at': row['last_reply_at'] if 'last_reply_at' in row.keys() else None, 'image_count': row['image_count'] if 'image_count' in row.keys() else 0, } @app.route('/api/changelog', methods=['GET']) @login_required def get_changelog(): """Вернуть последние изменения из git-истории.""" commits: list[dict[str, str]] = [] try: result = subprocess.run( ['git', 'log', '--pretty=format:%h|%ad|%s', '--date=short', '-n', '30'], capture_output=True, text=True, timeout=10, cwd=PROJECT_ROOT, ) if result.returncode == 0: for line in result.stdout.strip().splitlines(): parts = line.split('|', 2) if len(parts) == 3: commits.append({ 'hash': parts[0].strip(), 'date': parts[1].strip(), 'message': parts[2].strip(), }) except Exception as e: current_app.logger.warning('changelog git log failed: %s', e) return jsonify({'commits': commits}) @app.route('/api/feedback', methods=['GET', 'POST']) @login_required def feedback_api(): """Форум замечаний и предложений (только для авторизованных). GET — список тем (с количеством ответов, фото и датой последнего ответа). POST — создать новую тему (заголовок + текст + до 5 фото). """ if request.method == 'POST': title = (request.form.get('title') or '').strip() body = (request.form.get('body') or '').strip() if not title: return jsonify({'error': 'Укажите заголовок темы'}), 400 if not body: return jsonify({'error': 'Текст сообщения не может быть пустым'}), 400 if len(title) > 200: return jsonify({'error': 'Заголовок слишком длинный (макс. 200 символов)'}), 400 if len(body) > 5000: return jsonify({'error': 'Текст слишком длинный (макс. 5000 символов)'}), 400 uploaded_files = request.files.getlist('images') uploaded_files = [f for f in uploaded_files if f and f.filename] if len(uploaded_files) > MAX_IMAGES_PER_TOPIC: return jsonify({'error': f'Можно прикрепить не более {MAX_IMAGES_PER_TOPIC} фото'}), 400 topic_id = str(uuid.uuid4()) created_at = datetime.now().isoformat(timespec='seconds') conn = _forum_db() try: conn.execute( 'INSERT INTO forum_topics (id, title, body, created_at) VALUES (?, ?, ?, ?)', (topic_id, title, body, created_at), ) if uploaded_files: try: filenames = _save_uploaded_images(uploaded_files, topic_id) except ValueError as e: conn.rollback() return jsonify({'error': str(e)}), 400 conn.executemany( 'INSERT INTO forum_images (id, topic_id, filename, created_at) VALUES (?, ?, ?, ?)', [(str(uuid.uuid4()), topic_id, fn, created_at) for fn in filenames], ) conn.commit() finally: conn.close() return jsonify({'ok': True, 'id': topic_id, 'created_at': created_at}), 201 # GET — список тем conn = _forum_db() try: rows = conn.execute( ''' SELECT t.id, t.title, t.body, t.created_at, (SELECT COUNT(*) FROM forum_replies r WHERE r.topic_id = t.id) AS reply_count, (SELECT MAX(r.created_at) FROM forum_replies r WHERE r.topic_id = t.id) AS last_reply_at, (SELECT COUNT(*) FROM forum_images i WHERE i.topic_id = t.id) AS image_count FROM forum_topics t ORDER BY COALESCE(last_reply_at, t.created_at) DESC LIMIT 200 ''' ).fetchall() finally: conn.close() return jsonify({'items': [_row_to_topic(r) for r in rows]}) @app.route('/api/feedback/<topic_id>', methods=['GET', 'PATCH', 'DELETE']) @login_required def feedback_topic(topic_id: str): """Получить, отредактировать или удалить тему форума.""" conn = _forum_db() try: topic_row = conn.execute( 'SELECT id, title, body, created_at FROM forum_topics WHERE id = ?', (topic_id,), ).fetchone() if topic_row is None: return jsonify({'error': 'Тема не найдена'}), 404 if request.method == 'GET': reply_rows = conn.execute( 'SELECT id, topic_id, body, created_at FROM forum_replies WHERE topic_id = ? ORDER BY created_at ASC', (topic_id,), ).fetchall() image_filenames = _topic_image_filenames(conn, topic_id) return jsonify({ 'topic': { 'id': topic_row['id'], 'title': topic_row['title'], 'body': topic_row['body'], 'created_at': topic_row['created_at'], 'images': [_image_url(fn) for fn in image_filenames], }, 'replies': [ {'id': r['id'], 'topic_id': r['topic_id'], 'body': r['body'], 'created_at': r['created_at']} for r in reply_rows ], }) if request.method == 'DELETE': old_images = _topic_image_filenames(conn, topic_id) conn.execute('DELETE FROM forum_topics WHERE id = ?', (topic_id,)) conn.commit() _delete_image_files(old_images) return jsonify({'ok': True}), 200 # PATCH title = (request.form.get('title') or '').strip() body = (request.form.get('body') or '').strip() if not title or not body: return jsonify({'error': 'Заголовок и текст не могут быть пустыми'}), 400 if len(title) > 200 or len(body) > 5000: return jsonify({'error': 'Превышен лимит длины заголовка или текста'}), 400 current_images = _topic_image_filenames(conn, topic_id) remove_names = request.form.getlist('remove_images') # поддержка единственного поля с разделителями if len(remove_names) == 1 and ',' in remove_names[0]: remove_names = [n.strip() for n in remove_names[0].split(',') if n.strip()] remove_names = [n for n in remove_names if n in current_images] uploaded_files = request.files.getlist('images') uploaded_files = [f for f in uploaded_files if f and f.filename] remaining_slots = MAX_IMAGES_PER_TOPIC - (len(current_images) - len(remove_names)) if len(uploaded_files) > remaining_slots: return jsonify({'error': f'Можно хранить не более {MAX_IMAGES_PER_TOPIC} фото в теме'}), 400 if uploaded_files: try: new_filenames = _save_uploaded_images(uploaded_files, topic_id) except ValueError as e: return jsonify({'error': str(e)}), 400 now = datetime.now().isoformat(timespec='seconds') conn.executemany( 'INSERT INTO forum_images (id, topic_id, filename, created_at) VALUES (?, ?, ?, ?)', [(str(uuid.uuid4()), topic_id, fn, now) for fn in new_filenames], ) if remove_names: conn.executemany('DELETE FROM forum_images WHERE topic_id = ? AND filename = ?', [(topic_id, name) for name in remove_names]) conn.execute( 'UPDATE forum_topics SET title = ?, body = ? WHERE id = ?', (title, body, topic_id), ) conn.commit() _delete_image_files(remove_names) return jsonify({'ok': True}), 200 finally: conn.close() @app.route('/api/feedback/<topic_id>/reply', methods=['POST']) @login_required def feedback_reply(topic_id: str): """Ответить в тему форума.""" data = request.get_json(silent=True) or {} body = (data.get('body') or '').strip() if not body: return jsonify({'error': 'Текст ответа не может быть пустым'}), 400 if len(body) > 5000: return jsonify({'error': 'Текст слишком длинный (макс. 5000 символов)'}), 400 reply_id = str(uuid.uuid4()) created_at = datetime.now().isoformat(timespec='seconds') conn = _forum_db() try: topic = conn.execute('SELECT 1 FROM forum_topics WHERE id = ?', (topic_id,)).fetchone() if topic is None: return jsonify({'error': 'Тема не найдена'}), 404 conn.execute( 'INSERT INTO forum_replies (id, topic_id, body, created_at) VALUES (?, ?, ?, ?)', (reply_id, topic_id, body, created_at), ) conn.commit() finally: conn.close() return jsonify({'ok': True, 'id': reply_id, 'created_at': created_at}), 201 @app.route('/api/feedback/<topic_id>/reply/<reply_id>', methods=['PATCH', 'DELETE']) @login_required def feedback_reply_item(topic_id: str, reply_id: str): """Редактировать или удалить ответ в теме форума.""" conn = _forum_db() try: reply = conn.execute( 'SELECT id, topic_id FROM forum_replies WHERE id = ? AND topic_id = ?', (reply_id, topic_id), ).fetchone() if reply is None: return jsonify({'error': 'Ответ не найден'}), 404 if request.method == 'DELETE': conn.execute('DELETE FROM forum_replies WHERE id = ?', (reply_id,)) conn.commit() return jsonify({'ok': True}), 200 # PATCH data = request.get_json(silent=True) or {} body = (data.get('body') or '').strip() if not body: return jsonify({'error': 'Текст ответа не может быть пустым'}), 400 if len(body) > 5000: return jsonify({'error': 'Текст слишком длинный (макс. 5000 символов)'}), 400 conn.execute('UPDATE forum_replies SET body = ? WHERE id = ?', (body, reply_id)) conn.commit() return jsonify({'ok': True}), 200 finally: conn.close() @app.route('/api/feedback/images/<filename>') @login_required def feedback_image(filename: str): """Отдать изображение из форума.""" if not _allowed_image(filename): abort(404) return send_from_directory(FORUM_IMAGES_DIR, filename) # --- Хранилище сессий сканирования (in-memory) --- scan_sessions: dict[str, dict] = {} @app.route('/api/login', methods=['POST']) def login(): """Вход по паролю.""" data = request.get_json(silent=True) or {} password = data.get('password', '') if password == ACCESS_PASSWORD: session['authenticated'] = True return jsonify({'ok': True, 'message': 'Авторизация успешна'}) return jsonify({'ok': False, 'error': 'Неверный пароль'}), 403 @app.route('/api/logout', methods=['POST']) def logout(): """Выход.""" session.clear() return jsonify({'ok': True}) @app.route('/api/check-auth') def check_auth(): """Проверка авторизации.""" return jsonify({'authenticated': bool(session.get('authenticated'))}) # ============================================================================= # Загрузка файлов # ============================================================================= @app.route('/api/upload', methods=['POST']) @login_required def upload_files(): """Загрузить файлы инвойсов. Возвращает session_id.""" if 'files' not in request.files: return jsonify({'error': 'Нет файлов'}), 400 files = request.files.getlist('files') if not files: return jsonify({'error': 'Пустой список файлов'}), 400 # Создаём сессию сканирования session_id = str(uuid.uuid4())[:8] session_dir = os.path.join(UPLOAD_FOLDER, session_id) os.makedirs(session_dir, exist_ok=True) uploaded = [] for f in files: if not f.filename: continue # Сохраняем структуру подпапок (относительные пути) raw_name = f.filename.replace('\\', '/') ext = os.path.splitext(raw_name)[1].lower() if ext not in ALLOWED_EXTENSIONS: continue # Сохраняем файл safe_name = raw_name.replace('/', os.sep) dest = os.path.join(session_dir, safe_name) os.makedirs(os.path.dirname(dest), exist_ok=True) f.save(dest) uploaded.append(raw_name) scan_sessions[session_id] = { 'id': session_id, 'dir': session_dir, 'uploaded': uploaded, 'created': datetime.now().isoformat(), 'status': 'uploaded', 'results': None, } return jsonify({ 'ok': True, 'session_id': session_id, 'files_count': len(uploaded), 'files': uploaded, }) @app.route('/api/upload-folder', methods=['POST']) @login_required def upload_folder(): """Загрузить целую папку (с webkitRelativePath).""" if 'files' not in request.files: return jsonify({'error': 'Нет файлов'}), 400 files = request.files.getlist('files') if not files: return jsonify({'error': 'Пустой список файлов'}), 400 session_id = str(uuid.uuid4())[:8] session_dir = os.path.join(UPLOAD_FOLDER, session_id) os.makedirs(session_dir, exist_ok=True) uploaded = [] for f in files: if not f.filename: continue raw_name = f.filename.replace('\\', '/') ext = os.path.splitext(raw_name)[1].lower() if ext not in ALLOWED_EXTENSIONS: continue dest = os.path.join(session_dir, raw_name.replace('/', os.sep)) os.makedirs(os.path.dirname(dest), exist_ok=True) f.save(dest) uploaded.append(raw_name) scan_sessions[session_id] = { 'id': session_id, 'dir': session_dir, 'uploaded': uploaded, 'created': datetime.now().isoformat(), 'status': 'uploaded', 'results': None, } return jsonify({ 'ok': True, 'session_id': session_id, 'files_count': len(uploaded), 'files': uploaded, }) # ============================================================================= # Сканирование / парсинг # ============================================================================= @app.route('/api/scan/<session_id>', methods=['POST']) @login_required def scan_session(session_id): """Запустить парсинг загруженных файлов.""" sess = scan_sessions.get(session_id) if not sess: return jsonify({'error': 'Сессия не найдена'}), 404 sess['status'] = 'scanning' session_dir = sess['dir'] results = { 'packing': [], 'invoices': [], 'errors': [], 'log': [], } try: # --- 1) Packing parser (XLS) --- _run_packing_parser(session_dir, results) # --- 2) PDF Invoice parser --- _run_pdf_parser(session_dir, results) except Exception as e: results['errors'].append(f'Критическая ошибка: {e}') results['log'].append(f'[ОШИБКА] {e}') # Итоги summary = { 'packing_files': len(results['packing']), 'packing_items': sum(p['items_count'] for p in results['packing']), 'packing_stems': sum(p['stems'] for p in results['packing']), 'pdf_invoices': len(results['invoices']), 'pdf_items': sum(inv['items_count'] for inv in results['invoices']), 'pdf_boxes': sum(inv['boxes'] for inv in results['invoices']), 'errors_count': len(results['errors']), } results['summary'] = summary sess['results'] = results sess['status'] = 'done' return jsonify({'ok': True, 'session_id': session_id, 'results': results}) @app.route('/api/results/<session_id>') @login_required def get_results(session_id): """Получить результаты сканирования.""" sess = scan_sessions.get(session_id) if not sess: return jsonify({'error': 'Сессия не найдена'}), 404 return jsonify({ 'session_id': session_id, 'status': sess['status'], 'files_count': len(sess['uploaded']), 'results': sess['results'], }) @app.route('/api/sessions') @login_required def list_sessions(): """Список всех сессий.""" items = [] for sid, sess in scan_sessions.items(): items.append({ 'id': sid, 'created': sess['created'], 'status': sess['status'], 'files_count': len(sess['uploaded']), 'summary': sess['results']['summary'] if sess['results'] else None, }) items.sort(key=lambda x: x['created'], reverse=True) return jsonify({'sessions': items}) # ============================================================================= # Парсинг: helper функции # ============================================================================= def _run_packing_parser(session_dir: str, results: dict): """Запуск packing_parser на загруженных XLS файлах.""" import re from packing_parser import parse_packing, normalize_marking, _folder_hints from corrections import load_corrections corr = load_corrections() root_p = Path(session_dir) skip_pat = re.compile( r'_autofilled|florunner|Таблица|Табл\.|^\d{2}\.\d{2}\.\d{4}', re.IGNORECASE, ) only_meta_pat = re.compile(r'pro\s*forma|proforma', re.IGNORECASE) candidates = [] for f in sorted(root_p.rglob('*.xls*')): ext = f.suffix.lower() if ext not in ('.xls', '.xlsx', '.xlsm'): continue if skip_pat.search(f.name): continue if only_meta_pat.search(f.name): continue candidates.append(f) results['log'].append(f'--- Packing: найдено {len(candidates)} файлов ---') for f in candidates: rel = str(f.relative_to(root_p)) try: sheet = parse_packing(str(f)) except Exception as e: results['errors'].append(f'{rel}: {e}') results['log'].append(f' [✗] {rel}: {e}') continue # folder hints h = _folder_hints(str(f)) if not sheet.country and h['country']: sheet.country = h['country'] if h['client']: cur = (sheet.marking or '').strip().upper() bogus = cur in ('', 'INFORMATION', 'CLIENT', 'CUSTOMER', 'INVOICE', 'ORDER', 'NUMBER', 'PACKING') if bogus: sheet.marking = normalize_marking(h['client']) if not sheet.awb and h['awb']: sheet.awb = h['awb'] for it in sheet.items: if not it.country and sheet.country: it.country = sheet.country # corrections if sheet.source: fname_mark = corr.resolve_marking_from_filename(Path(sheet.source).name) if fname_mark: sheet.marking = fname_mark resolved = corr.resolve_marking(sheet.marking or '') if resolved is None: sheet.marking = '' elif resolved != (sheet.marking or ''): sheet.marking = resolved for it in sheet.items: fam = corr.resolve_family(it.variety) if fam and not it.flower: it.flower = fam if not sheet.items: results['log'].append(f' [∅] {rel}: пусто') continue n_items = len(sheet.items) n_stems = sum(it.stems for it in sheet.items) results['log'].append( f' [✓] {rel}: marking={sheet.marking!r} items={n_items} stems={n_stems}' ) results['packing'].append({ 'file': rel, 'marking': sheet.marking or '', 'country': sheet.country or '', 'items_count': n_items, 'stems': n_stems, 'items': [ { 'variety': it.variety, 'flower': it.flower or '', 'stems': it.stems, 'boxes': it.boxes, 'marking': it.marking or '', } for it in sheet.items ], }) def _run_pdf_parser(session_dir: str, results: dict): """Запуск pdf_parser (InvoiceParser) на PDF файлах.""" try: from pdf_parser import FolderScanner from flower_db import FlowerDatabase except ImportError as e: results['log'].append(f' [⚠] PDF-парсер недоступен: {e}') return flower_db = FlowerDatabase() scanner = FolderScanner(flower_db) # Сканируем все подпапки (в загрузке нет шаблонов) root_p = Path(session_dir) # scan root and all immediate subdirs dirs_to_scan = [session_dir] for d in root_p.iterdir(): if d.is_dir(): dirs_to_scan.append(str(d)) for sub in d.iterdir(): if sub.is_dir(): dirs_to_scan.append(str(sub)) results['log'].append(f'--- PDF-инвойсы: сканирование ---') seen_files = set() for scan_dir in dirs_to_scan: try: pdf_results = scanner.scan_directory(scan_dir) except Exception as e: results['errors'].append(f'PDF scanner: {e}') continue for client, invs in pdf_results.items(): for inv in invs: if inv.file_path in seen_files: continue seen_files.add(inv.file_path) fname = os.path.basename(inv.file_path) n_items = len(inv.items) n_boxes = inv.total_boxes n_stems = inv.total_stems if n_items > 0: results['log'].append( f' [✓] {fname}: клиент={client!r} позиций={n_items} коробок={n_boxes}' ) else: results['log'].append(f' [∅] {fname}: не распознано') results['invoices'].append({ 'file': fname, 'client': client, 'supplier': inv.supplier or '', 'invoice_number': inv.invoice_number or '', 'items_count': n_items, 'boxes': n_boxes, 'stems': n_stems, 'items': [ { 'name': it.raw_name, 'matched': it.matched_flower or '', 'boxes': it.boxes_qty, 'stems': it.stems_qty, } for it in inv.items ], }) # ============================================================================= # AI-парсинг (через DeepSeek) # ============================================================================= @app.route('/api/scan-ai/<session_id>', methods=['POST']) @login_required def scan_ai(session_id): """Запустить AI-парсинг загруженных файлов через DeepSeek.""" sess = scan_sessions.get(session_id) if not sess: return jsonify({'error': 'Сессия не найдена'}), 404 from ai_parser import ( parse_invoice_with_ai, parse_packing_with_ai, extract_text_from_pdf, extract_text_from_excel, ) session_dir = sess['dir'] root_p = Path(session_dir) ai_results = {'invoices': [], 'packing': [], 'errors': [], 'log': []} # Обработка PDF for pdf_file in root_p.rglob('*.pdf'): rel = str(pdf_file.relative_to(root_p)) ai_results['log'].append(f'[ИИ] Обработка: {rel}') try: text = extract_text_from_pdf(str(pdf_file)) if not text.strip(): ai_results['log'].append(f' [∅] {rel}: пустой текст') continue result = parse_invoice_with_ai(text) if 'error' in result: ai_results['errors'].append(f'{rel}: {result["error"]}') ai_results['log'].append(f' [✗] {rel}: {result["error"]}') else: result['_file'] = rel ai_results['invoices'].append(result) n_items = len(result.get('items', [])) ai_results['log'].append(f' [✓] {rel}: {n_items} позиций') except Exception as e: ai_results['errors'].append(f'{rel}: {e}') ai_results['log'].append(f' [✗] {rel}: {e}') # Обработка Excel for xls_file in root_p.rglob('*'): if xls_file.suffix.lower() not in ('.xls', '.xlsx', '.xlsm'): continue rel = str(xls_file.relative_to(root_p)) ai_results['log'].append(f'[ИИ] Обработка: {rel}') try: text = extract_text_from_excel(str(xls_file)) if not text.strip(): ai_results['log'].append(f' [∅] {rel}: пустой') continue result = parse_packing_with_ai(text) if 'error' in result: ai_results['errors'].append(f'{rel}: {result["error"]}') ai_results['log'].append(f' [✗] {rel}: {result["error"]}') else: result['_file'] = rel ai_results['packing'].append(result) n_items = len(result.get('items', [])) ai_results['log'].append(f' [✓] {rel}: {n_items} позиций') except Exception as e: ai_results['errors'].append(f'{rel}: {e}') ai_results['log'].append(f' [✗] {rel}: {e}') # Сохраняем результаты ai_results['summary'] = { 'invoices_count': len(ai_results['invoices']), 'packing_count': len(ai_results['packing']), 'errors_count': len(ai_results['errors']), } sess['ai_results'] = ai_results return jsonify({'ok': True, 'session_id': session_id, 'ai_results': ai_results}) @app.route('/api/ai-results/<session_id>') @login_required def get_ai_results(session_id): """Получить результаты AI-парсинга.""" sess = scan_sessions.get(session_id) if not sess: return jsonify({'error': 'Сессия не найдена'}), 404 return jsonify({ 'session_id': session_id, 'ai_results': sess.get('ai_results'), }) # ============================================================================= # Распознавание сертификатов EUR1/CoO # ============================================================================= _CERT_FNAME_RE = None # lazy compile def _is_cert_file(name: str) -> bool: """Файл — сертификат, если в имени есть 'sertif' / 'cert' / 'EUR1' / 'CoO'.""" import re global _CERT_FNAME_RE if _CERT_FNAME_RE is None: _CERT_FNAME_RE = re.compile( r'sertif|certif|\bcert\b|\bEUR\.?1\b|\bCoO\b|origin', re.IGNORECASE, ) return bool(_CERT_FNAME_RE.search(name)) @app.route('/api/scan-certs/<session_id>', methods=['POST']) @login_required def scan_certificates(session_id): """Найти PDF-сертификаты в папке сессии и распарсить их через cert_parser. Использует корневой cert_parser.parse_certificate (pdfplumber + ocr_eur1). Отбирает файлы по имени (sertif/certif/EUR1/CoO). """ sess = scan_sessions.get(session_id) if not sess: return jsonify({'error': 'Сессия не найдена'}), 404 session_dir = sess['dir'] if not os.path.isdir(session_dir): return jsonify({'error': 'Каталог сессии не найден'}), 404 try: from cert_parser import parse_certificate except Exception as e: return jsonify({'error': f'Не удалось импортировать cert_parser: {e}'}), 500 root_p = Path(session_dir) results = {'certificates': [], 'errors': [], 'log': []} pdfs = [p for p in root_p.rglob('*.pdf') if p.is_file() and _is_cert_file(p.name)] if not pdfs: return jsonify({ 'ok': True, 'session_id': session_id, 'cert_results': { 'certificates': [], 'errors': [], 'log': ['[cert] PDF-сертификатов не найдено (искал sertif/certif/EUR1/CoO в имени)'], 'summary': {'certs_count': 0, 'errors_count': 0}, }, }) results['log'].append(f'[cert] Найдено {len(pdfs)} PDF-сертификат(ов)') for pdf in pdfs: rel = str(pdf.relative_to(root_p)) try: cd = parse_certificate(str(pdf)) info = { 'file': rel, 'country': cd.country, 'cert_number': cd.cert_number, 'eur1_numbers': list(cd.eur1_numbers), 'master_invoices': list(cd.master_invoices), 'awb': cd.awb, 'exporter': cd.exporter, 'consignee': cd.consignee, 'date': cd.date, 'total_boxes': cd.total_boxes, 'total_stems': cd.total_stems, 'items_count': len(cd.items), 'items': [ { 'flower': it.flower, 'farm': it.farm, 'marking': it.marking, 'invoice_nr': it.invoice_nr, '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, 'master_invoice': it.master_invoice, } for it in cd.items ], } results['certificates'].append(info) results['log'].append( f' [✓] {rel}: {cd.country or "?"} / ' f'{cd.cert_number or "-"} / {len(cd.items)} поз.' ) except Exception as e: results['errors'].append(f'{rel}: {e}') results['log'].append(f' [✗] {rel}: {e}') results['summary'] = { 'certs_count': len(results['certificates']), 'errors_count': len(results['errors']), } sess['cert_results'] = results return jsonify({'ok': True, 'session_id': session_id, 'cert_results': results}) # ============================================================================= # Генерация отчётов (fill_mtl.py) # ============================================================================= @app.route('/api/generate-reports/<session_id>', methods=['POST']) @login_required def generate_reports(session_id): """Запустить fill_mtl.py на папке сессии и вернуть ZIP с _autofilled файлами. Если внутри session_dir лежит одна папка-рейс (не в корне), fill_mtl запускается на ней; иначе на самом session_dir. """ sess = scan_sessions.get(session_id) if not sess: return jsonify({'error': 'Сессия не найдена'}), 404 session_dir = sess['dir'] if not os.path.isdir(session_dir): return jsonify({'error': 'Каталог сессии не найден'}), 404 # Определяем trip-папку: если session_dir содержит ровно одну подпапку # и никаких файлов на верхнем уровне — используем её. trip = session_dir try: entries = [e for e in os.listdir(session_dir) if not e.startswith('.') and not e.startswith('_')] subdirs = [e for e in entries if os.path.isdir(os.path.join(session_dir, e))] files = [e for e in entries if os.path.isfile(os.path.join(session_dir, e))] if len(subdirs) == 1 and not files: trip = os.path.join(session_dir, subdirs[0]) except Exception: pass fill_mtl_path = os.path.join(PROJECT_ROOT, 'fill_mtl.py') if not os.path.isfile(fill_mtl_path): return jsonify({'error': f'fill_mtl.py не найден: {fill_mtl_path}'}), 500 log_lines: list[str] = [] env = dict(os.environ) env.setdefault('SKIP_OCR', '1') # ускоряем: пропускаем медленный OCR env['PYTHONIOENCODING'] = 'utf-8' try: proc = subprocess.run( [sys.executable, fill_mtl_path, trip], capture_output=True, text=True, timeout=600, env=env, cwd=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* файлы из trip (рекурсивно) trip_p = Path(trip) autofilled: list[Path] = [] for p in trip_p.rglob('*_autofilled*'): if p.is_file(): autofilled.append(p) if not autofilled: return jsonify({ 'error': 'fill_mtl.py отработал, но _autofilled файлов не создано', 'returncode': rc, 'log': ''.join(log_lines)[-4000:], }), 500 # Пакуем в ZIP в памяти buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: for p in autofilled: arcname = p.relative_to(trip_p).as_posix() zf.write(p, arcname=arcname) # Прикладываем лог запуска zf.writestr('_fill_mtl.log', ''.join(log_lines)) buf.seek(0) ts = datetime.now().strftime('%Y%m%d_%H%M%S') fname = f'reports_{session_id}_{ts}.zip' return send_file( buf, mimetype='application/zip', as_attachment=True, download_name=fname, ) # ============================================================================= # Шаблоны ведомостей (templates/) # ============================================================================= def _safe_template_name(name: str) -> str | None: """Защита от path-traversal. Возвращает базовое имя файла или None.""" if not name: return None # Убираем любые компоненты пути base = os.path.basename(name.replace('\\', '/').split('/')[-1]).strip() if not base or base in ('.', '..') or base.startswith('.'): return None ext = os.path.splitext(base)[1].lower() if ext not in TEMPLATE_EXTS: return None return base def _list_templates() -> list[dict]: """Собрать список шаблонов с метаданными.""" result = [] if not os.path.isdir(TEMPLATES_DIR): return result # Канонические имена (совпадают с паттернами в fill_mtl._ensure_templates) KNOWN = { 'florunner-specification.xlsx': '🌿 Florunner — спецификация', 'Таблица европа (000) PL.xlsm': '🇳🇱 Таблица европа (NL)', 'Таблица импорт.xlsx': '🌍 Таблица импорт (Кения / Эквадор / Колумбия и др.)', 'master.xlsx': '🗂️ Master (Загрузка + справочники)', } for fname in sorted(os.listdir(TEMPLATES_DIR)): full = os.path.join(TEMPLATES_DIR, fname) if not os.path.isfile(full): continue ext = os.path.splitext(fname)[1].lower() if ext not in TEMPLATE_EXTS: continue st = os.stat(full) result.append({ 'name': fname, 'title': KNOWN.get(fname, fname), 'size': st.st_size, 'mtime': datetime.fromtimestamp(st.st_mtime).strftime('%d.%m.%Y %H:%M'), 'ext': ext, 'known': fname in KNOWN, }) return result @app.route('/api/templates', methods=['GET']) @login_required def api_list_templates(): """Список всех шаблонов в templates/.""" return jsonify({'templates': _list_templates(), 'dir': TEMPLATES_DIR}) @app.route('/api/templates/<path:name>/download', methods=['GET']) @login_required def api_download_template(name): """Скачать шаблон по имени.""" safe = _safe_template_name(name) if not safe: return jsonify({'error': 'Неверное имя файла'}), 400 full = os.path.join(TEMPLATES_DIR, safe) if not os.path.isfile(full): return jsonify({'error': 'Шаблон не найден'}), 404 return send_from_directory(TEMPLATES_DIR, safe, as_attachment=True, download_name=safe) @app.route('/api/templates', methods=['POST']) @app.route('/api/templates/<path:name>', methods=['POST', 'PUT']) @login_required def api_upload_template(name=None): """Залить/заменить шаблон. - POST /api/templates — новый файл, имя берётся из загружаемого - POST /api/templates/<name> — заменить существующий (имя фиксируется) """ if 'file' not in request.files: return jsonify({'error': 'Нет файла в запросе'}), 400 f = request.files['file'] if not f or not f.filename: return jsonify({'error': 'Пустой файл'}), 400 # Если name задан — перезапись; иначе берём из загружаемого target_name = name if name else f.filename safe = _safe_template_name(target_name) if not safe: return jsonify({ 'error': f'Допустимы только {", ".join(sorted(TEMPLATE_EXTS))}', }), 400 dst = os.path.join(TEMPLATES_DIR, safe) # На Windows openpyxl может держать файл — атомарная замена через tmp+replace tmp = dst + '.uploading' try: f.save(tmp) os.replace(tmp, dst) except Exception as e: try: if os.path.exists(tmp): os.remove(tmp) except Exception: pass return jsonify({'error': f'Не удалось сохранить: {e}'}), 500 st = os.stat(dst) return jsonify({ 'ok': True, 'name': safe, 'size': st.st_size, 'mtime': datetime.fromtimestamp(st.st_mtime).strftime('%d.%m.%Y %H:%M'), }) @app.route('/api/templates/<path:name>', methods=['DELETE']) @login_required def api_delete_template(name): """Удалить шаблон (кроме базовых 4 штатных).""" safe = _safe_template_name(name) if not safe: return jsonify({'error': 'Неверное имя файла'}), 400 KNOWN_PROTECTED = { 'florunner-specification.xlsx', 'Таблица европа (000) PL.xlsm', 'Таблица импорт.xlsx', 'master.xlsx', } if safe in KNOWN_PROTECTED: return jsonify({'error': 'Базовые шаблоны удалять нельзя (их можно только заменить)'}), 400 full = os.path.join(TEMPLATES_DIR, safe) if not os.path.isfile(full): return jsonify({'error': 'Шаблон не найден'}), 404 try: os.remove(full) return jsonify({'ok': True}) except Exception as e: return jsonify({'error': f'Не удалось удалить: {e}'}), 500 # ============================================================================= # SPA fallback: отдаём Vue.js frontend # ============================================================================= @app.route('/') def serve_index(): index_path = os.path.join(FRONTEND_DIR, 'index.html') if os.path.isfile(index_path): return send_from_directory(FRONTEND_DIR, 'index.html') # Fallback: если index.html не найден, показать диагностику return jsonify({ 'error': 'index.html not found', 'FRONTEND_DIR': FRONTEND_DIR, 'exists': os.path.isdir(FRONTEND_DIR), 'files': os.listdir(FRONTEND_DIR) if os.path.isdir(FRONTEND_DIR) else [], 'cwd': os.getcwd(), }), 500 @app.route('/<path:path>') def serve_static(path): file_path = os.path.join(FRONTEND_DIR, path) if os.path.isfile(file_path): return send_from_directory(FRONTEND_DIR, path) # SPA fallback return send_from_directory(FRONTEND_DIR, 'index.html') # ============================================================================= # Запуск # ============================================================================= if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description='Декларант — Веб-сервер') parser.add_argument('--host', default='0.0.0.0', help='Хост (по умолчанию 0.0.0.0)') parser.add_argument('--port', type=int, default=5000, help='Порт (по умолчанию 5000)') parser.add_argument('--debug', action='store_true', help='Debug режим') args = parser.parse_args() print(f'Декларант веб-сервер запускается на http://{args.host}:{args.port}') print(f'Пароль для входа: {ACCESS_PASSWORD}') app.run(host=args.host, port=args.port, debug=args.debug)