""" Легаси-монолит WESP (архив). Актуальное приложение: из корня репозитория выполните `python run.py`. Запуск этого файла: из корня — `python legacy/proga_monolith.py` или `python proga.py --legacy`. """ import os as _wesp_os import sys as _wesp_sys _wesp_root = _wesp_os.path.dirname(_wesp_os.path.dirname(_wesp_os.path.abspath(__file__))) if _wesp_root not in _wesp_sys.path: _wesp_sys.path.insert(0, _wesp_root) from flask import Flask, request, jsonify, Response, send_from_directory, render_template, session, send_file, redirect, url_for from flask_sqlalchemy import SQLAlchemy from flask_cors import CORS from sqlalchemy import event, inspect, or_, and_, desc, func import time import threading import json import os import webbrowser from threading import Timer, Semaphore from datetime import datetime, timezone, timedelta import pytz from waitress import serve import sqlite3 import uuid import subprocess import re import schedule import logging import traceback from functools import wraps from collections import defaultdict, deque import tempfile import gzip import base64 from recipe_calculator import calculate_recipe request_counts = defaultdict(deque) def log_db_operation(operation_name): """Декоратор для логирования операций с БД (успешные операции — только на уровне DEBUG)""" _log = logging.getLogger(__name__) def decorator(func): @wraps(func) def wrapper(*args, **kwargs): try: _log.debug("БД %s: начало операции", operation_name) result = func(*args, **kwargs) _log.debug("БД %s: операция успешна", operation_name) return result except Exception as e: error_msg = f"🗄️ БД ОШИБКА в {operation_name}: {str(e)}" print(error_msg) print(f"🗄️ БД ОШИБКА: тип исключения: {type(e).__name__}") print(f"🗄️ БД ОШИБКА: стек вызовов:\n{traceback.format_exc()}") if hasattr(e, 'orig'): print(f"🗄️ БД ОШИБКА: оригинальная ошибка: {e.orig}") if hasattr(e, 'statement'): print(f"🗄️ БД ОШИБКА: SQL запрос: {e.statement}") if hasattr(e, 'params'): print(f"🗄️ БД ОШИБКА: параметры: {e.params}") raise return wrapper return decorator def rate_limit(max_requests=10, window=60): """Декоратор для rate limiting - 10 запросов в минуту""" def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): client_ip = request.remote_addr now = time.time() while request_counts[client_ip] and request_counts[client_ip][0] <= now - window: request_counts[client_ip].popleft() if len(request_counts[client_ip]) >= max_requests: # logger.warning(f"⚠️ Rate limit exceeded для {client_ip} ({len(request_counts[client_ip])} запросов)") return jsonify({ 'error': 'Rate limit exceeded', 'message': 'Слишком много запросов. Попробуйте позже.', 'retry_after': window }), 429 request_counts[client_ip].append(now) return f(*args, **kwargs) return decorated_function return decorator import sys import locale if sys.platform == "win32": import codecs sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach()) else: sys.stdout.reconfigure(encoding='utf-8') logging.basicConfig( level=logging.INFO, format='%(asctime)s | %(levelname)-8s | %(name)-20s | %(funcName)-20s | %(lineno)-4d | %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) logger = logging.getLogger(__name__) def _log_server_operation_start(operation: str, **kwargs): """Логирование начала серверной операции""" # logger.info(f"🚀 === НАЧАЛО СЕРВЕРНОЙ ОПЕРАЦИИ: {operation} ===") # logger.info(f"🚀 Время: {moscow_now()}") # for key, value in kwargs.items(): # logger.info(f"🚀 {key}: {value}") # logger.info("🚀 =================================") pass def _log_server_operation_end(operation: str, success: bool, duration: float = None, **kwargs): """Логирование завершения серверной операции""" # status = "✅ УСПЕШНО" if success else "❌ ОШИБКА" # logger.info(f"🏁 === ЗАВЕРШЕНИЕ СЕРВЕРНОЙ ОПЕРАЦИИ: {operation} ===") # logger.info(f"🏁 Статус: {status}") # logger.info(f"🏁 Время: {moscow_now()}") # if duration is not None: # logger.info(f"🏁 Длительность: {duration:.3f} сек") # for key, value in kwargs.items(): # logger.info(f"🏁 {key}: {value}") # logger.info("🏁 ===================================") pass def _log_server_sync_change(change: dict, action: str): """Логирование изменений синхронизации на сервере""" # logger.info(f"🔄 === СЕРВЕРНОЕ ИЗМЕНЕНИЕ СИНХРОНИЗАЦИИ ===") # logger.info(f"🔄 Действие: {action}") # logger.info(f"🔄 Таблица: {change.get('table_name', 'N/A')}") # logger.info(f"🔄 ID записи: {change.get('record_id', 'N/A')}") # logger.info(f"🔄 Операция: {change.get('action', 'N/A')}") # logger.info(f"🔄 Версия: {change.get('version', 'N/A')}") # logger.info(f"🔄 Хеш: {change.get('content_hash', 'N/A')}") # logger.info(f"🔄 Время: {change.get('timestamp', 'N/A')}") # if 'data' in change: # logger.info(f"🔄 Данные: {json.dumps(change['data'], ensure_ascii=False, indent=2)}") # logger.info("🔄 ==============================") pass def _log_server_changes_statistics(changes: list, context: str): """Логирование статистики изменений на сервере""" # if not changes: # logger.info(f"📊 {context}: изменений нет") # return # # stats = {} # for change in changes: # table_name = change.get('table_name', 'unknown') # action = change.get('action', 'unknown') # key = f"{table_name}.{action}" # if key not in stats: # stats[key] = 0 # stats[key] += 1 # # logger.info(f"📊 === СТАТИСТИКА {context.upper()} ===") # for change_type, count in stats.items(): # logger.info(f"📊 - {change_type}: {count} изменений") # logger.info(f"📊 Всего: {len(changes)} изменений") # logger.info("📊 ================================") pass def _log_server_error_with_traceback(error: Exception, context: str = ""): """Логирование ошибок сервера с полным traceback""" # import traceback # logger.error(f"💥 === СЕРВЕРНАЯ ОШИБКА ===") # if context: # logger.error(f"💥 Контекст: {context}") # logger.error(f"💥 Тип ошибки: {type(error).__name__}") # logger.error(f"💥 Сообщение: {str(error)}") # logger.error(f"💥 Traceback:") # for line in traceback.format_exc().splitlines(): # logger.error(f"💥 {line}") # logger.error("💥 =============") pass try: locale.setlocale(locale.LC_ALL, 'ru_RU.UTF-8') except locale.Error: pass # --- Базовые настройки приложения --- MOSCOW_TZ = pytz.timezone('Europe/Moscow') def moscow_now(): """Возвращает текущее время в московском часовом поясе без timezone info""" return datetime.now(MOSCOW_TZ).replace(tzinfo=None) def moscow_datetime_default(): """Функция для использования в default параметрах SQLAlchemy""" return moscow_now() app = Flask(__name__, static_folder='static') app.config['JSON_AS_ASCII'] = False app.config['TEMPLATES_AUTO_RELOAD'] = True app.secret_key = 'zootechnician_auth_secret_key_2024' # Секретный ключ для сессий # --- Настройка CORS --- CORS(app, resources={ r"/api/*": { "origins": ["*"], "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], "allow_headers": [ "Content-Type", "Authorization", "Content-Encoding", # Поддержка сжатых ответов "Accept-Encoding" # Поддержка сжатых ответов ] } }) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) CALIBRATION_FILE = os.path.join(BASE_DIR, 'calibration_factor.json') INITIAL_WEIGHT_FILE = os.path.join(BASE_DIR, 'weight_0.json') LED_PIN = 23 # GPIO pin для светодиода SAMPLES_PER_READ = 3 SAMPLE_HISTORY_SIZE = 200 MOVING_AVERAGE_WINDOW = 2 LOW_RESOURCE_MODE = os.getenv('LOW_RESOURCE_MODE', '0') == '1' _default_threads = '10' if LOW_RESOURCE_MODE else '20' WAITRESS_THREADS = int(os.getenv('WAITRESS_THREADS', _default_threads)) SYNC_BATCH_SIZE = int(os.getenv('SYNC_BATCH_SIZE', '30')) # Размер батча для обработки изменений SYNC_MAX_CONCURRENT = int(os.getenv('SYNC_MAX_CONCURRENT', '1')) # Максимум одновременных синхронизаций SYNC_RATE_LIMIT = int(os.getenv('SYNC_RATE_LIMIT', '10')) # Максимум запросов в минуту на клиента _sync_semaphore = Semaphore(SYNC_MAX_CONCURRENT) _sync_rate_limits = defaultdict(deque) # --- Конфигурация привязки к MAC адресу --- MAC_LOCK_ENABLED = False # Можно отключить через переменную окружения VIRTUAL_INTERFACE_PREFIXES = ( 'lo', 'docker', 'veth', 'virbr', 'br-', 'tun', 'tap', 'wg', 'tailscale', 'zt', 'vmnet' ) WAIT_FOR_INTERFACE_TIMEOUT = int(os.getenv('MAC_WAIT_TIMEOUT', '15')) WAIT_FOR_INTERFACE_INTERVAL = 0.5 MAC_AUTH_ALLOWED_PATHS = { '/unauthorized-device', '/api/mac_info', } MAC_AUTH_ALLOWED_PREFIXES = ( '/static/', '/favicon.ico' ) def _is_virtual_interface(name): if not name: return True normalized = name.lower() return any(normalized.startswith(prefix) for prefix in VIRTUAL_INTERFACE_PREFIXES) def _wait_for_physical_mac(interface): """Ожидает, пока интерфейс получит аппаратный MAC""" deadline = time.time() + WAIT_FOR_INTERFACE_TIMEOUT addr_type_path = f'/sys/class/net/{interface}/addr_assign_type' operstate_path = f'/sys/class/net/{interface}/operstate' while time.time() < deadline: try: with open(addr_type_path, 'r') as f: addr_type = f.read().strip() with open(operstate_path, 'r') as f: operstate = f.read().strip() if addr_type == '0' and operstate in ('up', 'unknown'): return True except FileNotFoundError: return False time.sleep(WAIT_FOR_INTERFACE_INTERVAL) return False def _read_physical_mac(interface): """Возвращает аппаратный MAC интерфейса""" addr_type_path = f'/sys/class/net/{interface}/addr_assign_type' try: with open(addr_type_path, 'r') as f: addr_type = f.read().strip() except FileNotFoundError: return None address_path = f'/sys/class/net/{interface}/address' if addr_type == '0': try: with open(address_path, 'r') as f: mac = f.read().strip().upper() if mac and mac != '00:00:00:00:00:00': return mac except FileNotFoundError: return None try: result = subprocess.run(['ethtool', '-P', interface], capture_output=True, text=True, check=True) for line in result.stdout.splitlines(): if 'Permanent address:' in line: mac = line.split('Permanent address:')[-1].strip().upper() if mac and mac != '00:00:00:00:00:00': return mac except (subprocess.CalledProcessError, FileNotFoundError): return None return None SECRET_FOLDER = os.path.join(BASE_DIR, '.secret') MAC_ADDRESS_FILE = os.path.join(SECRET_FOLDER, 'authorized_mac.txt') # Файл для сохранения MAC адреса CREDENTIALS_FILE = os.path.join(_wesp_root, 'data', 'credentials.json') UNIVERSAL_LOGIN = "admin" UNIVERSAL_PASSWORD = "U4FHx_nhmvAK" def ensure_secret_folder(): """Создает секретную папку если её не существует""" try: if not os.path.exists(SECRET_FOLDER): os.makedirs(SECRET_FOLDER, mode=0o700) # Права только для владельца # print(f"🔒 Создана секретная папка: {SECRET_FOLDER}") if os.name == 'nt': try: import subprocess subprocess.run(['attrib', '+h', SECRET_FOLDER], check=True, capture_output=True) # print("🔒 Папка сделана скрытой") except: pass except Exception as e: # print(f"❌ Ошибка создания секретной папки: {e}") pass ensure_secret_folder() def load_authorized_mac(): """Загружает сохраненный MAC адрес из файла""" try: if os.path.exists(MAC_ADDRESS_FILE): with open(MAC_ADDRESS_FILE, 'r') as f: mac = f.read().strip() if mac and mac != '': # print(f"📋 Загружен сохраненный MAC адрес: {mac}") return [mac] return [] except Exception as e: # print(f"❌ Ошибка загрузки MAC адреса: {e}") return [] def save_authorized_mac(mac_address): """Сохраняет MAC адрес в файл""" try: ensure_secret_folder() with open(MAC_ADDRESS_FILE, 'w') as f: f.write(mac_address) # print(f"💾 MAC адрес {mac_address} сохранен в секретном файле") if os.name == 'nt': try: subprocess.run(['attrib', '+h', MAC_ADDRESS_FILE], check=True, capture_output=True) # print("🔒 Файл сделан скрытым") except: pass except Exception as e: # print(f"❌ Ошибка сохранения MAC адреса: {e}") pass def load_credentials(): """Загружает учетные данные из файла""" try: # print(f"Проверяем файл учетных данных: {CREDENTIALS_FILE}") if os.path.exists(CREDENTIALS_FILE): # print(f"Файл найден, читаем данные...") with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f: credentials = json.load(f) login = credentials.get('login', 'Л1') password = credentials.get('password', 'M9w_Q') # print(f"📋агружены учетные данные: логин={login}, пароль={'*' * len(password)}") return login, password else: # print(f"⚠️ Файл не найден, используем значения по умолчанию") pass return 'Л1', 'M9w_Q' # Значения по умолчанию except Exception as e: # print(f"❌ Ошибка загрузки учетных данных: {e}") return 'Л1', 'M9w_Q' # Значения по умолчанию def save_credentials(login, password): """Сохраняет учетные данные в файл""" try: credentials = { 'login': login, 'password': password, 'updated_at': datetime.now().isoformat() } with open(CREDENTIALS_FILE, 'w', encoding='utf-8') as f: json.dump(credentials, f, indent=2, ensure_ascii=False) # print(f"Учетные данные сохранены: логин {login}") except Exception as e: # print(f"❌ Ошибка сохранения учетных данных: {e}") raise e ALLOWED_MAC_ADDRESSES = load_authorized_mac() def get_mac_address(): """Получает аппаратный MAC адрес физического интерфейса (Linux)""" try: try: interfaces = [iface for iface in os.listdir('/sys/class/net') if not _is_virtual_interface(iface)] except Exception: interfaces = ['eth0', 'wlan0'] for interface in interfaces: if not _wait_for_physical_mac(interface): # print(f"⏳ Интерфейс {interface} не готов (случайный MAC или down)") continue mac = _read_physical_mac(interface) if mac: # print(f"🔍 Аппаратный MAC {mac} у интерфейса {interface}") return mac # print("❌ Не удалось получить аппаратный MAC адрес") return None except Exception as e: # print(f"❌ Критическая ошибка получения MAC адреса: {e}") return None def check_mac_authorization(): """Проверяет, разрешен ли MAC адрес для запуска приложения""" if not MAC_LOCK_ENABLED: # print("🔓 Привязка к MAC адресу отключена") return True current_mac = get_mac_address() if not current_mac: # print("⚠️ Не удалось получить MAC адрес. Приложение запущено без проверки.") return True # print(f"Текущий MAC адрес: {current_mac}") if not ALLOWED_MAC_ADDRESSES: # print(f"🎯 Первый запуск!") save_authorized_mac(current_mac) ALLOWED_MAC_ADDRESSES.append(current_mac) # print(f"✅ MAC адрес {current_mac} сохранен") return True if current_mac in ALLOWED_MAC_ADDRESSES: # print(f"✅ MAC адрес разрешен") return True else: # print(f"MAC адрес не разрешен") return False if LOW_RESOURCE_MODE: READ_INTERVAL = 0.2 STREAM_INTERVAL = 1.0 SAMPLE_HISTORY_SIZE = 100 else: READ_INTERVAL = 0.05 STREAM_INTERVAL = 0.5 SIMULATION_MODE = False GPIO_AVAILABLE = False HX711_AVAILABLE = False SIMULATION_MODE = True try: import RPi.GPIO as GPIO GPIO.setwarnings(False) GPIO.setmode(GPIO.BCM) GPIO.setup(LED_PIN, GPIO.OUT, initial=GPIO.HIGH) GPIO_AVAILABLE = True # print("Инициализация GPIO успешна") except Exception as e: GPIO_AVAILABLE = False # print(f"Ошибка инициализации GPIO: {e}") if GPIO_AVAILABLE: try: from hx711 import HX711 hx = HX711(dout_pin=2, pd_sck_pin=3) HX711_AVAILABLE = True # print("Инициализация HX711 успешна") except Exception as e: HX711_AVAILABLE = False # print(f"Ошибка инициализации HX711: {e}") if GPIO_AVAILABLE and HX711_AVAILABLE: SIMULATION_MODE = False # print("Система работает в полном режиме") elif GPIO_AVAILABLE: # print("Система работает без весов HX711") pass else: # print("Система работает в режиме симуляции") pass DATABASE_PATH = os.path.join(BASE_DIR, 'data', 'recipes.db') REPORTS_DATABASE_PATH = os.path.join(BASE_DIR, 'data', 'reports.db') app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DATABASE_PATH}?timeout=30' app.config['SQLALCHEMY_BINDS'] = { 'reports': f'sqlite:///{REPORTS_DATABASE_PATH}?timeout=30' } app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { 'connect_args': { 'timeout': 30, 'check_same_thread': False }, 'pool_size': 10, # Размер пула соединений 'max_overflow': 20, # Максимум дополнительных соединений 'pool_pre_ping': True, # Проверка соединений перед использованием 'pool_recycle': 3600 # Переиспользование соединений каждый час } db = SQLAlchemy(app) original_commit = db.session.commit original_add = db.session.add original_rollback = db.session.rollback @log_db_operation("COMMIT") def logged_commit(): return original_commit() @log_db_operation("ADD") def logged_add(obj): return original_add(obj) @log_db_operation("ROLLBACK") def logged_rollback(): return original_rollback() db.session.commit = logged_commit db.session.add = logged_add db.session.rollback = logged_rollback def setup_sqlite_pragma(): """Настройка SQLite PRAGMA""" @event.listens_for(db.engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA synchronous=NORMAL") cursor.execute("PRAGMA busy_timeout=30000") cursor.close() @event.listens_for(db.engines['reports'], "connect") def set_sqlite_pragma_reports(dbapi_connection, connection_record): cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA synchronous=NORMAL") cursor.execute("PRAGMA busy_timeout=30000") cursor.close() def get_record_key(obj): try: if hasattr(obj, 'id') and getattr(obj, 'id'): return obj.id if hasattr(obj, 'period_id') and hasattr(obj, 'recipe_id'): return f"{obj.period_id}:{obj.recipe_id}" except Exception: pass return '' class SoftDeleteMixin: """Миксин для мягкого удаления с полным аудитом""" is_deleted = db.Column(db.Boolean, default=False, nullable=True) deleted_at = db.Column(db.DateTime, nullable=True) deleted_by = db.Column(db.String(50), nullable=True) deleted_reason = db.Column(db.String(255), nullable=True) deleted_ip = db.Column(db.String(45), nullable=True) deleted_user_agent = db.Column(db.String(500), nullable=True) restored_at = db.Column(db.DateTime, nullable=True) restored_by = db.Column(db.String(50), nullable=True) restored_reason = db.Column(db.String(255), nullable=True) restored_ip = db.Column(db.String(45), nullable=True) restored_user_agent = db.Column(db.String(500), nullable=True) delete_restore_count = db.Column(db.Integer, default=0, nullable=True) def soft_delete(self, deleted_by='system', reason=None, request=None): """Мягкое удаление с полным аудитом""" if hasattr(self, 'is_deleted') and self.is_deleted: # logger.info(f"ℹ️ Запись уже удалена: {self.__tablename__}.{get_record_key(self)}") return # Пропускаем повторное удаление if hasattr(self, 'is_deleted'): self.is_deleted = True self.deleted_at = moscow_now() self.deleted_by = deleted_by self.deleted_reason = reason if hasattr(self, 'delete_restore_count'): self.delete_restore_count = (self.delete_restore_count or 0) + 1 if request: if hasattr(self, 'deleted_ip'): self.deleted_ip = request.remote_addr if hasattr(self, 'deleted_user_agent'): self.deleted_user_agent = request.headers.get('User-Agent', '')[:500] if hasattr(self, 'updated_at'): self.updated_at = moscow_now() if hasattr(self, 'updated_by'): self.updated_by = deleted_by if hasattr(self, 'version'): self.version += 1 if hasattr(self, 'content_hash'): update_content_hash(self) self._cascade_soft_delete(deleted_by, reason, request) if deleted_by != 'sync': try: record_id = get_record_key(self) create_sync_task(self.__tablename__, record_id, 'delete', priority=1, target_node_id=None) except Exception as e: # logger.error(f"Ошибка создания задачи синхронизации при удалении: {e}") pass def restore(self, restored_by='system', reason=None, request=None): """Восстановление с полным аудитом""" if hasattr(self, 'is_deleted') and not self.is_deleted: # logger.info(f"ℹ️ Запись уже восстановлена: {self.__tablename__}.{get_record_key(self)}") return # Пропускаем повторное восстановление if hasattr(self, 'is_deleted'): self.is_deleted = False self.restored_at = moscow_now() self.restored_by = restored_by self.restored_reason = reason if request: if hasattr(self, 'restored_ip'): self.restored_ip = request.remote_addr if hasattr(self, 'restored_user_agent'): self.restored_user_agent = request.headers.get('User-Agent', '')[:500] if hasattr(self, 'updated_at'): self.updated_at = moscow_now() if hasattr(self, 'updated_by'): self.updated_by = restored_by if hasattr(self, 'version'): self.version += 1 if hasattr(self, 'content_hash'): update_content_hash(self) if restored_by != 'sync': try: record_id = get_record_key(self) create_sync_task(self.__tablename__, record_id, 'restore', priority=2, target_node_id=None) except Exception as e: logger.error(f"Ошибка создания задачи синхронизации при восстановлении: {e}") def _cascade_soft_delete(self, deleted_by, reason, request): """Каскадное мягкое удаление дочерних объектов - переопределяется в каждой модели""" pass class Component(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) type = db.Column(db.String(100), nullable=False, default='') is_active = db.Column(db.Boolean, default=True) dry_matter = db.Column(db.Float, nullable=False, default=0) protein = db.Column(db.Float, nullable=False, default=0) energy = db.Column(db.Float, nullable=False, default=0) price = db.Column(db.Float, nullable=False, default=0) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') def _cascade_soft_delete(self, deleted_by, reason, request): """Каскадное удаление ингредиентов, использующих этот компонент""" deleted_count = 0 for ingredient in self.ingredients: if not hasattr(ingredient, 'is_deleted') or not ingredient.is_deleted: ingredient.soft_delete( deleted_by=deleted_by, reason=f"Каскадное удаление: компонент {self.name} удален", request=request ) deleted_count += 1 if deleted_count > 0: # logger.info(f"🔄 Каскадное удаление: удалено {deleted_count} ингредиентов для компонента {self.name}") pass class UnloadingGroup(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) distribution_type = db.Column(db.String(20), nullable=False) # 'percent' или 'heads' value = db.Column(db.Float, nullable=False) # процент или количество голов weight = db.Column(db.Float, nullable=True) # вес в кг order = db.Column(db.Integer, nullable=False) # порядок выгрузки recipe_id = db.Column(db.String(36), db.ForeignKey('recipe.id'), nullable=False) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') class Recipe(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) heads_per_trip = db.Column(db.Integer, default=1) mixing_time = db.Column(db.Integer, default=0) trip_percent = db.Column(db.Float, default=100) # Процент на рейс dry_matter_locked = db.Column(db.Boolean, nullable=False, default=False) unloading_link_broken = db.Column(db.Boolean, nullable=False, default=False) # Состояние связи между группами выгрузки и Процент на рейс (%) target_component_id = db.Column(db.String(36), db.ForeignKey('component.id'), nullable=True) # Для кормоцеха: выбранный компонент version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') ingredients = db.relationship('Ingredient', backref='recipe', cascade='all, delete-orphan') unloading_groups = db.relationship('UnloadingGroup', backref='recipe', cascade='all, delete-orphan') trips = db.relationship('Trip', back_populates='recipe', cascade='all, delete-orphan') period_recipes_rel = db.relationship('PeriodRecipe', cascade='all, delete-orphan', overlaps="feeding_periods") def _cascade_soft_delete(self, deleted_by, reason, request): """Каскадное удаление связанных объектов""" deleted_trips = 0 for trip in self.trips: if not hasattr(trip, 'is_deleted') or not trip.is_deleted: trip.soft_delete(deleted_by, f"Каскадное удаление: рецепт {self.name} удален", request) deleted_trips += 1 if deleted_trips > 0: # logger.info(f"🔄 Каскадное удаление: удалено {deleted_trips} рейсов для рецепта {self.name}") pass for period_recipe in self.period_recipes_rel: if not hasattr(period_recipe, 'is_deleted') or not period_recipe.is_deleted: period_recipe.soft_delete(deleted_by, f"Каскадное удаление: рецепт {self.name} удален", request) if hasattr(LoadingReport, 'is_deleted'): loading_reports = LoadingReport.query.filter_by(recipe_id=self.id, is_deleted=False).all() else: loading_reports = LoadingReport.query.filter_by(recipe_id=self.id).all() for report in loading_reports: report.soft_delete(deleted_by, f"Каскадное удаление: рецепт {self.name} удален", request) if hasattr(UnloadingReport, 'is_deleted'): unloading_reports = UnloadingReport.query.filter_by(recipe_id=self.id, is_deleted=False).all() else: unloading_reports = UnloadingReport.query.filter_by(recipe_id=self.id).all() for report in unloading_reports: report.soft_delete(deleted_by, f"Каскадное удаление: рецепт {self.name} удален", request) class Ingredient(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) amount = db.Column(db.Float, nullable=False) weight_per_head = db.Column(db.Float) # Новое поле: вес на голову dry_matter = db.Column(db.Float, nullable=False, default=0) # Процент сухого вещества dry_matter_per_head = db.Column(db.Float, nullable=True) # СВ на голову (кг) - константа для режима "замок СВ" order = db.Column(db.Integer, nullable=False, default=0) # Порядок ингредиента recipe_id = db.Column(db.String(36), db.ForeignKey('recipe.id'), nullable=False) component_id = db.Column(db.String(36), db.ForeignKey('component.id'), nullable=True) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') component = db.relationship('Component', backref='ingredients') class LoadingReport(db.Model, SoftDeleteMixin): __bind_key__ = 'reports' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) recipe_id = db.Column(db.String(36), nullable=False) # UUID рецепта recipe_name = db.Column(db.String(100), nullable=False) start_time = db.Column(db.DateTime, nullable=False, default=moscow_datetime_default) end_time = db.Column(db.DateTime) target_mixing_time = db.Column(db.Integer) actual_mixing_time = db.Column(db.Integer) total_weight = db.Column(db.Float) dispenser_type = db.Column(db.String(20), nullable=False, default='dispenser') # 'dispenser' | 'mill' version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') client_id = db.Column(db.String(50), nullable=True) server_synced = db.Column(db.Boolean, default=False, nullable=False) sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') components = db.relationship('LoadingReportComponent', back_populates='report', cascade='all, delete-orphan') component_loading_times = db.relationship('ComponentLoadingTime', back_populates='report', cascade='all, delete-orphan') def _cascade_soft_delete(self, deleted_by, reason, request): """Каскадное удаление связанных объектов""" unloading_reports = UnloadingReport.query.filter_by(loading_report_id=self.id, is_deleted=False).all() for report in unloading_reports: report.soft_delete(deleted_by, f"Каскадное удаление: отчет загрузки {self.id} удален", request) class LoadingReportComponent(db.Model, SoftDeleteMixin): __bind_key__ = 'reports' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) report_id = db.Column(db.String(36), db.ForeignKey('loading_report.id'), nullable=False) component_id = db.Column(db.String(36), nullable=True) # UUID из recipes.db (Component.id) component_name = db.Column(db.String(100), nullable=False) target_weight = db.Column(db.Float, nullable=False) actual_weight = db.Column(db.Float, nullable=False) overload = db.Column(db.Float) loading_order = db.Column(db.Integer, nullable=False) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') report = db.relationship('LoadingReport', back_populates='components') class ComponentLoadingTime(db.Model, SoftDeleteMixin): __bind_key__ = 'reports' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) report_id = db.Column(db.String(36), db.ForeignKey('loading_report.id'), nullable=False) component_name = db.Column(db.String(100), nullable=False) start_time = db.Column(db.DateTime, nullable=False) end_time = db.Column(db.DateTime, nullable=False) loading_duration = db.Column(db.Float, nullable=False) loading_order = db.Column(db.Integer, nullable=False) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') report = db.relationship('LoadingReport', back_populates='component_loading_times') class UnloadingReport(db.Model, SoftDeleteMixin): __bind_key__ = 'reports' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) recipe_id = db.Column(db.String(36), nullable=False) # UUID рецепта recipe_name = db.Column(db.String(100), nullable=False) loading_report_id = db.Column(db.String(36), nullable=False) # UUID отчета загрузки start_time = db.Column(db.DateTime, nullable=False, default=moscow_datetime_default) end_time = db.Column(db.DateTime) total_weight = db.Column(db.Float, nullable=False) total_unloaded_weight = db.Column(db.Float, nullable=False) remaining_weight = db.Column(db.Float, nullable=False) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') client_id = db.Column(db.String(50), nullable=True) server_synced = db.Column(db.Boolean, default=False, nullable=False) sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') groups = db.relationship('UnloadingReportGroup', back_populates='report', cascade='all, delete-orphan') class UnloadingReportGroup(db.Model, SoftDeleteMixin): __bind_key__ = 'reports' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) report_id = db.Column(db.String(36), db.ForeignKey('unloading_report.id'), nullable=False) name = db.Column(db.String(100), nullable=False) target_weight = db.Column(db.Float, nullable=False) unloaded_weight = db.Column(db.Float, nullable=False) remaining_weight = db.Column(db.Float, nullable=False) distribution_type = db.Column(db.String(20), nullable=False) distribution_value = db.Column(db.Float, nullable=False) order = db.Column(db.Integer, nullable=False) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') report = db.relationship('UnloadingReport', back_populates='groups') class FeedMixer(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) model = db.Column(db.String(100)) # Модель кормосмесителя gosnomer = db.Column(db.String(20)) # Госномер operator = db.Column(db.String(100)) # ФИО оператора ip_address = db.Column(db.String(15)) # IP-адрес шкафа управления is_active = db.Column(db.Boolean, default=True) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') trips = db.relationship('Trip', back_populates='mixer', cascade='all, delete-orphan') def _cascade_soft_delete(self, deleted_by, reason, request): """Каскадное удаление связанных объектов""" for trip in self.trips: if not hasattr(trip, 'is_deleted') or not trip.is_deleted: trip.soft_delete(deleted_by, f"Каскадное удаление: кормосмеситель {self.name} удален", request) if hasattr(FeedingLocation, 'is_deleted'): feeding_locations = FeedingLocation.query.filter_by(mixer_id=self.id, is_deleted=False).all() else: feeding_locations = FeedingLocation.query.filter_by(mixer_id=self.id).all() for location in feeding_locations: location.soft_delete(deleted_by, f"Каскадное удаление: кормосмеситель {self.name} удален", request) if hasattr(FeedingPoint, 'is_deleted'): feeding_points = FeedingPoint.query.filter_by(mixer_id=self.id, is_deleted=False).all() else: feeding_points = FeedingPoint.query.filter_by(mixer_id=self.id).all() for point in feeding_points: point.soft_delete(deleted_by, f"Каскадное удаление: кормосмеситель {self.name} удален", request) class FeedingLocation(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) mixer_id = db.Column(db.String(36), db.ForeignKey('feed_mixer.id'), nullable=True) is_active = db.Column(db.Boolean, default=True) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') class FeedingPeriod(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) dispenser_id = db.Column(db.String(36), db.ForeignKey('feed_dispenser.id'), nullable=False) is_active = db.Column(db.Boolean, default=True) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') recipes = db.relationship('Recipe', secondary='period_recipes', backref='feeding_periods', lazy='joined', overlaps="period_recipes_rel") period_recipes_rel = db.relationship('PeriodRecipe', cascade='all, delete-orphan', overlaps="feeding_periods,recipes") dispenser = db.relationship('FeedDispenser', back_populates='periods') def _cascade_soft_delete(self, deleted_by, reason, request): """Каскадное удаление связанных объектов""" for period_recipe in self.period_recipes_rel: if not hasattr(period_recipe, 'is_deleted') or not period_recipe.is_deleted: period_recipe.soft_delete(deleted_by, f"Каскадное удаление: период {self.name} удален", request) if hasattr(FeedingPoint, 'is_deleted'): feeding_points = FeedingPoint.query.filter_by(period_id=self.id, is_deleted=False).all() else: feeding_points = FeedingPoint.query.filter_by(period_id=self.id).all() for point in feeding_points: point.soft_delete(deleted_by, f"Каскадное удаление: период {self.name} удален", request) class FeedingPoint(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) mixer_id = db.Column(db.String(36), db.ForeignKey('feed_mixer.id'), nullable=True) location_id = db.Column(db.String(36), db.ForeignKey('feeding_location.id'), nullable=True) period_id = db.Column(db.String(36), db.ForeignKey('feeding_period.id'), nullable=True) is_active = db.Column(db.Boolean, default=True) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') class Trip(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) mixer_id = db.Column(db.String(36), db.ForeignKey('feed_mixer.id'), nullable=False) recipe_id = db.Column(db.String(36), db.ForeignKey('recipe.id'), nullable=False) daily_ration_percent = db.Column(db.Float, nullable=False) # Процент суточного рациона heads_count = db.Column(db.Integer, nullable=False) # Количество голов mixing_time = db.Column(db.Integer, nullable=False) # Время смешивания correction_percent = db.Column(db.Float, nullable=False) # Процент коррекции status = db.Column(db.String(20), nullable=False) # Статус рейса (новый, выполняется, завершен) created_at = db.Column(db.DateTime, default=moscow_datetime_default) started_at = db.Column(db.DateTime) completed_at = db.Column(db.DateTime) version = db.Column(db.Integer, default=1, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') mixer = db.relationship('FeedMixer', back_populates='trips') recipe = db.relationship('Recipe', back_populates='trips') class FeedDispenser(db.Model, SoftDeleteMixin): id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) name = db.Column(db.String(100), nullable=False) farm = db.Column(db.String(100), nullable=False) operator = db.Column(db.String(100), nullable=False) type = db.Column(db.String(20), nullable=False, default='dispenser') # 'dispenser' или 'mill' (кормоцех) is_active = db.Column(db.Boolean, default=True) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') periods = db.relationship('FeedingPeriod', back_populates='dispenser', cascade='all, delete-orphan', lazy=True) def _cascade_soft_delete(self, deleted_by, reason, request): """Каскадное удаление связанных объектов""" for period in self.periods: if not hasattr(period, 'is_deleted') or not period.is_deleted: period.soft_delete(deleted_by, f"Каскадное удаление: кормораздатчик {self.name} удален", request) for period in self.periods: if hasattr(FeedingPoint, 'is_deleted'): feeding_points = FeedingPoint.query.filter_by(period_id=period.id, is_deleted=False).all() else: feeding_points = FeedingPoint.query.filter_by(period_id=period.id).all() for point in feeding_points: point.soft_delete(deleted_by, f"Каскадное удаление: период {period.name} удален", request) class PeriodRecipe(db.Model, SoftDeleteMixin): __tablename__ = 'period_recipes' period_id = db.Column(db.String(36), db.ForeignKey('feeding_period.id'), primary_key=True) recipe_id = db.Column(db.String(36), db.ForeignKey('recipe.id'), primary_key=True) order = db.Column(db.Integer, nullable=False, default=0) version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) created_by = db.Column(db.String(50), nullable=False, default='system') updated_by = db.Column(db.String(50), nullable=False, default='system') sync_timestamp = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='pending', nullable=False) content_hash = db.Column(db.String(64), nullable=False, default='') class SyncMetadata(db.Model, SoftDeleteMixin): """Метаданные синхронизации - паспорт системы""" __tablename__ = 'sync_metadata' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) node_id = db.Column(db.String(36), nullable=False, unique=True) node_type = db.Column(db.String(20), nullable=False) # 'server' или 'client' node_name = db.Column(db.String(100), nullable=True) node_status = db.Column(db.String(20), default='active') # active, offline, disabled last_heartbeat = db.Column(db.DateTime, nullable=True) last_sync = db.Column(db.DateTime, nullable=True) sync_status = db.Column(db.String(20), default='idle') # idle, syncing, error is_enabled = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) class SyncClient(db.Model, SoftDeleteMixin): """Реестр всех клиентов""" __tablename__ = 'sync_clients' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) node_id = db.Column(db.String(36), nullable=False, unique=True) client_name = db.Column(db.String(100), nullable=False) ip_address = db.Column(db.String(45), nullable=True) port = db.Column(db.Integer, default=5000) status = db.Column(db.String(20), default='active') # active, offline, disabled last_seen = db.Column(db.DateTime, nullable=True) total_syncs = db.Column(db.Integer, default=0) last_error = db.Column(db.Text, nullable=True) is_enabled = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) class SyncClientDisplayName(db.Model): """Привязка клиента синхронизации к отображаемому имени (из feed_dispenser.name)""" __tablename__ = 'sync_client_display_name' node_id = db.Column(db.String(36), primary_key=True) display_name = db.Column(db.String(100), nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) class SyncQueue(db.Model, SoftDeleteMixin): """Очередь изменений для синхронизации""" __tablename__ = 'sync_queue' __table_args__ = ( db.UniqueConstraint('table_name', 'record_id', 'action', 'target_node_id', name='uq_sync_queue_task'), ) id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) table_name = db.Column(db.String(50), nullable=False) record_id = db.Column(db.String(36), nullable=False) action = db.Column(db.String(20), nullable=False) # 'create', 'update', 'delete' status = db.Column(db.String(20), default='pending') # pending, processing, completed, failed target_node_id = db.Column(db.String(36), nullable=True) source_node_id = db.Column(db.String(36), nullable=True) priority = db.Column(db.Integer, default=1) # 1-высокий, 5-низкий retry_count = db.Column(db.Integer, default=0) max_retries = db.Column(db.Integer, default=3) error_message = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) processed_at = db.Column(db.DateTime, nullable=True) completed_at = db.Column(db.DateTime, nullable=True) delivered_to_clients = db.Column(db.Text, nullable=True) # JSON список ID клиентов, получивших эту универсальную задачу def ensure_sync_queue_unique_index_sqlalchemy(): """Создаёт уникальный индекс для sync_queue, чтобы исключить дубликаты задач. Индекс по (table_name, record_id, action, COALESCE(target_node_id, '')). Если в таблице уже есть дубликаты, удаляем их перед созданием индекса. """ try: from sqlalchemy import text check_stmt = text(""" SELECT name FROM sqlite_master WHERE type='index' AND name='uq_sync_queue_task' """) result = db.session.execute(check_stmt).fetchone() if result: # logger.debug("Уникальный индекс uq_sync_queue_task уже существует") return # logger.debug("Индекс не найден, удаляем дубликаты перед созданием") cleanup_stmt = text(""" DELETE FROM sync_queue WHERE rowid NOT IN ( SELECT MIN(rowid) FROM sync_queue GROUP BY table_name, record_id, action, COALESCE(target_node_id, '') ) """) db.session.execute(cleanup_stmt) db.session.commit() # logger.debug("Дубликаты удалены") create_stmt = text(""" CREATE UNIQUE INDEX uq_sync_queue_task ON sync_queue ( table_name, record_id, action, COALESCE(target_node_id, '') ) """) db.session.execute(create_stmt) db.session.commit() # logger.info("Уникальный индекс uq_sync_queue_task создан") except Exception as e: db.session.rollback() # logger.error(f"Ошибка создания уникального индекса: {e}") pass class SyncConflict(db.Model, SoftDeleteMixin): """Конфликты синхронизации""" __tablename__ = 'sync_conflicts' id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) table_name = db.Column(db.String(50), nullable=False) record_id = db.Column(db.String(36), nullable=False) conflict_type = db.Column(db.String(20), nullable=False) # 'version', 'deletion', 'hash' local_data = db.Column(db.Text, nullable=True) # JSON локальных данных remote_data = db.Column(db.Text, nullable=True) # JSON удаленных данных resolution = db.Column(db.String(20), nullable=True) # 'local', 'remote', 'pending' resolved_by = db.Column(db.String(50), nullable=True) resolved_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=moscow_datetime_default, nullable=False) updated_at = db.Column(db.DateTime, default=moscow_datetime_default, onupdate=moscow_datetime_default, nullable=False) def ensure_sync_client_display_name_table(): """Создаёт таблицу sync_client_display_name в основной БД, если её нет (миграция).""" try: from sqlalchemy import text r = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='sync_client_display_name'")).fetchone() if not r: db.session.execute(text(""" CREATE TABLE sync_client_display_name ( node_id VARCHAR(36) NOT NULL PRIMARY KEY, display_name VARCHAR(100) NOT NULL, updated_at DATETIME NOT NULL ) """)) db.session.commit() except Exception as e: db.session.rollback() # print(f"Ошибка миграции sync_client_display_name: {e}") def init_sync_metadata(bind=None): """Инициализирует метаданные синхронизации""" try: from sqlalchemy import text if bind: engine = db.engines[bind] if bind else db.engine with engine.connect() as conn: table_exists = conn.execute(text(""" SELECT name FROM sqlite_master WHERE type='table' AND name='sync_metadata' """)).fetchone() if not table_exists: # print(f"Таблица sync_metadata не существует в базе {bind}, пропускаем инициализацию") return result = conn.execute(text("SELECT COUNT(*) FROM sync_metadata")).fetchone() else: result = db.session.execute(text("SELECT COUNT(*) FROM sync_metadata")).fetchone() if result[0] == 0: metadata = SyncMetadata( node_id=str(uuid.uuid4()), node_type='client', node_name='Клиент 1', node_status='active', sync_status='idle', is_enabled=True ) if bind: engine = db.engines[bind] if bind else db.engine with engine.connect() as conn: now_iso = moscow_now().isoformat() conn.execute(text(""" INSERT INTO sync_metadata (id, node_id, node_type, node_name, node_status, sync_status, is_enabled, created_at, updated_at) VALUES (:id, :node_id, :node_type, :node_name, :node_status, :sync_status, :is_enabled, :created_at, :updated_at) """), { 'id': metadata.id, 'node_id': metadata.node_id, 'node_type': metadata.node_type, 'node_name': metadata.node_name, 'node_status': metadata.node_status, 'sync_status': metadata.sync_status, 'is_enabled': metadata.is_enabled, 'created_at': now_iso, 'updated_at': now_iso }) conn.commit() else: db.session.add(metadata) db.session.commit() # print(f"Инициализированы метаданные синхронизации с node_id: {metadata.node_id}") else: # print("Метаданные синхронизации уже существуют") pass except Exception as e: # print(f"Ошибка инициализации метаданных синхронизации: {e}") pass from sqlalchemy import event def create_sync_task(table_name, record_id, action, priority=3, target_node_id=None): """Создает задачу синхронизации""" try: sync_task = SyncQueue( table_name=table_name, record_id=record_id, action=action, status='pending', priority=priority, target_node_id=target_node_id ) db.session.add(sync_task) db_commit_with_retry() # print(f"Создана задача синхронизации: {table_name}.{record_id} - {action} для {target_node_id or 'всех'}") return sync_task except Exception as e: # print(f"Ошибка создания задачи синхронизации: {e}") raise db.session.rollback() return None _pending_sync_tasks = [] DB_AUDIT_IGNORE_FIELDS = {'updated_at', 'sync_timestamp', 'content_hash'} def _format_table_log(action, table, pk, data=None, changes=None): """Форматирует лог операции с БД в читаемый табличный вид""" lines = [] pk_str = str(pk)[:35] if pk else 'None' lines.append("┌" + "─" * 80 + "┐") lines.append(f"│ {action:8} │ Таблица: {table:<25} │ PK: {pk_str:<35} │") lines.append("├" + "─" * 80 + "┤") if data: # Для CREATE/DELETE - показываем основные поля important_fields = ['name', 'id', 'recipe_id', 'component_id', 'period_id', 'amount', 'weight_per_head', 'dry_matter', 'dry_matter_per_head', 'order', 'distribution_type', 'value', 'weight', 'heads_per_trip', 'mixing_time', 'trip_percent', 'dry_matter_locked'] shown_fields = [] for field in important_fields: if field in data and data[field] is not None: value = str(data[field]) if len(value) > 45: value = value[:42] + "..." shown_fields.append((field, value)) if shown_fields: for field, value in shown_fields: lines.append(f"│ {field:<22} │ {value:<52} │") else: lines.append(f"│ (нет важных полей) {' ' * 52} │") if changes: # Для UPDATE - показываем изменения for field, change_dict in list(changes.items())[:8]: # Ограничиваем 8 полями old_val = str(change_dict.get('old', 'None')) new_val = str(change_dict.get('new', 'None')) if len(old_val) > 28: old_val = old_val[:25] + "..." if len(new_val) > 28: new_val = new_val[:25] + "..." lines.append(f"│ {field:<22} │ {old_val:<28} → {new_val:<28} │") if len(changes) > 8: lines.append(f"│ ... и еще {len(changes) - 8} полей {' ' * 38} │") lines.append("└" + "─" * 80 + "┘") return "\n".join(lines) def _get_pk_for_log(obj): try: if hasattr(obj, '__tablename__') and getattr(obj, '__tablename__') == 'period_recipes': return f"{getattr(obj, 'period_id', '')}:{getattr(obj, 'recipe_id', '')}" if hasattr(obj, 'id'): return getattr(obj, 'id', '') except Exception: pass return '' def _iter_changes(session): for obj in session.new: try: tbl = getattr(obj, '__tablename__', obj.__class__.__name__) pk = _get_pk_for_log(obj) data = {} for attr in obj.__mapper__.column_attrs: key = attr.key if key in DB_AUDIT_IGNORE_FIELDS: continue data[key] = getattr(obj, key, None) log_table = _format_table_log("CREATE", tbl, pk, data=data) logger.info(f"\n{log_table}") except Exception as e: logger.warning(f"[DB-AUDIT] CREATE log failed: {e}") for obj in session.dirty: try: state = inspect(obj) if getattr(state, 'deleted', False): continue tbl = getattr(obj, '__tablename__', obj.__class__.__name__) pk = _get_pk_for_log(obj) changes = {} for attr in obj.__mapper__.column_attrs: key = attr.key if key in DB_AUDIT_IGNORE_FIELDS: continue hist = state.attrs.get(key) if not hist or not hasattr(hist, 'history'): continue h = hist.history if h.has_changes(): old_val = h.deleted[0] if h.deleted else None new_val = h.added[0] if h.added else getattr(obj, key, None) if old_val != new_val: changes[key] = {'old': old_val, 'new': new_val} if changes: log_table = _format_table_log("UPDATE", tbl, pk, changes=changes) logger.info(f"\n{log_table}") except Exception as e: logger.warning(f"[DB-AUDIT] UPDATE log failed: {e}") for obj in session.deleted: try: tbl = getattr(obj, '__tablename__', obj.__class__.__name__) pk = _get_pk_for_log(obj) snapshot = {} for attr in obj.__mapper__.column_attrs: key = attr.key if key in DB_AUDIT_IGNORE_FIELDS: continue snapshot[key] = getattr(obj, key, None) log_table = _format_table_log("DELETE", tbl, pk, data=snapshot) logger.warning(f"\n{log_table}") except Exception as e: logger.warning(f"[DB-AUDIT] DELETE log failed: {e}") @event.listens_for(db.session, "before_flush") def _db_audit_before_flush(session, flush_context, instances): try: _iter_changes(session) except Exception as e: logger.warning(f"[DB-AUDIT] before_flush failed: {e}") def create_sync_task_async(table_name, record_id, action, priority=3, target_node_id=None): """Добавляет задачу синхронизации в очередь для отложенного создания""" try: task_info = { 'table_name': table_name, 'record_id': record_id, 'action': action, 'priority': priority, 'target_node_id': target_node_id } _pending_sync_tasks.append(task_info) return f"queued-{len(_pending_sync_tasks)}" except Exception as e: logger.error(f"[SYNC-TASK-QUEUE] Ошибка добавления задачи в очередь: {e}") return None def process_pending_sync_tasks(): """Обрабатывает все отложенные задачи синхронизации""" global _pending_sync_tasks if not _pending_sync_tasks: return logger.info(f"[SYNC-TASK-PROCESS] Обрабатываем {len(_pending_sync_tasks)} отложенных задач синхронизации") try: from sqlalchemy import text engine = db.engine with engine.connect() as conn: inserted_count = 0 skipped_count = 0 completed_count = 0 updated_count = 0 for task_info in _pending_sync_tasks: task_id = str(uuid.uuid4()) now = moscow_now().isoformat() if task_info['action'] == 'update': update_result = conn.execute(text(""" UPDATE sync_queue SET status = 'completed', completed_at = :completed_at WHERE table_name = :table_name AND record_id = :record_id AND action = 'update' AND status IN ('pending', 'processing') AND COALESCE(target_node_id, '') = COALESCE(:target_node_id, '') """), { 'table_name': task_info['table_name'], 'record_id': task_info['record_id'], 'target_node_id': task_info['target_node_id'], 'completed_at': now }) if update_result.rowcount > 0: completed_count += update_result.rowcount logger.info(f"[SYNC-TASK-REPLACE] Помечено {update_result.rowcount} старых задач update как completed: {task_info['table_name']}.{task_info['record_id'][:8]}... для {task_info['target_node_id'][:8] if task_info['target_node_id'] else 'всех'}...") update_existing = conn.execute(text(""" UPDATE sync_queue SET status = 'pending', created_at = :created_at, completed_at = NULL, processed_at = NULL, priority = :priority WHERE table_name = :table_name AND record_id = :record_id AND action = 'update' AND COALESCE(target_node_id, '') = COALESCE(:target_node_id, '') """), { 'table_name': task_info['table_name'], 'record_id': task_info['record_id'], 'target_node_id': task_info['target_node_id'], 'created_at': now, 'priority': task_info['priority'] }) if update_existing.rowcount > 0: updated_count += 1 logger.info(f"[SYNC-TASK-UPDATE] Обновлена существующая задача update: {task_info['table_name']}.{task_info['record_id'][:8]}... - {task_info['action']} для {task_info['target_node_id'][:8] if task_info['target_node_id'] else 'всех'}... (используется версия записи)") continue # Пропускаем INSERT для этой задачи result = conn.execute(text(""" INSERT OR IGNORE INTO sync_queue (id, table_name, record_id, action, status, priority, target_node_id, created_at) VALUES (:id, :table_name, :record_id, :action, 'pending', :priority, :target_node_id, :created_at) """), { 'id': task_id, 'table_name': task_info['table_name'], 'record_id': task_info['record_id'], 'action': task_info['action'], 'priority': task_info['priority'], 'target_node_id': task_info['target_node_id'], 'created_at': now }) if result.rowcount > 0: inserted_count += 1 logger.info(f"[SYNC-TASK-INSERT] Задача вставлена в БД: {task_info['table_name']}.{task_info['record_id'][:8]}... - {task_info['action']} для {task_info['target_node_id'][:8] if task_info['target_node_id'] else 'всех'}...") else: skipped_count += 1 logger.debug(f"[SYNC-TASK-INSERT] Задача пропущена INSERT OR IGNORE (дубликат): {task_info['table_name']}.{task_info['record_id'][:8]}... - {task_info['action']} для {task_info['target_node_id'][:8] if task_info['target_node_id'] else 'всех'}...") logger.info(f"[SYNC-TASK-INSERT] Итого: вставлено {inserted_count}, обновлено {updated_count}, помечено completed {completed_count}, пропущено {skipped_count} из {len(_pending_sync_tasks)} задач") conn.commit() _pending_sync_tasks.clear() logger.info(f"[SYNC-TASK-PROCESS] Обработка завершена, очередь очищена") except Exception as e: logger.error(f"[SYNC-TASK-PROCESS] Ошибка обработки отложенных задач: {e}", exc_info=True) def create_sync_task_hybrid(table_name, record_id, action, priority=3): """Создает задачи синхронизации для всех клиентов (не только активных)""" try: all_clients = SyncClient.query.filter_by( is_enabled=True, is_deleted=False ).all() created_tasks = [] for client in all_clients: task_id = create_sync_task_async( table_name=table_name, record_id=record_id, action=action, priority=priority, target_node_id=client.node_id ) if task_id: created_tasks.append(task_id) if created_tasks: lines = [] lines.append("┌" + "─" * 80 + "┐") lines.append(f"│ SYNC-TASK │ Создано {len(created_tasks)} задач для {len(all_clients)} клиентов") lines.append(f"│ │ Таблица: {table_name:<25} │ Действие: {action:<10} │") record_id_str = str(record_id)[:40] lines.append(f"│ │ ID записи: {record_id_str:<40} │") lines.append("├" + "─" * 80 + "┤") for client in all_clients: client_name = (client.client_name or 'N/A')[:35] lines.append(f"│ → {client.node_id[:36]:<36} │ {client_name:<35} │") lines.append("└" + "─" * 80 + "┘") logger.info("\n" + "\n".join(lines)) else: lines = [] lines.append("┌" + "─" * 80 + "┐") lines.append(f"│ SYNC-TASK │ Задачи не созданы (клиентов нет или ошибка)") lines.append(f"│ │ Таблица: {table_name:<25} │ Действие: {action:<10} │") record_id_str = str(record_id)[:40] lines.append(f"│ │ ID записи: {record_id_str:<40} │") lines.append("└" + "─" * 80 + "┘") logger.info("\n" + "\n".join(lines)) return created_tasks except Exception as e: logger.error(f"[SYNC-TASK-CREATE] Ошибка создания задач: {e}") return [] def sync_task_after_insert(mapper, connection, target): """Создает гибридные задачи синхронизации после создания записи""" priority_map = { 'component': 2, # Новые компоненты - высокий приоритет 'recipe': 2, # Новые рецепты - высокий приоритет 'ingredient': 3, # Ингредиенты - средний приоритет 'unloading_group': 4, # Группы разгрузки - низкий приоритет 'loading_report': 4, # Отчеты загрузки - низкий приоритет 'unloading_report': 4, # Отчеты разгрузки - низкий приоритет 'feed_mixer': 3, # Миксеры - средний приоритет 'feeding_location': 3, # Места кормления - средний приоритет 'feeding_period': 3, # Периоды кормления - средний приоритет 'feeding_point': 3, # Точки кормления - средний приоритет 'trip': 4, # Рейсы - низкий приоритет 'feed_dispenser': 3, # Кормораздатчики - средний приоритет 'period_recipes': 3, # Рецепты периодов - средний приоритет } priority = priority_map.get(target.__tablename__, 3) if target.__tablename__ == 'period_recipes': record_id = f"{target.period_id}:{target.recipe_id}" else: record_id = target.id create_sync_task_hybrid(target.__tablename__, record_id, 'create', priority) def sync_task_after_update(mapper, connection, target): """Создает гибридные задачи синхронизации после изменения записи""" priority_map = { 'component': 3, # Изменения компонентов - средний приоритет 'recipe': 3, # Изменения рецептов - средний приоритет 'ingredient': 4, # Изменения ингредиентов - низкий приоритет 'unloading_group': 4, # Изменения групп разгрузки - низкий приоритет 'loading_report': 4, # Изменения отчетов загрузки - низкий приоритет 'unloading_report': 4, # Изменения отчетов разгрузки - низкий приоритет 'feed_mixer': 4, # Изменения миксеров - низкий приоритет 'feeding_location': 4, # Изменения мест кормления - низкий приоритет 'feeding_period': 4, # Изменения периодов кормления - низкий приоритет 'feeding_point': 4, # Изменения точек кормления - низкий приоритет 'trip': 4, # Изменения рейсов - низкий приоритет 'feed_dispenser': 4, # Изменения кормораздатчиков - низкий приоритет 'period_recipes': 4, # Изменения рецептов периодов - низкий приоритет } priority = priority_map.get(target.__tablename__, 4) if target.__tablename__ == 'period_recipes': record_id = f"{target.period_id}:{target.recipe_id}" else: record_id = target.id logger.debug(f"[SYNC-AFTER-UPDATE] Событие after_update: {target.__tablename__}.{record_id[:8] if record_id else 'N/A'}... - update") create_sync_task_hybrid(target.__tablename__, record_id, 'update', priority) sync_models = [ Component, Recipe, Ingredient, UnloadingGroup, LoadingReport, ComponentLoadingTime, UnloadingReport, UnloadingReportGroup, FeedMixer, FeedingLocation, FeedingPeriod, FeedingPoint, Trip, FeedDispenser, PeriodRecipe ] for model in sync_models: event.listen(model, 'after_insert', sync_task_after_insert) event.listen(model, 'after_update', sync_task_after_update) import hashlib import json def calculate_content_hash(obj_data): """Вычисляет хеш содержимого объекта для проверки целостности""" sorted_data = json.dumps(obj_data, sort_keys=True, ensure_ascii=False) return hashlib.sha256(sorted_data.encode('utf-8')).hexdigest() from datetime import datetime def _to_iso(value): """Безопасно преобразует значение даты/времени к ISO-строке. Поддерживает None, str, datetime и прочие сериализуемые типы. """ if value is None: return None if isinstance(value, str): return value try: return value.isoformat() except Exception: try: return str(value) except Exception: return None def get_object_data(obj): """Получает данные объекта для хеширования""" base_data = {} try: if hasattr(obj, 'id') and getattr(obj, 'id'): base_data['id'] = obj.id if hasattr(obj, 'version'): base_data['version'] = obj.version if hasattr(obj, 'content_hash'): base_data['content_hash'] = obj.content_hash if hasattr(obj, 'created_at'): base_data['created_at'] = _to_iso(getattr(obj, 'created_at', None)) if hasattr(obj, 'updated_at'): base_data['updated_at'] = _to_iso(getattr(obj, 'updated_at', None)) if hasattr(obj, 'created_by'): base_data['created_by'] = getattr(obj, 'created_by', None) if hasattr(obj, 'updated_by'): base_data['updated_by'] = getattr(obj, 'updated_by', None) except Exception: pass if hasattr(obj, 'is_deleted'): base_data.update({ 'is_deleted': obj.is_deleted, 'deleted_at': _to_iso(getattr(obj, 'deleted_at', None)), 'deleted_by': getattr(obj, 'deleted_by', None), 'deleted_reason': getattr(obj, 'deleted_reason', None), 'restored_at': _to_iso(getattr(obj, 'restored_at', None)), 'restored_by': getattr(obj, 'restored_by', None), 'restored_reason': getattr(obj, 'restored_reason', None), 'delete_restore_count': getattr(obj, 'delete_restore_count', 0) }) if isinstance(obj, Component): base_data.update({ 'name': obj.name, 'type': obj.type, 'is_active': obj.is_active, 'dry_matter': obj.dry_matter, 'protein': obj.protein, 'energy': obj.energy, 'price': obj.price }) return base_data elif isinstance(obj, Recipe): base_data.update({ 'name': obj.name, 'heads_per_trip': obj.heads_per_trip, 'mixing_time': obj.mixing_time, 'trip_percent': obj.trip_percent, 'dry_matter_locked': obj.dry_matter_locked }) return base_data elif isinstance(obj, Ingredient): base_data.update({ 'name': obj.name, 'amount': obj.amount, 'weight_per_head': obj.weight_per_head if obj.weight_per_head is not None else 0, 'dry_matter': obj.dry_matter, 'order': obj.order, 'component_id': obj.component_id, 'recipe_id': obj.recipe_id # 🔧 ДОБАВЛЯЕМ recipe_id для синхронизации }) return base_data elif isinstance(obj, UnloadingGroup): base_data.update({ 'name': obj.name, 'distribution_type': obj.distribution_type, 'value': obj.value, 'weight': obj.weight if obj.weight is not None else 0, 'order': obj.order, 'recipe_id': obj.recipe_id # 🔧 ДОБАВЛЯЕМ recipe_id для синхронизации }) return base_data elif isinstance(obj, LoadingReport): base_data.update({ 'recipe_id': obj.recipe_id, 'recipe_name': obj.recipe_name, 'start_time': _to_iso(obj.start_time), 'end_time': _to_iso(obj.end_time), 'target_mixing_time': obj.target_mixing_time, 'actual_mixing_time': obj.actual_mixing_time, 'total_weight': obj.total_weight, 'dispenser_type': getattr(obj, 'dispenser_type', None) or 'dispenser' }) return base_data elif isinstance(obj, LoadingReportComponent): base_data.update({ 'report_id': obj.report_id, # 🔧 ДОБАВЛЯЕМ report_id для синхронизации 'component_id': getattr(obj, 'component_id', None), 'component_name': obj.component_name, 'target_weight': obj.target_weight, 'actual_weight': obj.actual_weight, 'overload': obj.overload, 'loading_order': obj.loading_order }) return base_data elif isinstance(obj, ComponentLoadingTime): base_data.update({ 'report_id': obj.report_id, # 🔧 ДОБАВЛЯЕМ report_id для синхронизации 'component_name': obj.component_name, 'start_time': _to_iso(obj.start_time), 'end_time': _to_iso(obj.end_time), 'loading_duration': obj.loading_duration, 'loading_order': obj.loading_order }) return base_data elif isinstance(obj, UnloadingReport): base_data.update({ 'recipe_id': obj.recipe_id, 'recipe_name': obj.recipe_name, 'loading_report_id': obj.loading_report_id, 'start_time': _to_iso(obj.start_time), 'end_time': _to_iso(obj.end_time), 'total_weight': obj.total_weight, 'total_unloaded_weight': obj.total_unloaded_weight, 'remaining_weight': obj.remaining_weight }) return base_data elif isinstance(obj, UnloadingReportGroup): base_data.update({ 'report_id': obj.report_id, # 🔧 ДОБАВЛЯЕМ report_id для синхронизации 'name': obj.name, 'target_weight': obj.target_weight, 'unloaded_weight': obj.unloaded_weight, 'remaining_weight': obj.remaining_weight, 'distribution_type': obj.distribution_type, 'distribution_value': obj.distribution_value, 'order': obj.order }) return base_data elif isinstance(obj, FeedDispenser): base_data.update({ 'name': obj.name, 'farm': obj.farm, 'operator': obj.operator, 'type': obj.type if hasattr(obj, 'type') and obj.type else 'dispenser', 'is_active': obj.is_active }) return base_data elif isinstance(obj, FeedMixer): base_data.update({ 'name': obj.name, 'model': obj.model, 'gosnomer': obj.gosnomer, 'operator': obj.operator, 'ip_address': obj.ip_address, 'is_active': obj.is_active }) return base_data elif isinstance(obj, FeedingPeriod): base_data.update({ 'name': obj.name, 'dispenser_id': obj.dispenser_id, 'is_active': obj.is_active }) return base_data elif isinstance(obj, Trip): base_data.update({ 'mixer_id': obj.mixer_id, 'recipe_id': obj.recipe_id, 'daily_ration_percent': obj.daily_ration_percent, 'heads_count': obj.heads_count, 'mixing_time': obj.mixing_time, 'correction_percent': obj.correction_percent, 'status': obj.status, 'started_at': _to_iso(obj.started_at), 'completed_at': _to_iso(obj.completed_at) }) return base_data elif isinstance(obj, PeriodRecipe): try: if hasattr(obj, 'version'): base_data['version'] = obj.version if hasattr(obj, 'content_hash'): base_data['content_hash'] = obj.content_hash if hasattr(obj, 'created_at'): base_data['created_at'] = _to_iso(getattr(obj, 'created_at', None)) if hasattr(obj, 'updated_at'): base_data['updated_at'] = _to_iso(getattr(obj, 'updated_at', None)) if hasattr(obj, 'created_by'): base_data['created_by'] = getattr(obj, 'created_by', None) if hasattr(obj, 'updated_by'): base_data['updated_by'] = getattr(obj, 'updated_by', None) except Exception: pass base_data.update({ 'period_id': obj.period_id, 'recipe_id': obj.recipe_id, 'order': obj.order }) return base_data elif isinstance(obj, ComponentLoadingTime): base_data.update({ 'component_name': obj.component_name, 'start_time': _to_iso(obj.start_time), 'end_time': _to_iso(obj.end_time), 'loading_duration': obj.loading_duration, 'loading_order': obj.loading_order }) return base_data elif isinstance(obj, FeedingLocation): base_data.update({ 'name': obj.name, 'mixer_id': obj.mixer_id, 'is_active': obj.is_active }) return base_data elif isinstance(obj, FeedingPoint): base_data.update({ 'name': obj.name, 'mixer_id': obj.mixer_id, 'location_id': obj.location_id, 'period_id': obj.period_id, 'is_active': obj.is_active }) return base_data return base_data def update_content_hash(obj): """Обновляет хеш содержимого объекта""" try: obj_data = get_object_data(obj) obj.content_hash = calculate_content_hash(obj_data) logger.debug(f"[CONTENT-HASH] Обновлен хеш для {type(obj).__name__}.{get_record_key(obj)[:8] if get_record_key(obj) else 'N/A'}...") except Exception as e: logger.warning(f"[CONTENT-HASH] Ошибка обновления хеша для {type(obj).__name__}: {e}") raise def migrate_to_uuid(): """Миграция существующих данных на UUID""" # print("Начинаем миграцию на UUID...") with app.app_context(): try: db.create_all() # print("Новые таблицы созданы") migrate_components() migrate_recipes() migrate_reports() # print("Миграция на UUID завершена успешно!") except Exception as e: # print(f"Ошибка миграции: {e}") db.session.rollback() def migrate_components(): """Миграция компонентов на UUID""" # print("Мигрируем компоненты...") old_components = db.session.execute("SELECT * FROM component").fetchall() for old_comp in old_components: new_comp = Component( id=str(uuid.uuid4()), name=old_comp.name, type=old_comp.type, is_active=old_comp.is_active, dry_matter=old_comp.dry_matter, protein=old_comp.protein, energy=old_comp.energy, price=old_comp.price, created_by='migration', updated_by='migration' ) update_content_hash(new_comp) db.session.add(new_comp) db.session.commit() # print(f"Мигрировано {len(old_components)} компонентов") def migrate_recipes(): """Миграция рецептов на UUID""" # print("Мигрируем рецепты...") old_recipes = db.session.execute("SELECT * FROM recipe").fetchall() for old_recipe in old_recipes: new_recipe = Recipe( id=str(uuid.uuid4()), name=old_recipe.name, heads_per_trip=old_recipe.heads_per_trip, mixing_time=old_recipe.mixing_time, trip_percent=old_recipe.trip_percent, created_by='migration', updated_by='migration' ) update_content_hash(new_recipe) db.session.add(new_recipe) db.session.flush() migrate_ingredients(old_recipe.id, new_recipe.id) migrate_unloading_groups(old_recipe.id, new_recipe.id) db.session.commit() # print(f"Мигрировано {len(old_recipes)} рецептов") def migrate_ingredients(old_recipe_id, new_recipe_id): """Миграция ингредиентов на UUID""" old_ingredients = db.session.execute( "SELECT * FROM ingredient WHERE recipe_id = ?", (old_recipe_id,) ).fetchall() for old_ing in old_ingredients: new_ing = Ingredient( id=str(uuid.uuid4()), name=old_ing.name, amount=old_ing.amount, weight_per_head=old_ing.weight_per_head, dry_matter=old_ing.dry_matter, order=old_ing.order, recipe_id=new_recipe_id, component_id=None, # Будет обновлено позже created_by='migration', updated_by='migration' ) update_content_hash(new_ing) db.session.add(new_ing) def migrate_unloading_groups(old_recipe_id, new_recipe_id): """Миграция групп выгрузки на UUID""" old_groups = db.session.execute( "SELECT * FROM unloading_group WHERE recipe_id = ?", (old_recipe_id,) ).fetchall() for old_group in old_groups: new_group = UnloadingGroup( id=str(uuid.uuid4()), name=old_group.name, distribution_type=old_group.distribution_type, value=old_group.value, weight=old_group.weight, order=old_group.order, recipe_id=new_recipe_id, created_by='migration', updated_by='migration' ) update_content_hash(new_group) db.session.add(new_group) def migrate_reports(): """Миграция отчетов на UUID""" # print("Мигрируем отчеты...") old_reports = db.session.execute("SELECT * FROM loading_report").fetchall() for old_report in old_reports: new_report = LoadingReport( id=str(uuid.uuid4()), recipe_id=old_report.recipe_id, # Пока оставляем старый ID recipe_name=old_report.recipe_name, start_time=old_report.start_time, end_time=old_report.end_time, target_mixing_time=old_report.target_mixing_time, actual_mixing_time=old_report.actual_mixing_time, total_weight=old_report.total_weight, created_by='migration', updated_by='migration' ) update_content_hash(new_report) db.session.add(new_report) db.session.flush() migrate_report_components(old_report.id, new_report.id) db.session.commit() # print(f"Мигрировано {len(old_reports)} отчетов") def migrate_report_components(old_report_id, new_report_id): """Миграция компонентов отчетов на UUID""" old_components = db.session.execute( "SELECT * FROM loading_report_component WHERE report_id = ?", (old_report_id,) ).fetchall() for old_comp in old_components: new_comp = LoadingReportComponent( id=str(uuid.uuid4()), report_id=new_report_id, component_id=getattr(old_comp, 'component_id', None), component_name=old_comp.component_name, target_weight=old_comp.target_weight, actual_weight=old_comp.actual_weight, overload=old_comp.overload, loading_order=old_comp.loading_order, created_by='migration', updated_by='migration' ) update_content_hash(new_comp) db.session.add(new_comp) running = True weight_data = { "raw_samples": [], "current_readings": [], "weight_history": [] } initial_weight_raw = None calibration_factor = 1.0 calibration_target_weight = None calibration_initial_raw = None simulated_raw_value = 0.0 _read_weight_thread_started = False _read_weight_thread_lock = threading.Lock() def start_read_weight_thread(): global _read_weight_thread_started with _read_weight_thread_lock: if _read_weight_thread_started: return False reading_thread = threading.Thread(target=read_weight, daemon=True) reading_thread.start() _read_weight_thread_started = True return True current_recipe_id = None current_loading_component = None current_component_index = 0 component_reset_flag = False component_reset_index = -1 weight_at_current_component_start = 0.0 is_mixing_mode = False mixing_timer_active = False navigation_commands_queue = [] def clear_recipe_state(): """Очищает все состояния, связанные с текущим рецептом""" global current_recipe_id, current_loading_component, current_component_index global component_reset_flag, component_reset_index, is_mixing_mode, mixing_timer_active global navigation_commands_queue current_recipe_id = None current_loading_component = None current_component_index = 0 component_reset_flag = False component_reset_index = -1 is_mixing_mode = False mixing_timer_active = False navigation_commands_queue.clear() if hasattr(api_navigate_component, '_total_ingredients'): delattr(api_navigate_component, '_total_ingredients') if hasattr(api_navigate_component, '_recipe_id'): delattr(api_navigate_component, '_recipe_id') def get_active_recipe_by_id(recipe_id): """Возвращает рецепт, если он не помечен на мягкое удаление.""" if recipe_id is None: return None return get_active_objects(Recipe, id=recipe_id).first() def _normalize_name(s): """Нормализация имени для сравнения: strip, lower, слияние пробелов.""" if s is None: return "" return " ".join(str(s).strip().lower().split()) def _resolve_component_id_from_recipe(recipe_id, component_name): """Находит component_id по рецепту и component_name. ORM — для save_report.""" if not component_name or not recipe_id: return None name_norm = _normalize_name(component_name) if not name_norm: return None q_ing = Ingredient.query.filter_by(recipe_id=recipe_id) if hasattr(Ingredient, "is_deleted"): q_ing = q_ing.filter(db.or_(Ingredient.is_deleted.is_(None), Ingredient.is_deleted == False)) for ing in q_ing.all(): if _normalize_name(ing.name) != name_norm and _normalize_name(ing.component.name if ing.component else None) != name_norm: continue if ing.component_id: return str(ing.component_id) qc = Component.query if hasattr(Component, "is_deleted"): qc = qc.filter(db.or_(Component.is_deleted.is_(None), Component.is_deleted == False)) for c in qc.all(): if _normalize_name(c.name) == name_norm: return str(c.id) return None def _resolve_component_id_sql(engine_recipes, recipe_id, component_name): """Находит component_id в recipes.db через raw SQL. Сопоставление: ingredient.name или component.name = component_name.""" if not component_name or not recipe_id: return None name_norm = _normalize_name(component_name) if not name_norm: return None from sqlalchemy import text with engine_recipes.connect() as conn: ing_rows = conn.execute(text(""" SELECT component_id, name FROM ingredient WHERE recipe_id = :rid """), {"rid": recipe_id}).fetchall() for (cid, ing_name) in ing_rows: if _normalize_name(ing_name) == name_norm: if cid: return str(cid) for (comp_id, comp_name) in conn.execute(text("SELECT id, name FROM component")).fetchall(): if _normalize_name(comp_name) == name_norm: return str(comp_id) return None if cid: comp_row = conn.execute(text("SELECT name FROM component WHERE id = :cid"), {"cid": cid}).fetchone() if comp_row and _normalize_name(comp_row[0]) == name_norm: return str(cid) for (comp_id, comp_name) in conn.execute(text("SELECT id, name FROM component")).fetchall(): if _normalize_name(comp_name) == name_norm: return str(comp_id) return None def init_db(): if not os.path.exists(DATABASE_PATH): with app.app_context(): db.create_all() # print("Основная база данных создана") init_sync_metadata() else: # print("Используется существующая основная база данных") pass with app.app_context(): try: from sqlalchemy import text result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='component'")).fetchone() if result: columns = db.session.execute(text("PRAGMA table_info(component)")).fetchall() has_uuid = any(col[1] == 'id' and 'VARCHAR' in col[2] for col in columns) if not has_uuid: # print("Обнаружены старые таблицы, запускаем миграцию на UUID...") migrate_to_uuid() except Exception as e: # print(f"Ошибка проверки миграции: {e}") pass # Миграция: добавить dry_matter_locked в recipe (если еще нет) try: from sqlalchemy import text result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='recipe'")).fetchone() if result: columns = db.session.execute(text("PRAGMA table_info(recipe)")).fetchall() has_dry_matter_locked = any(col[1] == 'dry_matter_locked' for col in columns) if not has_dry_matter_locked: print("Обнаружена старая таблица recipe, добавляем колонку dry_matter_locked...") db.session.execute(text("ALTER TABLE recipe ADD COLUMN dry_matter_locked INTEGER NOT NULL DEFAULT 0;")) db.session.commit() print("Колонка dry_matter_locked успешно добавлена") try: logger.info("🗄️ [DB-MIGRATION] recipe: колонка dry_matter_locked успешно добавлена") except Exception: pass except Exception as e: print(f"Ошибка миграции dry_matter_locked: {e}") try: logger.error(f"🗄️ [DB-MIGRATION] Ошибка миграции dry_matter_locked: {e}", exc_info=True) except Exception: pass # Миграция: добавить dry_matter_per_head в ingredient (если еще нет) try: from sqlalchemy import text result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='ingredient'")).fetchone() if result: columns = db.session.execute(text("PRAGMA table_info(ingredient)")).fetchall() has_dry_matter_per_head = any(col[1] == 'dry_matter_per_head' for col in columns) if not has_dry_matter_per_head: print("Обнаружена старая таблица ingredient, добавляем колонку dry_matter_per_head...") db.session.execute(text("ALTER TABLE ingredient ADD COLUMN dry_matter_per_head REAL;")) db.session.commit() print("Колонка dry_matter_per_head успешно добавлена") try: logger.info("🗄️ [DB-MIGRATION] ingredient: колонка dry_matter_per_head успешно добавлена") except Exception: pass except Exception as e: print(f"Ошибка миграции dry_matter_per_head: {e}") db.session.rollback() try: logger.error(f"🗄️ [DB-MIGRATION] Ошибка миграции dry_matter_per_head: {e}", exc_info=True) except Exception: pass # Миграция: добавить type в feed_dispenser (если еще нет) try: from sqlalchemy import text result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='feed_dispenser'")).fetchone() if result: columns = db.session.execute(text("PRAGMA table_info(feed_dispenser)")).fetchall() has_type = any(col[1] == 'type' for col in columns) if not has_type: print("Обнаружена старая таблица feed_dispenser, добавляем колонку type...") db.session.execute(text("ALTER TABLE feed_dispenser ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'dispenser';")) db.session.commit() print("Колонка type успешно добавлена в feed_dispenser") try: logger.info("🗄️ [DB-MIGRATION] feed_dispenser: колонка type успешно добавлена") except Exception: pass except Exception as e: print(f"Ошибка миграции type в feed_dispenser: {e}") db.session.rollback() try: logger.error(f"🗄️ [DB-MIGRATION] Ошибка миграции type в feed_dispenser: {e}", exc_info=True) except Exception: pass # Миграция: добавить target_component_id в recipe (если еще нет) try: from sqlalchemy import text result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='recipe'")).fetchone() if result: columns = db.session.execute(text("PRAGMA table_info(recipe)")).fetchall() has_target_component_id = any(col[1] == 'target_component_id' for col in columns) if not has_target_component_id: print("Обнаружена старая таблица recipe, добавляем колонку target_component_id...") # В SQLite нельзя добавить FOREIGN KEY через ALTER TABLE, поэтому просто добавляем колонку # Связь будет поддерживаться на уровне SQLAlchemy db.session.execute(text("ALTER TABLE recipe ADD COLUMN target_component_id VARCHAR(36);")) db.session.commit() print("Колонка target_component_id успешно добавлена в recipe") try: logger.info("🗄️ [DB-MIGRATION] recipe: колонка target_component_id успешно добавлена") except Exception: pass except Exception as e: print(f"Ошибка миграции target_component_id в recipe: {e}") db.session.rollback() try: logger.error(f"🗄️ [DB-MIGRATION] Ошибка миграции target_component_id в recipe: {e}", exc_info=True) except Exception: pass # Миграция: добавить unloading_link_broken в recipe (если еще нет) try: from sqlalchemy import text result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='recipe'")).fetchone() if result: columns = db.session.execute(text("PRAGMA table_info(recipe)")).fetchall() has_unloading_link_broken = any(col[1] == 'unloading_link_broken' for col in columns) if not has_unloading_link_broken: print("Обнаружена старая таблица recipe, добавляем колонку unloading_link_broken...") db.session.execute(text("ALTER TABLE recipe ADD COLUMN unloading_link_broken INTEGER NOT NULL DEFAULT 0;")) db.session.commit() print("Колонка unloading_link_broken успешно добавлена в recipe") try: logger.info("🗄️ [DB-MIGRATION] recipe: колонка unloading_link_broken успешно добавлена") except Exception: pass except Exception as e: print(f"Ошибка миграции unloading_link_broken в recipe: {e}") db.session.rollback() try: logger.error(f"🗄️ [DB-MIGRATION] Ошибка миграции unloading_link_broken в recipe: {e}", exc_info=True) except Exception: pass try: init_sync_metadata() except Exception as e: # print(f"Ошибка инициализации метаданных для существующей базы: {e}") pass try: ensure_sync_client_display_name_table() except Exception as e: pass if not os.path.exists(REPORTS_DATABASE_PATH): with app.app_context(): from sqlalchemy import MetaData metadata = MetaData() try: db.create_all() except TypeError: pass # print("База данных отчетов создана") init_sync_metadata(bind='reports') else: # print("Используется существующая база данных отчетов") pass with app.app_context(): try: init_sync_metadata(bind='reports') except Exception as e: # print(f"Ошибка инициализации метаданных для существующей базы отчетов: {e}") pass # Миграция: добавить component_id в loading_report_component (reports.db) try: from sqlalchemy import text engine = db.engines['reports'] with engine.connect() as conn: r = conn.execute(text( "SELECT name FROM sqlite_master WHERE type='table' AND name='loading_report_component'" )).fetchone() if r: cols = conn.execute(text("PRAGMA table_info(loading_report_component)")).fetchall() has_component_id = any(c[1] == 'component_id' for c in cols) if not has_component_id: conn.execute(text("ALTER TABLE loading_report_component ADD COLUMN component_id VARCHAR(36);")) conn.commit() print("loading_report_component: добавлена колонка component_id") except Exception as e: print(f"Ошибка миграции component_id в loading_report_component: {e}") # Миграция: перенести component_id из recipes.db в reports.db # 1) reports.db: строки с пустым component_id; 2) recipes.db: ищем id по ingredient/component; 3) reports.db: UPDATE try: from sqlalchemy import text engine_reports = db.engines['reports'] engine_recipes = db.engine with engine_reports.connect() as conn_r: rows = conn_r.execute(text(""" SELECT lrc.id, lrc.component_name, lr.recipe_id FROM loading_report_component lrc JOIN loading_report lr ON lr.id = lrc.report_id WHERE (lrc.component_id IS NULL OR lrc.component_id = '') """)).fetchall() if rows: updated = 0 for row in rows: lrc_id, component_name, recipe_id = row[0], row[1], row[2] cid = _resolve_component_id_sql(engine_recipes, recipe_id, component_name) if cid: with engine_reports.connect() as conn_w: conn_w.execute(text( "UPDATE loading_report_component SET component_id = :cid WHERE id = :lid" ), {"cid": cid, "lid": lrc_id}) conn_w.commit() updated += 1 if updated > 0: print(f"loading_report_component: перенесено component_id из recipes.db для {updated} строк") except Exception as e: import traceback print(f"Ошибка миграции backfill component_id: {e}") traceback.print_exc() # Миграция: добавить dispenser_type в loading_report (reports.db) try: from sqlalchemy import text engine = db.engines['reports'] with engine.connect() as conn: r = conn.execute(text( "SELECT name FROM sqlite_master WHERE type='table' AND name='loading_report'" )).fetchone() if r: cols = conn.execute(text("PRAGMA table_info(loading_report)")).fetchall() has_dispenser_type = any(c[1] == 'dispenser_type' for c in cols) if not has_dispenser_type: conn.execute(text( "ALTER TABLE loading_report ADD COLUMN dispenser_type VARCHAR(20) NOT NULL DEFAULT 'dispenser';" )) conn.commit() print("loading_report: добавлена колонка dispenser_type") except Exception as e: print(f"Ошибка миграции dispenser_type в loading_report: {e}") with app.app_context(): init_db() setup_sqlite_pragma() # Настройка SQLite PRAGMA после создания БД def led_on(): if GPIO_AVAILABLE: GPIO.output(LED_PIN, GPIO.HIGH) def led_off(): if GPIO_AVAILABLE: GPIO.output(LED_PIN, GPIO.LOW) def led_blink(times=1, delay=0.5): if not SIMULATION_MODE and GPIO_AVAILABLE: for _ in range(times): led_on() time.sleep(delay) led_off() time.sleep(delay) led_on() def load_calibration_factor(): global calibration_factor try: if os.path.exists(CALIBRATION_FILE): with open(CALIBRATION_FILE, 'r') as f: data = json.load(f) calibration_factor = data.get('calibration_factor', 1.0) except Exception as e: print(f"Ошибка загрузки калибровки: {str(e)}") def load_initial_weight(): global initial_weight_raw try: if os.path.exists(INITIAL_WEIGHT_FILE): with open(INITIAL_WEIGHT_FILE, 'r') as f: data = json.load(f) initial_weight_raw = data.get('initial_average_raw') except Exception as e: print(f"Ошибка загрузки нуля: {str(e)}") def save_initial_weight(): try: if not weight_data["current_readings"]: return None initial_avg_raw = sum(weight_data["current_readings"][-MOVING_AVERAGE_WINDOW:]) / MOVING_AVERAGE_WINDOW with open(INITIAL_WEIGHT_FILE, 'w') as f: json.dump({'initial_average_raw': initial_avg_raw}, f) return initial_avg_raw except Exception as e: print(f"Ошибка сохранения нуля: {str(e)}") return None def read_weight(): global running, initial_weight_raw, simulated_raw_value, calibration_factor while running: try: if SIMULATION_MODE: if initial_weight_raw is None: initial_weight_raw = 0.0 # Начальное значение для симуляции simulated_raw_value = 0.0 increment_per_step = (5 * calibration_factor * 0.05) if calibration_factor != 0 else 0.5 simulated_raw_value += increment_per_step current_reading = simulated_raw_value weight_data["current_readings"].append(current_reading) if len(weight_data["current_readings"]) > SAMPLE_HISTORY_SIZE: weight_data["current_readings"].pop(0) else: raw_samples = hx.get_raw_data(times=SAMPLES_PER_READ) if isinstance(raw_samples, list): weight_data["raw_samples"].extend(raw_samples) current_reading = sum(raw_samples) / SAMPLES_PER_READ weight_data["current_readings"].append(current_reading) if len(weight_data["current_readings"]) > SAMPLE_HISTORY_SIZE: weight_data["current_readings"].pop(0) if initial_weight_raw is None: initial_weight_raw = current_reading time.sleep(READ_INTERVAL) # Задержка чтения зависит от режима except Exception as e: print(f"Ошибка чтения: {str(e)}") time.sleep(1) def median_filter(data, window_size): """Реализация медианного фильтра для сглаживания данных.""" filtered = [] half_window = window_size // 2 for i in range(len(data)): window_start = max(0, i - half_window) window_end = min(len(data), i + half_window + 1) window = sorted(data[window_start:window_end]) median = window[len(window) // 2] filtered.append(median) return filtered def calculate_current_weight(): if not weight_data["current_readings"] or initial_weight_raw is None: return 0.0 MEDIAN_WINDOW = 6 # Размер окна для медианного фильтра AVG_WINDOW = 4 # Размер окна для скользящего среднего MIN_SAMPLES = 10 # Минимальное количество образцов для обработки readings = weight_data["current_readings"][-MEDIAN_WINDOW*2:] if len(readings) < MIN_SAMPLES: last_weight = weight_data["weight_history"][-1] if weight_data["weight_history"] else 0.0 return last_weight med_filtered = median_filter(readings, MEDIAN_WINDOW) filtered_for_avg = med_filtered[-AVG_WINDOW:] weights = [0.2, 0.3, 0.5] # Весовые коэффициенты для AVG_WINDOW=3 weighted_sum = sum(v * w for v, w in zip(filtered_for_avg, weights)) avg_raw = weighted_sum / sum(weights[:len(filtered_for_avg)]) calculated_weight = (avg_raw - initial_weight_raw) / calibration_factor if weight_data["weight_history"]: smoothing_factor = 0.3 # Коэффициент сглаживания (0-1) calculated_weight = (calculated_weight * (1 - smoothing_factor) + weight_data["weight_history"][-1] * smoothing_factor) weight_data["weight_history"].append(calculated_weight) if len(weight_data["weight_history"]) > SAMPLE_HISTORY_SIZE: weight_data["weight_history"].pop(0) return round(calculated_weight / 5) * 5 # Округляем до кратного 5 кг @app.route('/api/components', methods=['GET']) def get_components(): components = get_active_objects(Component, is_active=True).all() response = jsonify([{ 'id': c.id, 'name': c.name, 'type': c.type, 'dryMatter': c.dry_matter, 'protein': c.protein, 'energy': c.energy, 'price': c.price, 'version': c.version, 'created_at': c.created_at.isoformat() if c.created_at else None, 'updated_at': c.updated_at.isoformat() if c.updated_at else None, 'created_by': c.created_by, 'updated_by': c.updated_by, 'content_hash': c.content_hash } for c in components]) return add_no_cache_headers(response) @app.route('/api/components/', methods=['GET']) def get_component(id): component = Component.query.get_or_404(id) return jsonify({ 'id': component.id, 'name': component.name, 'type': component.type, 'is_active': component.is_active, 'dryMatter': component.dry_matter, 'protein': component.protein, 'energy': component.energy, 'price': component.price, 'version': component.version, 'created_at': component.created_at.isoformat() if component.created_at else None, 'updated_at': component.updated_at.isoformat() if component.updated_at else None, 'created_by': component.created_by, 'updated_by': component.updated_by }) @app.route('/api/components', methods=['POST']) def create_component(): data = request.json component = Component( name=data['name'], type=data.get('type', ''), is_active=data.get('is_active', True), dry_matter=data.get('dry_matter', 0), protein=data.get('protein', 0), energy=data.get('energy', 0), price=data.get('price', 0), created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(component) db.session.flush() update_content_hash(component) db.session.commit() process_pending_sync_tasks() return jsonify({'success': True, 'message': 'Компонент успешно добавлен', 'id': component.id}) def _collect_component_dry_matter_updates_from_ingredients_payload(ingredients_payload): """ Собирает изменения dry_matter по компонентам из payload ингредиентов. Ожидает список dict с ключами component_id и dry_matter (snake_case) либо dryMatter (camelCase). """ updates = {} if not ingredients_payload: return updates for ing in ingredients_payload: if not isinstance(ing, dict): continue component_id = ing.get('component_id') or ing.get('componentId') if not component_id: continue has_dm = ('dry_matter' in ing) or ('dryMatter' in ing) if not has_dm: continue dm_raw = ing.get('dry_matter', ing.get('dryMatter')) if dm_raw is None: continue try: dm = float(dm_raw) except Exception: continue cid = str(component_id) if cid in updates and abs(updates[cid] - dm) > 1e-6: raise ValueError(f"Конфликт dry_matter для компонента {cid}: {updates[cid]} vs {dm}") updates[cid] = dm return updates def _apply_component_dry_matter_updates_and_recalculate_recipes( component_new_dm_map, component_old_dm_map, *, skip_recipe_ids=None ): """ Применяет новые значения Component.dry_matter и пересчитывает веса ВСЕХ ингредиентов во ВСЕХ рецептах, где встречаются измененные компоненты. Инвариант пересчета: для каждого ингредиента сохраняется сухое вещество на голову (кг), то есть DM_per_head = weight_per_head_old * (old_dm_percent / 100). После пересчета обновляются: - Ingredient.weight_per_head - Ingredient.amount (вес/рейс) - Ingredient.dry_matter (приводится к текущему Component.dry_matter, если он > 0) - UnloadingGroup.weight (в кг) - версии/хэши и updated_at/updated_by """ stats = { 'changed_components': 0, 'affected_recipes': 0, 'recalculated_recipes': 0, 'updated_ingredients': 0, 'updated_unloading_groups': 0, 'skipped_recipes': 0 } if not component_new_dm_map: return stats skip_recipe_ids = set(skip_recipe_ids or []) # 1) Обновляем компоненты (с сохранением old_dm в component_old_dm_map) changed_component_ids = [] for cid, new_dm in component_new_dm_map.items(): component = db.session.get(Component, str(cid)) if not component: continue # сохраняем old dm, если не задано снаружи if str(cid) not in component_old_dm_map: component_old_dm_map[str(cid)] = component.dry_matter # запрещаем некорректные значения, иначе пересчет невозможен try: new_dm_float = float(new_dm) except Exception: continue if new_dm_float <= 0: raise ValueError(f"dry_matter для компонента {cid} должен быть > 0") if abs((component.dry_matter or 0) - new_dm_float) > 1e-6: component.dry_matter = new_dm_float component.updated_by = 'system' component.updated_at = moscow_now() component.version += 1 update_content_hash(component) changed_component_ids.append(str(cid)) if not changed_component_ids: return stats stats['changed_components'] = len(changed_component_ids) # 2) Находим все рецепты, где есть хотя бы один из измененных компонентов affected_recipe_ids = set() affected_ingredients = get_active_objects(Ingredient).filter(Ingredient.component_id.in_(changed_component_ids)).all() for ing in affected_ingredients: if ing.recipe_id: affected_recipe_ids.add(ing.recipe_id) stats['affected_recipes'] = len(affected_recipe_ids) # 3) Пересчитываем каждый рецепт "от СВ" (с сохранением DM/гол как константы) for recipe_id in affected_recipe_ids: if recipe_id in skip_recipe_ids: stats['skipped_recipes'] += 1 continue recipe = db.session.get(Recipe, recipe_id) if not recipe: continue if hasattr(recipe, 'is_deleted') and recipe.is_deleted: continue recipe_ingredients = sorted( get_active_objects(Ingredient, recipe_id=recipe_id).all(), key=lambda x: x.order ) if not recipe_ingredients: continue unloading_groups = sorted( get_active_objects(UnloadingGroup, recipe_id=recipe_id).all(), key=lambda x: x.order ) # Проверяем, находится ли рецепт в режиме "замок СВ" recipe_is_locked = bool(getattr(recipe, 'dry_matter_locked', False)) # Сохраняем исходные значения dry_matter_per_head из БД для рецептов в режиме "замок СВ" # Это необходимо, чтобы константа не была перезаписана при пересчете original_dm_per_head_map = {} if recipe_is_locked: for ing in recipe_ingredients: if hasattr(ing, 'dry_matter_per_head') and ing.dry_matter_per_head is not None and ing.dry_matter_per_head > 0: original_dm_per_head_map[ing.id] = float(ing.dry_matter_per_head) # текущая карта dry_matter по компонентам (после обновления) comp_ids = [i.component_id for i in recipe_ingredients if i.component_id] components = Component.query.filter(Component.id.in_(comp_ids)).all() if comp_ids else [] component_current_dm_map = {c.id: float(c.dry_matter or 0) for c in components} # карта "старых" dry_matter для константы DM/гол component_old_for_const = {} for cid2 in comp_ids: if cid2 in component_old_dm_map: component_old_for_const[cid2] = float(component_old_dm_map[cid2] or 0) else: component_old_for_const[cid2] = float(component_current_dm_map.get(cid2, 0) or 0) ingredients_data = [] for ing in recipe_ingredients: cid2 = ing.component_id if not cid2: ingredients_data.append({'component_id': None, 'dryMatterPerHead': 0, 'dryMatter': 0}) continue # для измененных компонентов используем old dm компонента, чтобы сохранить DM/гол old_dm_percent = component_old_for_const.get(cid2, 0) if (old_dm_percent or 0) <= 0: # fallback: если в ингредиенте есть валидное значение, используем его try: if ing.dry_matter and float(ing.dry_matter) > 0: old_dm_percent = float(ing.dry_matter) except Exception: pass wph_old = float(ing.weight_per_head or 0) # ПРИОРИТЕТ: берем dry_matter_per_head из БД, если поле существует и валидно # В режиме "замок СВ" используем сохраненное исходное значение if recipe_is_locked and ing.id in original_dm_per_head_map: dm_per_head = original_dm_per_head_map[ing.id] elif hasattr(ing, 'dry_matter_per_head') and ing.dry_matter_per_head is not None and ing.dry_matter_per_head > 0: dm_per_head = float(ing.dry_matter_per_head) else: # Fallback: вычисляем из старого weight_per_head и старого dry_matter dm_per_head = wph_old * (float(old_dm_percent or 0) / 100.0) # ЗАЩИТА: если old_dm_percent был 0, но есть вес на голову, # пытаемся восстановить dm_per_head из текущего dry_matter ингредиента или компонента if dm_per_head <= 0 and wph_old > 0: # Пробуем использовать текущее dry_matter из ингредиента try: ing_dm = float(ing.dry_matter or 0) if hasattr(ing, 'dry_matter') and ing.dry_matter else 0 if ing_dm > 0: dm_per_head = wph_old * (ing_dm / 100.0) else: # Если в ингредиенте нет, пробуем из компонента (текущее значение) comp_dm = float(component_current_dm_map.get(cid2, 0) or 0) if comp_dm > 0: dm_per_head = wph_old * (comp_dm / 100.0) except Exception: pass new_dm_percent = float(component_current_dm_map.get(cid2, 0) or 0) # если у компонента невалидный dm, держим веса как есть, чтобы не "обнулить" if new_dm_percent <= 0 and old_dm_percent > 0: new_dm_percent = float(old_dm_percent) # Дополнительная защита: если new_dm_percent все еще 0, но есть вес на голову, # используем dry_matter из ингредиента, чтобы не обнулить веса elif new_dm_percent <= 0 and wph_old > 0: try: ing_dm = float(ing.dry_matter or 0) if hasattr(ing, 'dry_matter') and ing.dry_matter else 0 if ing_dm > 0: new_dm_percent = ing_dm except Exception: pass ingredients_data.append({ 'component_id': cid2, 'dryMatterPerHead': dm_per_head, 'dryMatter': new_dm_percent }) unloading_groups_data = [{ 'distributionType': g.distribution_type, 'value': g.value } for g in unloading_groups] # Используем режим "замок СВ" только если рецепт в этом режиме result = calculate_recipe( ingredients=ingredients_data, heads_count=int(recipe.heads_per_trip or 0), trip_percent=float(recipe.trip_percent or 100), unloading_groups=unloading_groups_data, component_dry_matter_map=component_current_dm_map, calculate_from_dry_matter=recipe_is_locked ) # применяем пересчет к ингредиентам for ing, calc in zip(recipe_ingredients, result.get('ingredients', [])): cid2 = ing.component_id new_dm_percent = float(component_current_dm_map.get(cid2, 0) or 0) if cid2 else 0 if new_dm_percent > 0: ing.dry_matter = new_dm_percent calculated_wph = float(calc.get('weightPerHead', 0) or 0) calculated_amount = float(calc.get('tripWeight', 0) or 0) # ЗАЩИТА: если расчет дал 0, но был исходный вес, сохраняем исходные значения # Это может произойти, если dry_matter_percent был 0 при расчете wph_old = float(ing.weight_per_head or 0) amount_old = float(ing.amount or 0) if calculated_wph <= 0 and wph_old > 0: # Если расчет обнулил вес, но был исходный вес, сохраняем исходные значения # Это защита от потери данных при некорректных значениях dry_matter try: logger.warning( f"[RECIPE-RECALC] Защита от обнуления: ingredient_id={ing.id}, " f"component_id={cid2}, old_wph={wph_old}, calculated_wph={calculated_wph}, " f"new_dm_percent={new_dm_percent}, recipe_id={recipe_id}" ) except Exception: pass ing.weight_per_head = wph_old ing.amount = amount_old else: ing.weight_per_head = calculated_wph ing.amount = calculated_amount # В режиме "замок СВ" НЕ перезаписываем dry_matter_per_head из результата расчета # Константа должна сохраняться из БД if recipe_is_locked: # Сохраняем dry_matter_per_head из БД (константа) # Используем сохраненное исходное значение из original_dm_per_head_map if ing.id in original_dm_per_head_map: # Восстанавливаем исходное значение из БД ing.dry_matter_per_head = original_dm_per_head_map[ing.id] elif hasattr(ing, 'dry_matter_per_head') and ing.dry_matter_per_head is not None and ing.dry_matter_per_head > 0: # Константа уже сохранена, не перезаписываем pass else: # Если константа отсутствует, используем значение из расчета (fallback) calculated_dm_per_head = float(calc.get('dryMatterPerHead', 0) or 0) ing.dry_matter_per_head = calculated_dm_per_head if calculated_dm_per_head > 0 else None else: # Обычный режим: обновляем dry_matter_per_head из результата расчета calculated_dm_per_head = float(calc.get('dryMatterPerHead', 0) or 0) ing.dry_matter_per_head = calculated_dm_per_head if calculated_dm_per_head > 0 else None # Явно добавляем объект в сессию для отслеживания изменений db.session.add(ing) ing.updated_by = 'system' ing.updated_at = moscow_now() ing.version += 1 update_content_hash(ing) stats['updated_ingredients'] += 1 # применяем пересчет к группам выгрузки for g, g_calc in zip(unloading_groups, result.get('unloadingGroups', [])): g.weight = float(g_calc.get('calculatedWeight', 0) or 0) # Явно добавляем объект в сессию для отслеживания изменений db.session.add(g) g.updated_by = 'system' g.updated_at = moscow_now() g.version += 1 update_content_hash(g) stats['updated_unloading_groups'] += 1 # трогаем рецепт, чтобы синхронизация/клиенты увидели изменение db.session.add(recipe) recipe.updated_by = 'system' recipe.updated_at = moscow_now() recipe.version += 1 update_content_hash(recipe) stats['recalculated_recipes'] += 1 # Фиксируем все изменения в сессии перед commit в вызывающей функции db.session.flush() return stats @app.route('/api/components/', methods=['PUT']) def update_component(id): component = Component.query.get_or_404(id) data = request.json or {} # Сохраняем старое значение dry_matter для проверки изменений old_dry_matter = component.dry_matter requested_dry_matter = None if 'dry_matter' in data or 'dryMatter' in data: requested_dry_matter = data.get('dry_matter', data.get('dryMatter')) try: requested_dry_matter = float(requested_dry_matter) if requested_dry_matter is not None else None except Exception: return jsonify({'error': 'dry_matter должно быть числом'}), 400 # базовая валидация процента if requested_dry_matter is not None: if requested_dry_matter <= 0: return jsonify({'error': 'dry_matter должно быть > 0'}), 400 if requested_dry_matter > 100: return jsonify({'error': 'dry_matter не может быть больше 100%'}), 400 # Поддерживаем частичное обновление (например, только dry_matter из recipes.html) if 'name' in data: component.name = data.get('name') or component.name if 'type' in data: component.type = data.get('type', component.type) if 'is_active' in data: component.is_active = data.get('is_active', component.is_active) if 'protein' in data: component.protein = data.get('protein', component.protein) if 'energy' in data: component.energy = data.get('energy', component.energy) if 'price' in data: component.price = data.get('price', component.price) component.updated_by = 'system' component.updated_at = moscow_now() # Версию и content_hash обновляем ниже (зависит от того, изменился ли dry_matter) # Если изменился dry_matter, пересчитываем ВСЕ рецепты с этим компонентом if requested_dry_matter is not None and abs((old_dry_matter or 0) - requested_dry_matter) > 1e-6: stats = _apply_component_dry_matter_updates_and_recalculate_recipes( {str(id): requested_dry_matter}, {str(id): old_dry_matter}, skip_recipe_ids=set() ) # _apply_component_* уже обновляет component.dry_matter/version/content_hash/updated_at msg = ( f"Сохранено. " f"Компонентов: {stats.get('changed_components', 0)}, " f"пересчитано: {stats.get('recalculated_recipes', 0)} рейсов " f"и обновлено ингредиентов: {stats.get('updated_ingredients', 0)}." ) else: # dry_matter не меняли — обычное обновление компонента component.version += 1 update_content_hash(component) stats = { 'changed_components': 0, 'affected_recipes': 0, 'recalculated_recipes': 0, 'updated_ingredients': 0, 'updated_unloading_groups': 0, 'skipped_recipes': 0 } msg = ( "Сохранено. " "Компонентов: 0, пересчитано: 0 рейсов и обновлено ингредиентов: 0." ) # Все изменения фиксируем в одной транзакции # События after_update автоматически сработают для всех измененных объектов db.session.commit() # Создаем задачи синхронизации для всех измененных объектов process_pending_sync_tasks() return jsonify({'success': True, 'message': msg, 'stats': stats}) @app.route('/api/components/', methods=['DELETE']) def delete_component(id): try: component = Component.query.get_or_404(id) logger.info(f"🗑️ Удаление компонента: {component.name} (ID: {id})") component.soft_delete( deleted_by='system', reason='Удаление через API', request=request ) db.session.commit() process_pending_sync_tasks() logger.info(f"✅ Компонент успешно удален: {component.name} (ID: {id})") return jsonify({ 'success': True, 'message': 'Компонент успешно удален', 'deleted_at': component.deleted_at.isoformat() if component.deleted_at else None, 'deleted_by': component.deleted_by }) except Exception as e: db.session.rollback() logger.error(f"❌ Ошибка при удалении компонента {id}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/components') def components_page(): return send_from_directory(app.static_folder,'components.html') @app.route('/api/recipes', methods=['GET']) def get_recipes(): recipes = get_active_objects(Recipe).all() response = jsonify([{ 'id': r.id, 'name': r.name, 'headsPerTrip': r.heads_per_trip, 'mixingTime': r.mixing_time, 'tripPercent': r.trip_percent, 'dryMatterLocked': r.dry_matter_locked, 'dry_matter_locked': r.dry_matter_locked, 'target_component_id': r.target_component_id, # Для кормоцеха 'version': r.version, 'created_at': r.created_at.isoformat() if r.created_at else None, 'updated_at': r.updated_at.isoformat() if r.updated_at else None, 'created_by': r.created_by, 'updated_by': r.updated_by, 'content_hash': r.content_hash, 'ingredients': [{ 'id': i.id, 'name': i.name, 'weightPerHead': i.weight_per_head, 'amount': i.amount, 'dry_matter': i.dry_matter, 'component_id': i.component_id, 'version': i.version, 'created_at': i.created_at.isoformat() if i.created_at else None, 'updated_at': i.updated_at.isoformat() if i.updated_at else None, 'created_by': i.created_by, 'updated_by': i.updated_by, 'content_hash': i.content_hash } for i in get_active_objects(Ingredient, recipe_id=r.id).all()], 'unloadingGroups': [{ 'id': g.id, 'name': g.name, 'distributionType': g.distribution_type, 'value': g.value, 'weight': g.weight, 'order': g.order, 'version': g.version, 'created_at': g.created_at.isoformat() if g.created_at else None, 'updated_at': g.updated_at.isoformat() if g.updated_at else None, 'created_by': g.created_by, 'updated_by': g.updated_by, 'content_hash': g.content_hash } for g in sorted(get_active_objects(UnloadingGroup, recipe_id=r.id).all(), key=lambda x: x.order)] } for r in recipes]) return add_no_cache_headers(response) @app.route('/api/recipes', methods=['POST']) def add_recipe(): """ Создание рецепта без привязки к периоду (для кормоцеха) """ data = request.json if not data or 'name' not in data: return jsonify({'error': 'Название рецепта обязательно'}), 400 try: # ВАЖНО: Component.dry_matter обновляется отдельным endpoint (/api/components/). # При создании рецепта не меняем компоненты "из ингредиентов", иначе возможны откаты СВ% (10 ↔ 100) при сохранениях. component_old_dm_map = {} component_new_dm_map = {} print(f"🗄️ БД CREATE RECIPE: начало создания рецепта") print(f"🗄️ БД CREATE RECIPE: название: '{data['name']}'") print(f"🗄️ БД CREATE RECIPE: количество ингредиентов: {len(data.get('ingredients', []))}") print(f"🗄️ БД CREATE RECIPE: количество групп выгрузки: {len(data.get('unloadingGroups', []))}") recipe = Recipe( name=data['name'], heads_per_trip=data.get('headsPerTrip', 1), mixing_time=data.get('mixingTime', 0), trip_percent=data.get('tripPercent', 100), dry_matter_locked=bool(data.get('dry_matter_locked', False)), unloading_link_broken=bool(data.get('unloading_link_broken', False)), target_component_id=data.get('target_component_id'), # Для кормоцеха created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(recipe) db.session.flush() print(f"🗄️ БД CREATE RECIPE: рецепт добавлен, ID: {recipe.id}") update_content_hash(recipe) for idx, ing in enumerate(data.get('ingredients', []), 1): # Получаем компонент по component_id (приоритет) или по name (для обратной совместимости) component = None component_id = ing.get('component_id') if component_id: component = Component.query.get(component_id) if not component and ing.get('name'): component = Component.query.filter_by(name=ing['name']).first() if not component: component_name = ing.get('name', 'Неизвестно') component_id_str = component_id or 'Не указан' return jsonify({'error': f'Компонент не найден. ID: "{component_id_str}", Имя: "{component_name}". Проверьте, что компонент существует в базе данных.'}), 400 print(f"🗄️ БД CREATE INGREDIENT (NEW RECIPE): создание ингредиента {idx}") print(f"🗄️ БД CREATE INGREDIENT (NEW RECIPE): компонент: {component.id} ({component.name})") print(f"🗄️ БД CREATE INGREDIENT (NEW RECIPE): количество: {ing.get('amount')}") print(f"🗄️ БД CREATE INGREDIENT (NEW RECIPE): вес на голову: {ing.get('weightPerHead') or ing.get('weight_per_head')}") # Получаем dry_matter_per_head если указан (для режима "замок СВ") dry_matter_per_head = ing.get('dry_matter_per_head') or ing.get('dryMatterPerHead') if dry_matter_per_head is not None: try: dry_matter_per_head = float(dry_matter_per_head) except (ValueError, TypeError): dry_matter_per_head = None ingredient = Ingredient( name=component.name, # Используем имя из компонента weight_per_head=ing.get('weightPerHead') or ing.get('weight_per_head', 0), amount=ing.get('amount', 0), dry_matter=ing.get('dry_matter', component.dry_matter), dry_matter_per_head=dry_matter_per_head, # Для режима "замок СВ" component_id=component.id, order=idx, recipe_id=recipe.id, # 🔧 ЯВНО УСТАНАВЛИВАЕМ recipe_id created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(ingredient) db.session.flush() update_content_hash(ingredient) print(f"🗄️ БД CREATE INGREDIENT (NEW RECIPE): ингредиент создан с ID: {ingredient.id}") db.session.flush() for idx, group in enumerate(data.get('unloadingGroups', []), 1): print(f"🗄️ БД CREATE UNLOADING_GROUP (NEW RECIPE): создание группы {idx}") print(f"🗄️ БД CREATE UNLOADING_GROUP (NEW RECIPE): название: '{group.get('name')}'") print(f"🗄️ БД CREATE UNLOADING_GROUP (NEW RECIPE): тип распределения: {group.get('distributionType')}") print(f"🗄️ БД CREATE UNLOADING_GROUP (NEW RECIPE): значение: {group.get('value')}") unloading_group = UnloadingGroup( name=group['name'], distribution_type=group['distributionType'], value=float(group['value']), weight=float(group['weight']) if group.get('weight') else None, order=idx, recipe=recipe, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(unloading_group) db.session.flush() update_content_hash(unloading_group) print(f"🗄️ БД CREATE UNLOADING_GROUP (NEW RECIPE): группа создана с ID: {unloading_group.id}") db.session.flush() # Если требуется обновление компонентов из этого запроса (не рекомендуется) — можно сделать это # отдельным запросом к /api/components/. Здесь намеренно не трогаем Component.dry_matter. print(f"🗄️ БД CREATE RECIPE: коммит создания рецепта {recipe.id}") db.session.commit() print(f"🗄️ БД CREATE RECIPE: успешно - рецепт {recipe.id} создан") process_pending_sync_tasks() return jsonify({'message': 'Рецепт добавлен', 'id': recipe.id}), 201 except Exception as e: print(f"🗄️ БД CREATE RECIPE: ошибка при создании рецепта: {str(e)}") db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/recipes_wibor') def recipes_selection(): return send_from_directory(app.static_folder, 'recipes_selection.html') @app.route('/api/recipes/', methods=['PUT']) def update_recipe(recipe_id): try: data = request.json recipe = db.session.get(Recipe, recipe_id) if not recipe: return jsonify({'error': 'Рецепт не найден'}), 404 print(f"🗄️ БД UPDATE RECIPE: начало изменения рецепта {recipe_id}") print(f"🗄️ БД UPDATE RECIPE: старое название: '{recipe.name}'") print(f"🗄️ БД UPDATE RECIPE: новые данные: {data}") old_name = recipe.name old_dry_matter_locked = getattr(recipe, 'dry_matter_locked', False) # ВАЖНО: Component.dry_matter обновляется отдельным endpoint (/api/components/). # При сохранении рецепта не меняем компоненты "из ингредиентов", иначе возможны откаты СВ% при сохранениях. component_old_dm_map = {} component_new_dm_map = {} recipe.name = data['name'] recipe.heads_per_trip = data['heads_count'] recipe.mixing_time = data['mixing_time'] recipe.trip_percent = data.get('trip_percent', 100) if 'dry_matter_locked' in data: recipe.dry_matter_locked = bool(data.get('dry_matter_locked')) if 'unloading_link_broken' in data: recipe.unloading_link_broken = bool(data.get('unloading_link_broken')) if 'target_component_id' in data: recipe.target_component_id = data.get('target_component_id') # Для кормоцеха recipe.updated_by = 'system' recipe.updated_at = moscow_now() recipe.version += 1 if 'dry_matter_locked' in data: try: logger.info( f"🗄️ [RECIPE-UPDATE] dry_matter_locked: recipe_id={recipe_id} " f"{old_dry_matter_locked} -> {recipe.dry_matter_locked}" ) except Exception: pass print(f"🗄️ БД UPDATE RECIPE: новое название: '{recipe.name}'") print(f"🗄️ БД UPDATE RECIPE: версия изменена с {recipe.version-1} на {recipe.version}") existing_ingredients = {ing.order: ing for ing in recipe.ingredients if not (hasattr(ing, 'is_deleted') and ing.is_deleted)} print(f"🗄️ БД UPDATE RECIPE: найдено {len(existing_ingredients)} активных ингредиентов") print(f"🗄️ БД UPDATE RECIPE: orders активных ингредиентов: {list(existing_ingredients.keys())}") seen_orders = set() # Вариант A: при замке СВ считаем веса на сервере от dry_matter_per_head + dry_matter (%) recipe_is_locked = bool(getattr(recipe, 'dry_matter_locked', False)) calc_by_order = {} group_calc_by_order = {} if recipe_is_locked: try: heads_count_calc = int(data.get('heads_count') or recipe.heads_per_trip or 0) trip_percent_calc = float(data.get('trip_percent', recipe.trip_percent or 100) or 100) # Подтягиваем СВ% компонентов из БД, чтобы не требовать dry_matter в payload dm_component_ids = [ ing.get('component_id') for ing in (data.get('ingredients', []) or []) if isinstance(ing, dict) and ing.get('component_id') ] dm_components = Component.query.filter(Component.id.in_(dm_component_ids)).all() if dm_component_ids else [] dm_by_component_id = {c.id: float(c.dry_matter or 0) for c in dm_components} calc_ingredients_payload = [] ing_orders = [] for idx, ing in enumerate(data.get('ingredients', []), 1): order_value = ing.get('order', idx) ing_orders.append(order_value) dm_percent = ing.get('dry_matter') if dm_percent is None: dm_percent = 0 try: dm_percent = float(dm_percent) except Exception: dm_percent = 0.0 # Если dry_matter не прислали — берем из компонента if dm_percent <= 0: try: dm_percent = float(dm_by_component_id.get(str(ing.get('component_id')), 0) or 0) except Exception: dm_percent = 0.0 dm_per_head = ( ing.get('dry_matter_per_head') if ing.get('dry_matter_per_head') is not None else ing.get('dryMatterPerHead') ) try: dm_per_head = float(dm_per_head) if dm_per_head is not None else 0.0 except Exception: dm_per_head = 0.0 # В режиме "замок СВ": если клиент не прислал dry_matter_per_head, # ПРИОРИТЕТ: берем из БД (из существующего ингредиента) - это константа if dm_per_head <= 0 and recipe_is_locked and order_value in existing_ingredients: old_ing = existing_ingredients[order_value] # ПРИОРИТЕТ 1: берем из БД напрямую (если поле уже существует) if hasattr(old_ing, 'dry_matter_per_head') and old_ing.dry_matter_per_head is not None and old_ing.dry_matter_per_head > 0: dm_per_head = float(old_ing.dry_matter_per_head) else: # Fallback: вычисляем из старого weight_per_head и старого dry_matter (для старых записей) old_wph = float(old_ing.weight_per_head or 0) old_dm = float(old_ing.dry_matter or 0) if old_dm > 0 and old_wph > 0: dm_per_head = old_wph * (old_dm / 100.0) else: return jsonify({'error': f"Не удалось восстановить СВ/гол для ингредиента (order={order_value}). Убедитесь, что отправлено поле dry_matter_per_head или ингредиент существует в БД."}), 400 # Backward-compat для НЕ-замка: если клиент не прислал dry_matter_per_head, пробуем восстановить из weight_per_head if dm_per_head <= 0 and dm_percent > 0 and not recipe_is_locked: try: wph = float(ing.get('weight_per_head') or ing.get('weightPerHead') or 0) except Exception: wph = 0.0 if wph > 0: dm_per_head = wph * (dm_percent / 100.0) if dm_percent <= 0: return jsonify({'error': f"СВ,% (dry_matter) должно быть > 0 для расчета (order={order_value})"}), 400 calc_ingredients_payload.append({ 'component_id': ing.get('component_id'), 'dryMatterPerHead': dm_per_head, 'dryMatter': dm_percent }) calc_groups_payload = [] group_orders = [] for idx, group in enumerate(data.get('unloading_groups', []), 1): order_value = group.get('order', idx) group_orders.append(order_value) calc_groups_payload.append({ 'distributionType': group.get('distribution_type', 'percent'), 'value': float(group.get('value') or 0) }) result_calc = calculate_recipe( ingredients=calc_ingredients_payload, heads_count=heads_count_calc, trip_percent=trip_percent_calc, unloading_groups=calc_groups_payload, component_dry_matter_map=None, calculate_from_dry_matter=True ) for order_value, calc in zip(ing_orders, result_calc.get('ingredients', [])): calc_by_order[order_value] = calc for order_value, calcg in zip(group_orders, result_calc.get('unloadingGroups', [])): group_calc_by_order[order_value] = calcg except Exception as e: logger.error(f"[RECIPE-UPDATE] Ошибка серверного расчета при замке СВ: {e}", exc_info=True) return jsonify({'error': f'Ошибка расчета при замке СВ: {str(e)}'}), 400 print(f"🗄️ БД UPDATE RECIPE: обрабатываем {len(data.get('ingredients', []))} ингредиентов из данных") for idx, ing in enumerate(data.get('ingredients', []), 1): order_value = ing.get('order', idx) print(f"🗄️ БД UPDATE RECIPE: обрабатываем ингредиент {idx}, order: {order_value}") component_id = ing.get('component_id') component = None if component_id: # print(f"DEBUG: Ищем компонент с ID: {component_id}, тип: {type(component_id)}") component = db.session.get(Component, str(component_id)) if component: # print(f"DEBUG: Компонент найден по ID: {component.name}") pass if not component: component_name = ing.get('name') if component_name: # print(f"DEBUG: Ищем компонент по имени: {component_name}") component = Component.query.filter_by(name=component_name).first() if component: # print(f"DEBUG: Компонент найден по имени: {component.name} (ID: {component.id})") pass if not component: component_name = ing.get('name', 'Неизвестно') component_id = ing.get('component_id', 'Не указан') return jsonify({'error': f'Компонент не найден. Имя: "{component_name}", ID: "{component_id}". Проверьте, что компонент существует в базе данных.'}), 400 if order_value in existing_ingredients: cur = existing_ingredients[order_value] cur.name = component.name if recipe_is_locked and order_value in calc_by_order: cur.weight_per_head = float(calc_by_order[order_value].get('weightPerHead', 0) or 0) cur.amount = float(calc_by_order[order_value].get('tripWeight', 0) or 0) # Сохраняем dry_matter_per_head в БД (константа для режима "замок СВ") # Берем из результата расчета - это гарантирует правильную константу dm_per_head_to_save = float(calc_by_order[order_value].get('dryMatterPerHead', 0) or 0) cur.dry_matter_per_head = dm_per_head_to_save if dm_per_head_to_save > 0 else None else: cur.weight_per_head = ing.get('weight_per_head', 0) cur.amount = ing.get('amount', 0) # В обычном режиме тоже сохраняем для совместимости cur_dm = float(ing.get('dry_matter', component.dry_matter) or 0) cur_wph = float(cur.weight_per_head or 0) cur.dry_matter_per_head = cur_wph * (cur_dm / 100.0) if (cur_dm > 0 and cur_wph > 0) else None cur.dry_matter = ing.get('dry_matter', component.dry_matter) cur.component_id = component.id cur.order = order_value cur.updated_by = 'system' cur.updated_at = moscow_now() cur.version += 1 update_content_hash(cur) create_sync_task_async('ingredient', cur.id, 'update', priority=2, target_node_id=None) else: print(f"🗄️ БД CREATE INGREDIENT: создание ингредиента {idx}") print(f"🗄️ БД CREATE INGREDIENT: компонент: {component.id} ({component.name})") print(f"🗄️ БД CREATE INGREDIENT: количество: {ing.get('amount')}") print(f"🗄️ БД CREATE INGREDIENT: вес на голову: {ing.get('weight_per_head')}") print(f"🗄️ БД CREATE INGREDIENT: order: {order_value}") new_wph = ing.get('weight_per_head', 0) new_amount = ing.get('amount', 0) new_dm_per_head = None if recipe_is_locked and order_value in calc_by_order: new_wph = float(calc_by_order[order_value].get('weightPerHead', 0) or 0) new_amount = float(calc_by_order[order_value].get('tripWeight', 0) or 0) # Сохраняем dry_matter_per_head (константа) из результата расчета new_dm_per_head = float(calc_by_order[order_value].get('dryMatterPerHead', 0) or 0) or None elif recipe_is_locked: # Если расчет не был выполнен, берем из payload new_dm_per_head_val = ing.get('dry_matter_per_head') or ing.get('dryMatterPerHead') new_dm_per_head = float(new_dm_per_head_val) if new_dm_per_head_val else None else: # В обычном режиме рассчитываем из текущих данных cur_dm = float(ing.get('dry_matter', component.dry_matter) or 0) new_dm_per_head = new_wph * (cur_dm / 100.0) if (cur_dm > 0 and new_wph > 0) else None new_ingredient = Ingredient( name=component.name, weight_per_head=new_wph, amount=new_amount, dry_matter=ing.get('dry_matter', component.dry_matter), dry_matter_per_head=new_dm_per_head, # Сохраняем в БД component_id=component.id, order=order_value, recipe_id=recipe_id, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(new_ingredient) db.session.flush() update_content_hash(new_ingredient) db.session.flush() create_sync_task_async('ingredient', new_ingredient.id, 'create', priority=2, target_node_id=None) print(f"🗄️ БД CREATE INGREDIENT: ингредиент создан с ID: {new_ingredient.id}") seen_orders.add(order_value) for order_value, cur in existing_ingredients.items(): if order_value not in seen_orders: print(f"🗄️ БД DELETE INGREDIENT: удаление ингредиента {cur.id}") print(f"🗄️ БД DELETE INGREDIENT: название: '{cur.name}'") print(f"🗄️ БД DELETE INGREDIENT: рецепт: {cur.recipe_id}") print(f"🗄️ БД DELETE INGREDIENT: order: {cur.order}") cur.soft_delete(deleted_by='system', reason='Удален при редактировании рецепта', request=None) create_sync_task_async('ingredient', cur.id, 'delete', priority=1, target_node_id=None) print(f"🗄️ БД DELETE INGREDIENT: ингредиент {cur.id} помечен как удаленный") existing_groups = {g.order: g for g in recipe.unloading_groups if not (hasattr(g, 'is_deleted') and g.is_deleted)} print(f"🗄️ БД UPDATE RECIPE: найдено {len(existing_groups)} активных групп выгрузки") print(f"🗄️ БД UPDATE RECIPE: orders активных групп: {list(existing_groups.keys())}") seen_group_orders = set() print(f"🗄️ БД UPDATE RECIPE: обрабатываем {len(data.get('unloading_groups', []))} групп выгрузки из данных") for idx, group in enumerate(data.get('unloading_groups', []), 1): order_value = group.get('order', idx) print(f"🗄️ БД UPDATE RECIPE: обрабатываем группу {idx}, order: {order_value}") if order_value in existing_groups: cur = existing_groups[order_value] cur.name = group['name'] cur.distribution_type = group['distribution_type'] cur.value = float(group['value']) if recipe_is_locked and order_value in group_calc_by_order: cur.weight = float(group_calc_by_order[order_value].get('calculatedWeight', 0) or 0) else: cur.weight = float(group['weight']) if group.get('weight') else None cur.order = order_value cur.updated_by = 'system' cur.updated_at = moscow_now() cur.version += 1 update_content_hash(cur) create_sync_task_async('unloading_group', cur.id, 'update', priority=2, target_node_id=None) else: print(f"🗄️ БД CREATE UNLOADING_GROUP: создание группы {idx}") print(f"🗄️ БД CREATE UNLOADING_GROUP: название: '{group.get('name')}'") print(f"🗄️ БД CREATE UNLOADING_GROUP: тип распределения: {group.get('distribution_type')}") print(f"🗄️ БД CREATE UNLOADING_GROUP: значение: {group.get('value')}") print(f"🗄️ БД CREATE UNLOADING_GROUP: вес: {group.get('weight')}") print(f"🗄️ БД CREATE UNLOADING_GROUP: order: {order_value}") new_group = UnloadingGroup( name=group['name'], distribution_type=group['distribution_type'], value=float(group['value']), weight=( float(group_calc_by_order[order_value].get('calculatedWeight', 0) or 0) if recipe_is_locked and order_value in group_calc_by_order else (float(group['weight']) if group.get('weight') else None) ), order=order_value, recipe_id=recipe_id, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(new_group) db.session.flush() update_content_hash(new_group) db.session.flush() create_sync_task_async('unloading_group', new_group.id, 'create', priority=2, target_node_id=None) print(f"🗄️ БД CREATE UNLOADING_GROUP: группа создана с ID: {new_group.id}") seen_group_orders.add(order_value) for order_value, cur in existing_groups.items(): if order_value not in seen_group_orders: print(f"🗄️ БД DELETE UNLOADING_GROUP: удаление группы {cur.id}") print(f"🗄️ БД DELETE UNLOADING_GROUP: название: '{cur.name}'") print(f"🗄️ БД DELETE UNLOADING_GROUP: рецепт: {cur.recipe_id}") print(f"🗄️ БД DELETE UNLOADING_GROUP: order: {cur.order}") cur.soft_delete(deleted_by='system', reason='Удалена при редактировании рецепта', request=None) create_sync_task_async('unloading_group', cur.id, 'delete', priority=1, target_node_id=None) print(f"🗄️ БД DELETE UNLOADING_GROUP: группа {cur.id} помечена как удаленная") update_content_hash(recipe) logger.info(f"[RECIPE-UPDATE] Рецепт обновлен: {recipe_id}, название: '{recipe.name}', версия: {recipe.version}") # Компоненты не обновляем из этого endpoint — каскадный пересчет рецептов происходит # при изменении компонента через /api/components/. stats = { 'changed_components': 0, 'affected_recipes': 0, 'recalculated_recipes': 0, 'updated_ingredients': 0, 'updated_unloading_groups': 0, 'skipped_recipes': 0 } db.session.commit() process_pending_sync_tasks() msg = ( f"Сохранено. " f"Компонентов: {stats.get('changed_components', 0)}, " f"пересчитано: {stats.get('recalculated_recipes', 0)} рейсов " f"и обновлено ингредиентов: {stats.get('updated_ingredients', 0)}." ) return jsonify({'message': msg, 'id': recipe_id, 'stats': stats}) except Exception as e: logger.error(f"[RECIPE-UPDATE] Ошибка при обновлении рецепта {recipe_id}: {e}", exc_info=True) db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/recipes/', methods=['DELETE']) def delete_recipe(recipe_id): try: recipe = Recipe.query.get_or_404(recipe_id) print(f"🗄️ БД DELETE RECIPE: начало мягкого удаления рецепта {recipe_id}") print(f"🗄️ БД DELETE RECIPE: название: '{recipe.name}'") print(f"🗄️ БД DELETE RECIPE: версия: {recipe.version}") print(f"🗄️ БД DELETE RECIPE: количество ингредиентов: {len(recipe.ingredients)}") print(f"🗄️ БД DELETE RECIPE: количество групп выгрузки: {len(recipe.unloading_groups)}") recipe.soft_delete( deleted_by='system', reason='Удаление через API', request=None ) print(f"🗄️ БД DELETE RECIPE: рецепт {recipe_id} помечен как удаленный") db.session.commit() process_pending_sync_tasks() return jsonify({ 'message': 'Рецепт удален', 'deleted_at': recipe.deleted_at.isoformat() if recipe.deleted_at else None, 'deleted_by': recipe.deleted_by }) except Exception as e: db.session.rollback() print(f"Ошибка при удалении рейса: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/recipes/', methods=['GET']) def get_recipe(recipe_id): try: recipe = Recipe.query.filter(Recipe.id == recipe_id) if hasattr(Recipe, 'is_deleted'): recipe = recipe.filter(Recipe.is_deleted == False) recipe = recipe.first() if not recipe: return jsonify({'error': 'Рецепт не найден'}), 404 response = jsonify({ 'id': recipe.id, 'name': recipe.name, 'heads_per_trip': recipe.heads_per_trip, 'heads_count': recipe.heads_per_trip, # Для совместимости 'mixing_time': recipe.mixing_time, 'trip_percent': recipe.trip_percent, 'dry_matter_locked': recipe.dry_matter_locked, 'unloading_link_broken': recipe.unloading_link_broken, 'target_component_id': recipe.target_component_id, # Для кормоцеха 'version': recipe.version, 'created_at': recipe.created_at.isoformat() if recipe.created_at else None, 'updated_at': recipe.updated_at.isoformat() if recipe.updated_at else None, 'created_by': recipe.created_by, 'updated_by': recipe.updated_by, 'ingredients': [{ 'id': i.id, 'name': i.name, 'component_id': i.component_id, 'weight_per_head': f'{round(float(i.weight_per_head), 2):.2f}' if i.weight_per_head is not None else '0.00', 'amount': round(float(i.amount), 2) if i.amount is not None else 0.0, 'dry_matter': i.dry_matter, 'dry_matter_per_head': getattr(i, 'dry_matter_per_head', None), # Добавить это поле 'order': i.order, 'version': i.version, 'created_at': i.created_at.isoformat() if i.created_at else None, 'updated_at': i.updated_at.isoformat() if i.updated_at else None, 'created_by': i.created_by, 'updated_by': i.updated_by } for i in sorted(get_active_objects(Ingredient, recipe_id=recipe.id).all(), key=lambda x: x.order)], 'unloading_groups': [{ 'id': g.id, 'name': g.name, 'distribution_type': g.distribution_type, 'value': g.value, 'weight': g.weight, 'order': g.order, 'version': g.version, 'created_at': g.created_at.isoformat() if g.created_at else None, 'updated_at': g.updated_at.isoformat() if g.updated_at else None, 'created_by': g.created_by, 'updated_by': g.updated_by } for g in sorted(get_active_objects(UnloadingGroup, recipe_id=recipe.id).all(), key=lambda x: x.order)] }) return add_no_cache_headers(response) except Exception as e: # print(f"Ошибка при получении рецепта: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/recipes/calculate', methods=['POST']) def calculate_recipe_endpoint(): """API endpoint для расчетов рецепта на бэкенде""" try: data = request.json if not data: return jsonify({'error': 'Данные не предоставлены'}), 400 ingredients = data.get('ingredients', []) or [] heads_count = int(data.get('headsCount') or data.get('heads_count') or 0) trip_percent = float(data.get('tripPercent') or data.get('trip_percent') or 100) unloading_groups = data.get('unloadingGroups') or data.get('unloading_groups') or [] calculate_from_dry_matter = bool( data.get('calculateFromDryMatter') if data.get('calculateFromDryMatter') is not None else data.get('calculate_from_dry_matter', False) ) # Нормализуем структуру для расчетчика (поддержка snake_case payload) normalized_ingredients = [] for ing in ingredients: if not isinstance(ing, dict): continue ing = dict(ing) if 'component_id' not in ing and 'componentId' in ing: ing['component_id'] = ing.get('componentId') # Нормализуем ключи для расчёта от СВ (dryMatterPerHead, dryMatter) if 'dryMatterPerHead' not in ing and 'dry_matter_per_head' in ing: ing['dryMatterPerHead'] = ing.get('dry_matter_per_head') if 'dryMatter' not in ing and 'dry_matter' in ing: ing['dryMatter'] = ing.get('dry_matter') normalized_ingredients.append(ing) ingredients = normalized_ingredients normalized_groups = [] for g in unloading_groups: if not isinstance(g, dict): continue if 'distributionType' not in g and 'distribution_type' in g: g = dict(g) g['distributionType'] = g.get('distribution_type') normalized_groups.append(g) unloading_groups = normalized_groups # Получаем dry_matter для компонентов из базы данных component_dry_matter_map = {} component_ids = [ing.get('component_id') for ing in ingredients if ing.get('component_id')] if component_ids: components = Component.query.filter(Component.id.in_(component_ids)).all() component_dry_matter_map = {comp.id: comp.dry_matter for comp in components} # Подробное логирование расчета (включается флагом CALC_DEBUG=1) try: if str(os.getenv("CALC_DEBUG", "")).strip().lower() in {"1", "true", "yes", "on"}: logger.info( "[CALC-ENDPOINT] request payload: %s", json.dumps( { "heads_count": heads_count, "trip_percent": trip_percent, "calculate_from_dry_matter": calculate_from_dry_matter, "ingredients": ingredients, "unloading_groups": unloading_groups, }, ensure_ascii=False, default=str, ), ) logger.info( "[CALC-ENDPOINT] component_dry_matter_map: %s", json.dumps(component_dry_matter_map, ensure_ascii=False, default=str), ) except Exception: pass # Вызываем модуль расчетов result = calculate_recipe( ingredients=ingredients, heads_count=heads_count, trip_percent=trip_percent, unloading_groups=unloading_groups, component_dry_matter_map=component_dry_matter_map, calculate_from_dry_matter=calculate_from_dry_matter ) # Нормализуем числовые значения до сотых (2 знака). weightPerHead отдаём строкой "X.XX", # усекая до 2 знаков (без округления вверх), чтобы 2.5 не превращалось в 3.00. def to_float2(x): try: return round(float(x), 2) except (TypeError, ValueError): return 0.0 def truncate2(x): """Усечь до 2 знаков после запятой (не округлять вверх).""" try: v = float(x) return float(int(v * 100)) / 100.0 except (TypeError, ValueError): return 0.0 if result.get('ingredients'): for idx, ing in enumerate(result['ingredients']): for key in ('weightPerHead', 'tripWeight', 'totalWeight', 'dryMatterPerHead'): if key in ing and ing[key] is not None: raw_val = ing[key] v = to_float2(raw_val) if key != 'weightPerHead' else truncate2(raw_val) ing[key] = f'{v:.2f}' if key == 'weightPerHead' else v if result.get('totals'): for key in ('totalWeight', 'totalTripWeight', 'totalDryMatterPerHead', 'totalWeightPerHead'): if key in result['totals'] and result['totals'][key] is not None: result['totals'][key] = to_float2(result['totals'][key]) try: if str(os.getenv("CALC_DEBUG", "")).strip().lower() in {"1", "true", "yes", "on"}: logger.info( "[CALC-ENDPOINT] response: %s", json.dumps(result, ensure_ascii=False, default=str), ) except Exception: pass return jsonify(result), 200 except Exception as e: print(f"Ошибка при расчете рецепта: {str(e)}") import traceback traceback.print_exc() return jsonify({'error': str(e)}), 500 @app.route('/') def index(): return send_from_directory(app.static_folder, 'role_selection.html') @app.route('/login') def login_page(): """Страница авторизации с логином и паролем""" return send_from_directory(app.static_folder, 'login.html') @app.route('/scales') def scales(): current_weight = calculate_current_weight() raw_data = { "last_50_raw": weight_data["raw_samples"][-50:], "readings_history": weight_data["current_readings"][-20:], "weight_history": weight_data["weight_history"][-20:] } return render_template( 'desktop_index.html', current_weight=round(current_weight, 2), calibration_factor=round(calibration_factor, 2), samples_count=len(weight_data["raw_samples"]), raw_data=raw_data, last_readings=weight_data["current_readings"][-10:] ) @app.route('/recipes') def recipes_page(): """Страница рецептов""" user_agent = request.headers.get('User-Agent', '').lower() is_mobile = any(device in user_agent for device in ['iphone', 'android', 'ipad', 'mobile']) filename = 'recipes.html' if is_mobile else 'recipes.html' return send_from_directory(app.static_folder, filename) @app.route('/recipes_content') def recipes_content(): """Прямой доступ к содержимому рецептов после авторизации""" user_agent = request.headers.get('User-Agent', '').lower() is_mobile = any(device in user_agent for device in ['iphone', 'android', 'ipad', 'mobile']) filename = 'recipes.html' if is_mobile else 'recipes.html' return send_from_directory(app.static_folder, filename) @app.route('/calibration') def calibration(): current_weight = calculate_current_weight() raw_data = { "last_50_raw": weight_data["raw_samples"][-50:], "readings_history": weight_data["current_readings"][-20:], "weight_history": weight_data["weight_history"][-20:] } return render_template( 'calibration.html', current_weight=round(current_weight, 2), calibration_factor=round(calibration_factor, 2), raw_data=json.dumps(raw_data, indent=2) ) @app.route('/tare', methods=['POST']) def tare(): global initial_weight_raw try: initial_weight_raw = save_initial_weight() led_blink(2, 0.3) # Мигание при установке нуля return jsonify({ "status": "success", # Оставляем только статус выполнения "new_zero": round(initial_weight_raw, 2) }), 200 except Exception as e: led_blink(5, 0.1) # Ошибка return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/calibrate', methods=['POST']) def calibrate(): global calibration_target_weight, calibration_initial_raw try: data = request.json calibration_target_weight = float(data['target_weight']) time.sleep(2) calibration_initial_raw = save_initial_weight() if not calibration_initial_raw: raise ValueError("Нет данных для калибровки") return jsonify({ "status": "confirm", "message": f"Поместите груз {calibration_target_weight}кг и подтвердите" }), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/calibrate/continue', methods=['POST']) def calibrate_continue(): global calibration_factor, calibration_target_weight, calibration_initial_raw try: if not calibration_target_weight or not calibration_initial_raw: raise ValueError("Калибровка не начата") time.sleep(2) readings = weight_data["current_readings"][-MOVING_AVERAGE_WINDOW:] final_avg_raw = sum(readings) / len(readings) calibration_factor = (final_avg_raw - calibration_initial_raw) / calibration_target_weight with open(CALIBRATION_FILE, 'w') as f: json.dump({'calibration_factor': calibration_factor}, f) calibration_target_weight = None calibration_initial_raw = None led_blink(3, 0.2) # Успешная калибровка return jsonify({ "status": "success", "calibration_factor": round(calibration_factor, 2), "message": "Калибровка завершена!" }), 200 except Exception as e: led_blink(5, 0.1) # Ошибка калибровки return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/stream_weight') def stream_weight(): def generate(): while True: current_weight = calculate_current_weight() yield f"data: {round(current_weight, 2)}\n\n" time.sleep(STREAM_INTERVAL) return Response(generate(), mimetype='text/event-stream') @app.route('/current_raw_data') def current_raw_data(): return jsonify({ "last_50_raw": weight_data["raw_samples"][-50:], "readings_history": weight_data["current_readings"][-20:], "weight_history": weight_data["weight_history"][-20:] }) @app.route('/current_weight', methods=['GET']) def get_current_weight(): return jsonify({"weight": round(calculate_current_weight(), 2)}) @app.route('/static/') def static_files(filename): return send_from_directory(os.path.join(app.root_path, 'static'), filename) @app.route('/sounds/unloading.mp3') def unloading_sound(): """Раздача звука выгрузки (из templates/unloading.mp3).""" return send_from_directory( os.path.join(app.root_path, 'templates'), 'unloading.mp3', mimetype='audio/mpeg' ) @app.route('/api/led/', methods=['POST']) def control_led(state): try: if state == 'on': led_on() return jsonify({"status": "success", "message": "Светодиод включен"}) elif state == 'off': led_off() return jsonify({"status": "success", "message": "Светодиод выключен"}) elif state == 'blink': times = request.json.get('times', 1) delay = request.json.get('delay', 0.5) threading.Thread(target=led_blink, args=(times, delay)).start() return jsonify({"status": "success", "message": f"Мигание {times} раз"}) else: return jsonify({"status": "error", "message": "Неверная команда"}), 400 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/unloading') def unloading_page(): return send_from_directory(app.static_folder, 'unloading.html') def parse_iso_datetime(iso_string): """Преобразует ISO строку в московское datetime.""" if not iso_string: return None try: cleaned_iso = iso_string.replace('Z', '+00:00') utc_dt = datetime.fromisoformat(cleaned_iso) moscow_dt = utc_dt.astimezone(MOSCOW_TZ) return moscow_dt.replace(tzinfo=None) except (ValueError, TypeError) as e: print(f"Error parsing datetime: {e}") return None def datetime_to_iso(dt): """Преобразует московское datetime в ISO строку.""" if not dt: return None try: if dt.tzinfo is None: dt = MOSCOW_TZ.localize(dt) return dt.isoformat() except (ValueError, TypeError) as e: print(f"Error formatting datetime: {e}") return None @app.route('/api/save_report', methods=['POST']) def save_report(): try: data = request.json if not data: return jsonify({'status': 'error', 'message': 'Нет данных для сохранения'}), 400 recipe_id = data.get('recipe_id') if not recipe_id: return jsonify({'status': 'error', 'message': 'Не указан ID рецепта'}), 400 recipe = db.session.get(Recipe, recipe_id) if not recipe: return jsonify({'status': 'error', 'message': 'Рецепт не найден'}), 404 total_weight = data.get('total_weight') if total_weight is None: return jsonify({'status': 'error', 'message': 'Не указан общий вес'}), 400 target_mixing_time = data.get('target_mixing_time') if target_mixing_time is None: return jsonify({'status': 'error', 'message': 'Не указано целевое время смешивания'}), 400 actual_mixing_time = data.get('actual_mixing_time') if actual_mixing_time is None: return jsonify({'status': 'error', 'message': 'Не указано фактическое время смешивания'}), 400 components = data.get('components', []) if not components: return jsonify({'status': 'error', 'message': 'Нет данных о компонентах'}), 400 loading_times = data.get('component_loading_times', []) if loading_times and len(loading_times) > 0: report_start_time = parse_iso_datetime(loading_times[0]['start_time']) else: report_start_time = moscow_now() dispenser_type = data.get('dispenser_type') or 'dispenser' if dispenser_type not in ('mill', 'dispenser'): dispenser_type = 'dispenser' report_id = str(uuid.uuid4()) report = LoadingReport( id=report_id, recipe_id=recipe_id, recipe_name=recipe.name, start_time=report_start_time, end_time=moscow_now(), target_mixing_time=target_mixing_time, actual_mixing_time=actual_mixing_time, total_weight=float(total_weight), dispenser_type=dispenser_type, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(report) db.session.flush() update_content_hash(report) for idx, comp in enumerate(components, 1): try: cid = comp.get('component_id') or comp.get('componentId') if not cid: cid = _resolve_component_id_from_recipe(recipe_id, comp.get('name', '')) report_component = LoadingReportComponent( report_id=report.id, component_id=cid, component_name=str(comp.get('name', '')), target_weight=float(comp.get('target_weight', 0)), actual_weight=float(comp.get('actual_weight', 0)), overload=float(comp.get('overload', 0)), loading_order=idx, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(report_component) db.session.flush() update_content_hash(report_component) except (ValueError, TypeError) as e: db.session.rollback() return jsonify({ 'status': 'error', 'message': f'Ошибка в данных компонента {idx}: {str(e)}' }), 400 for loading_time in loading_times: try: start_time = parse_iso_datetime(loading_time['start_time']) end_time = parse_iso_datetime(loading_time['end_time']) if not start_time or not end_time: raise ValueError("Invalid datetime format") component_loading_time = ComponentLoadingTime( report_id=report.id, component_name=str(loading_time['component_name']), start_time=start_time, end_time=end_time, loading_duration=float(loading_time['loading_duration']), loading_order=int(loading_time['loading_order']), created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(component_loading_time) db.session.flush() update_content_hash(component_loading_time) except (ValueError, TypeError, KeyError) as e: db.session.rollback() return jsonify({ 'status': 'error', 'message': f'Ошибка в данных времени загрузки: {str(e)}' }), 400 db.session.commit() process_pending_sync_tasks() return jsonify({ 'status': 'success', 'message': 'Отчёт успешно сохранён', 'report_id': report.id }) except Exception as e: db.session.rollback() return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/reports') def get_reports(): try: date_from = request.args.get('date_from') date_to = request.args.get('date_to') query = LoadingReport.query # В БД start_time хранится в московском времени (naive) if date_from and date_to: try: # Параметры в формате YYYY-MM-DD, фильтр по дате включительно (Москва) from_dt = datetime.strptime(date_from, '%Y-%m-%d') to_dt = datetime.strptime(date_to, '%Y-%m-%d').replace(hour=23, minute=59, second=59, microsecond=999999) query = query.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt) except ValueError: # неверный формат — по умолчанию за сутки now = moscow_now() since = now - timedelta(hours=24) query = query.filter(LoadingReport.start_time >= since, LoadingReport.start_time <= now) else: # Без параметров — только отчёты за последние 24 часа now = moscow_now() since = now - timedelta(hours=24) query = query.filter(LoadingReport.start_time >= since, LoadingReport.start_time <= now) reports = query.order_by(LoadingReport.start_time.desc()).all() result = [] for report in reports: dispenser_name = 'Не назначен' try: recipe = db.session.get(Recipe, report.recipe_id) if recipe and recipe.feeding_periods: period = recipe.feeding_periods[0] if period and period.dispenser: dispenser_name = period.dispenser.name except Exception as e: print(f"Ошибка определения кормораздатчика для отчета {report.id}: {str(e)}") unloading_data = None try: unloading_report = UnloadingReport.query.filter_by(loading_report_id=report.id).first() if unloading_report: unloading_data = { 'id': unloading_report.id, 'total_weight': unloading_report.total_weight, 'total_unloaded_weight': unloading_report.total_unloaded_weight, 'remaining_weight': unloading_report.remaining_weight, 'version': unloading_report.version, 'created_at': datetime_to_iso(unloading_report.created_at) if unloading_report.created_at else None, 'updated_at': datetime_to_iso(unloading_report.updated_at) if unloading_report.updated_at else None, 'created_by': unloading_report.created_by, 'updated_by': unloading_report.updated_by, 'content_hash': unloading_report.content_hash, 'unloading_groups': [{ 'name': group.name, 'target_weight': group.target_weight, 'unloaded_weight': group.unloaded_weight, 'remaining_weight': group.remaining_weight, 'distribution_type': group.distribution_type, 'distribution_value': group.distribution_value, 'order': group.order, 'version': group.version, 'created_at': datetime_to_iso(group.created_at) if group.created_at else None, 'updated_at': datetime_to_iso(group.updated_at) if group.updated_at else None, 'created_by': group.created_by, 'updated_by': group.updated_by, 'content_hash': group.content_hash } for group in sorted(unloading_report.groups, key=lambda x: x.order)] } except Exception as e: print(f"Ошибка загрузки данных выгрузки для отчета {report.id}: {str(e)}") result.append({ 'id': report.id, 'recipe_id': report.recipe_id, 'recipe_name': report.recipe_name, 'dispenser_name': dispenser_name, 'dispenser_type': getattr(report, 'dispenser_type', None) or 'dispenser', 'start_time': datetime_to_iso(report.start_time), 'end_time': datetime_to_iso(report.end_time), 'target_mixing_time': report.target_mixing_time, 'actual_mixing_time': report.actual_mixing_time, 'total_weight': report.total_weight, 'version': report.version, 'created_at': datetime_to_iso(report.created_at) if report.created_at else None, 'updated_at': datetime_to_iso(report.updated_at) if report.updated_at else None, 'created_by': report.created_by, 'updated_by': report.updated_by, 'content_hash': report.content_hash, 'unloading_data': unloading_data, 'components': [{ 'name': comp.component_name, 'component_id': getattr(comp, 'component_id', None), 'component_name': comp.component_name, 'target_weight': float(comp.target_weight), 'actual_weight': float(comp.actual_weight), 'overload': float(comp.overload) if comp.overload is not None else 0.0, 'loading_order': comp.loading_order, 'version': comp.version, 'created_at': datetime_to_iso(comp.created_at) if comp.created_at else None, 'updated_at': datetime_to_iso(comp.updated_at) if comp.updated_at else None, 'created_by': comp.created_by, 'updated_by': comp.updated_by, 'content_hash': comp.content_hash } for comp in sorted(report.components, key=lambda x: x.loading_order)], 'component_loading_times': [{ 'component_name': lt.component_name, 'start_time': datetime_to_iso(lt.start_time), 'end_time': datetime_to_iso(lt.end_time), 'loading_duration': float(lt.loading_duration), 'loading_order': lt.loading_order, 'version': lt.version, 'created_at': datetime_to_iso(lt.created_at) if lt.created_at else None, 'updated_at': datetime_to_iso(lt.updated_at) if lt.updated_at else None, 'created_by': lt.created_by, 'updated_by': lt.updated_by, 'content_hash': lt.content_hash } for lt in sorted(report.component_loading_times, key=lambda x: x.loading_order)] }) return jsonify(result) except Exception as e: print(f"Error in get_reports: {str(e)}") return jsonify({ 'error': True, 'message': f'Ошибка при загрузке отчетов: {str(e)}' }), 500 @app.route('/reports') def reports_page(): return send_from_directory(app.static_folder, 'reports.html') @app.route('/feed_consumption') def feed_consumption_page(): return send_from_directory(app.static_folder, 'consumption.html') @app.route('/api/consumption_by_component', methods=['GET']) def get_consumption_by_component(): """Сводка потребления по компонентам из reports.db (агрегация LoadingReportComponent).""" try: date_from = request.args.get('date_from') date_to = request.args.get('date_to') query = db.session.query( LoadingReportComponent.component_id, LoadingReportComponent.component_name, func.coalesce( func.sum( LoadingReportComponent.actual_weight + func.coalesce(LoadingReportComponent.overload, 0) ), 0 ).label('total_actual_weight'), func.count(LoadingReportComponent.id).label('report_count') ).join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id).filter( LoadingReportComponent.actual_weight > 0 ) if getattr(LoadingReport, 'is_deleted', None) is not None: query = query.filter(LoadingReport.is_deleted == False) if getattr(LoadingReportComponent, 'is_deleted', None) is not None: query = query.filter(LoadingReportComponent.is_deleted == False) if date_from and date_to: try: from_dt = datetime.strptime(date_from, '%Y-%m-%d') to_dt = datetime.strptime(date_to, '%Y-%m-%d').replace(hour=23, minute=59, second=59, microsecond=999999) query = query.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt) except ValueError: now = moscow_now() since = now - timedelta(hours=24) query = query.filter(LoadingReport.start_time >= since, LoadingReport.start_time <= now) else: now = moscow_now() since = now - timedelta(hours=24) query = query.filter(LoadingReport.start_time >= since, LoadingReport.start_time <= now) rows = query.group_by( LoadingReportComponent.component_id, LoadingReportComponent.component_name ).all() result = [ { 'component_id': row.component_id, 'component_name': row.component_name or '—', 'total_actual_weight': round(float(row.total_actual_weight), 2), 'report_count': row.report_count } for row in rows ] return jsonify(result) except Exception as e: print(f"Error in get_consumption_by_component: {str(e)}") return jsonify({'error': True, 'message': str(e)}), 500 @app.route('/api/reports//loading_times', methods=['GET']) def get_report_loading_times(report_id): try: report = LoadingReport.query.get_or_404(report_id) loading_times = ComponentLoadingTime.query.filter_by(report_id=report_id).order_by(ComponentLoadingTime.loading_order).all() if not loading_times: return jsonify({ 'status': 'success', 'loading_times': [] }) return jsonify({ 'status': 'success', 'loading_times': [{ 'component_name': lt.component_name, 'start_time': datetime_to_iso(lt.start_time), 'end_time': datetime_to_iso(lt.end_time), 'loading_duration': float(lt.loading_duration), 'loading_order': lt.loading_order } for lt in loading_times] }) except Exception as e: print(f"Error in get_report_loading_times: {str(e)}") return jsonify({ 'status': 'error', 'message': f'Ошибка при загрузке времен: {str(e)}' }), 500 @app.route('/api/save_unloading_report', methods=['POST']) def save_unloading_report(): try: data = request.json app.logger.info("save_unloading_report payload: %s", { 'has_data': bool(data), 'loading_report_id': data.get('loading_report_id') if isinstance(data, dict) else None, 'recipe_id': data.get('recipe_id') if isinstance(data, dict) else None, 'groups_count': len(data.get('unloading_groups', [])) if isinstance(data, dict) else 0 }) if not data: return jsonify({'error': 'Нет данных'}), 400 loading_report_id = data.get('loading_report_id') if not loading_report_id: return jsonify({'error': 'Не указан ID отчёта о загрузке'}), 400 recipe_id = data.get('recipe_id') if not recipe_id: return jsonify({'error': 'Не указан ID рецепта'}), 400 recipe = db.session.get(Recipe, recipe_id) if not recipe: return jsonify({'error': f'Рецепт с ID {recipe_id} не найден'}), 404 def _to_float(value, default=0.0): try: return float(value) except Exception: return default total_weight = _to_float(data.get('total_weight'), 0.0) total_unloaded_weight = _to_float(data.get('total_unloaded_weight'), 0.0) remaining_weight = _to_float(data.get('remaining_weight'), max(0.0, total_weight - total_unloaded_weight)) unloading_report_id = str(uuid.uuid4()) report = UnloadingReport( id=unloading_report_id, recipe_id=data['recipe_id'], recipe_name=recipe.name, loading_report_id=loading_report_id, total_weight=total_weight, total_unloaded_weight=total_unloaded_weight, remaining_weight=remaining_weight, end_time=moscow_now(), created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(report) db.session.flush() update_content_hash(report) for group_data in data.get('unloading_groups', []): g_target = _to_float(group_data.get('target_weight'), 0.0) g_unloaded = _to_float(group_data.get('unloaded_weight'), 0.0) g_remaining = _to_float(group_data.get('remaining_weight'), max(0.0, g_target - g_unloaded)) g_dist_value = _to_float(group_data.get('distribution_value'), 0.0) g_order = int(group_data.get('order') or 0) app.logger.debug("unloading_group item: %s", { 'name': group_data.get('name'), 'target_weight': g_target, 'unloaded_weight': g_unloaded, 'remaining_weight': g_remaining, 'distribution_type': group_data.get('distribution_type'), 'distribution_value': g_dist_value, 'order': g_order }) group = UnloadingReportGroup( report_id=report.id, name=group_data['name'], target_weight=g_target, unloaded_weight=g_unloaded, remaining_weight=g_remaining, distribution_type=group_data['distribution_type'], distribution_value=g_dist_value, order=g_order, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(group) db.session.flush() update_content_hash(group) db.session.commit() process_pending_sync_tasks() return jsonify({'status': 'success', 'message': 'Отчёт о выгрузке сохранён', 'unloading_report_id': report.id}) except Exception as e: db.session.rollback() try: import traceback as _tb app.logger.error("save_unloading_report failed: %s\n%s", str(e), _tb.format_exc()) except Exception: pass return jsonify({'error': f'Ошибка сохранения отчёта о выгрузке: {str(e)}'}), 500 @app.route('/api/unloading_reports', methods=['GET']) def get_unloading_reports(): try: reports = UnloadingReport.query.order_by(UnloadingReport.start_time.desc()).all() return jsonify([{ 'id': report.id, 'recipe_id': report.recipe_id, 'recipe_name': report.recipe_name, 'loading_report_id': report.loading_report_id, 'start_time': datetime_to_iso(report.start_time), 'end_time': datetime_to_iso(report.end_time) if report.end_time else None, 'total_weight': report.total_weight, 'total_unloaded_weight': report.total_unloaded_weight, 'remaining_weight': report.remaining_weight, 'version': report.version, 'created_at': datetime_to_iso(report.created_at) if report.created_at else None, 'updated_at': datetime_to_iso(report.updated_at) if report.updated_at else None, 'created_by': report.created_by, 'updated_by': report.updated_by, 'content_hash': report.content_hash, 'unloading_groups': [{ 'name': group.name, 'target_weight': group.target_weight, 'unloaded_weight': group.unloaded_weight, 'remaining_weight': group.remaining_weight, 'distribution_type': group.distribution_type, 'distribution_value': group.distribution_value, 'order': group.order, 'version': group.version, 'created_at': datetime_to_iso(group.created_at) if group.created_at else None, 'updated_at': datetime_to_iso(group.updated_at) if group.updated_at else None, 'created_by': group.created_by, 'updated_by': group.updated_by, 'content_hash': group.content_hash } for group in sorted(report.groups, key=lambda x: x.order)] } for report in reports]) except Exception as e: return jsonify({ 'status': 'error', 'message': str(e) }), 500 @app.route('/api/reports//unloading_groups', methods=['GET']) def get_report_unloading_groups(report_id): try: report = UnloadingReport.query.get_or_404(report_id) return jsonify({ 'status': 'success', 'recipe_name': report.recipe_name, 'total_weight': report.total_weight, 'total_unloaded_weight': report.total_unloaded_weight, 'remaining_weight': report.remaining_weight, 'unloading_groups': [{ 'name': group.name, 'target_weight': group.target_weight, 'unloaded_weight': group.unloaded_weight, 'remaining_weight': group.remaining_weight, 'distribution_type': group.distribution_type, 'distribution_value': group.distribution_value, 'order': group.order, 'version': group.version, 'created_at': datetime_to_iso(group.created_at) if group.created_at else None, 'updated_at': datetime_to_iso(group.updated_at) if group.updated_at else None, 'created_by': group.created_by, 'updated_by': group.updated_by } for group in sorted(report.groups, key=lambda x: x.order)] }) except Exception as e: return jsonify({ 'status': 'error', 'message': str(e) }), 500 @app.route('/feed_dispensers') def feed_dispensers_page(): # Важно: отключаем кеширование статики, чтобы изменения UI применялись сразу return add_no_cache_headers(send_from_directory(app.static_folder, 'feed_dispensers.html')) @app.route('/api/feed_dispensers', methods=['GET']) def get_feed_dispensers(): try: dispensers = get_active_objects(FeedDispenser, is_active=True).filter_by(is_deleted=False).all() result = [] for d in dispensers: # Безопасное получение типа устройства device_type = 'dispenser' # значение по умолчанию try: if hasattr(d, 'type'): device_type = d.type if d.type else 'dispenser' except Exception: # Если поле не существует в БД, используем значение по умолчанию device_type = 'dispenser' result.append({ 'id': d.id, 'name': d.name, 'farm': d.farm, 'operator': d.operator, 'type': device_type, 'version': d.version, 'created_at': d.created_at.isoformat() if d.created_at else None, 'updated_at': d.updated_at.isoformat() if d.updated_at else None, 'created_by': d.created_by, 'updated_by': d.updated_by, 'content_hash': d.content_hash, 'is_deleted': d.is_deleted if hasattr(d, 'is_deleted') else False, 'deleted_at': d.deleted_at.isoformat() if hasattr(d, 'deleted_at') and d.deleted_at else None, 'deleted_by': getattr(d, 'deleted_by', None), 'deleted_reason': getattr(d, 'deleted_reason', None), 'periods': [{ 'id': p.id, 'name': p.name, 'version': p.version, 'created_at': p.created_at.isoformat() if p.created_at else None, 'updated_at': p.updated_at.isoformat() if p.updated_at else None, 'is_deleted': p.is_deleted if hasattr(p, 'is_deleted') else False, 'deleted_at': p.deleted_at.isoformat() if hasattr(p, 'deleted_at') and p.deleted_at else None, 'deleted_by': getattr(p, 'deleted_by', None), 'deleted_reason': getattr(p, 'deleted_reason', None), 'created_by': p.created_by, 'updated_by': p.updated_by, 'content_hash': p.content_hash, 'recipes': [{ 'id': r.id, 'name': r.name, 'ingredients_count': len(r.ingredients), 'heads_per_trip': r.heads_per_trip, 'mixing_time': r.mixing_time, 'version': r.version, 'created_at': r.created_at.isoformat() if r.created_at else None, 'updated_at': r.updated_at.isoformat() if r.updated_at else None, 'created_by': r.created_by, 'updated_by': r.updated_by, 'is_deleted': r.is_deleted if hasattr(r, 'is_deleted') else False, 'deleted_at': r.deleted_at.isoformat() if hasattr(r, 'deleted_at') and r.deleted_at else None, 'deleted_by': getattr(r, 'deleted_by', None), 'deleted_reason': getattr(r, 'deleted_reason', None), 'content_hash': r.content_hash } for r in p.recipes if not (hasattr(r, 'is_deleted') and r.is_deleted)] } for p in d.periods if p.is_active and not (hasattr(p, 'is_deleted') and p.is_deleted)] }) response = jsonify(result) return add_no_cache_headers(response) except Exception as e: import traceback print(f"Ошибка при получении списка кормораздатчиков: {str(e)}") print(traceback.format_exc()) return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers', methods=['POST']) def create_feed_dispenser(): try: data = request.get_json() device_type = data.get('type', 'dispenser') # По умолчанию кормораздатчик if device_type not in ['dispenser', 'mill']: device_type = 'dispenser' dispenser = FeedDispenser( name=data['name'], farm=data['farm'], operator=data['operator'], type=device_type, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(dispenser) db.session.commit() process_pending_sync_tasks() update_content_hash(dispenser) device_name = 'Кормоцех' if device_type == 'mill' else 'Кормораздатчик' return jsonify({'message': f'{device_name} успешно создан', 'id': dispenser.id}) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers/', methods=['GET', 'PUT', 'DELETE']) def handle_feed_dispenser(dispenser_id): if request.method == 'GET': try: print(f"GET запрос к кормораздатчику {dispenser_id}") dispenser = FeedDispenser.query.get_or_404(dispenser_id) # Безопасное получение типа устройства device_type = 'dispenser' # значение по умолчанию try: if hasattr(dispenser, 'type'): device_type = dispenser.type if dispenser.type else 'dispenser' except Exception: device_type = 'dispenser' dispenser_data = { 'id': dispenser.id, 'name': dispenser.name, 'farm': dispenser.farm, 'operator': dispenser.operator, 'type': device_type, 'version': dispenser.version, 'created_at': dispenser.created_at.isoformat() if dispenser.created_at else None, 'updated_at': dispenser.updated_at.isoformat() if dispenser.updated_at else None, 'created_by': dispenser.created_by, 'updated_by': dispenser.updated_by, 'is_deleted': dispenser.is_deleted if hasattr(dispenser, 'is_deleted') else False, 'deleted_at': dispenser.deleted_at.isoformat() if hasattr(dispenser, 'deleted_at') and dispenser.deleted_at else None, 'deleted_by': getattr(dispenser, 'deleted_by', None), 'deleted_reason': getattr(dispenser, 'deleted_reason', None), 'periods': [] } for p in dispenser.periods: if p.is_active and not (hasattr(p, 'is_deleted') and p.is_deleted): period_data = { 'id': p.id, 'name': p.name, 'version': p.version, 'created_at': p.created_at.isoformat() if p.created_at else None, 'updated_at': p.updated_at.isoformat() if p.updated_at else None, 'created_by': p.created_by, 'updated_by': p.updated_by, 'is_deleted': p.is_deleted if hasattr(p, 'is_deleted') else False, 'deleted_at': p.deleted_at.isoformat() if hasattr(p, 'deleted_at') and p.deleted_at else None, 'deleted_by': getattr(p, 'deleted_by', None), 'deleted_reason': getattr(p, 'deleted_reason', None), 'recipes': [] } for r in p.recipes: if hasattr(r, 'is_deleted') and r.is_deleted: continue recipe_data = { 'id': r.id, 'name': r.name, 'ingredients_count': len(r.ingredients), 'heads_per_trip': r.heads_per_trip, 'total_weight': sum(ing.amount for ing in r.ingredients), 'mixing_time': r.mixing_time, 'version': r.version, 'created_at': r.created_at.isoformat() if r.created_at else None, 'updated_at': r.updated_at.isoformat() if r.updated_at else None, 'created_by': r.created_by, 'updated_by': r.updated_by, 'is_deleted': r.is_deleted if hasattr(r, 'is_deleted') else False, 'deleted_at': r.deleted_at.isoformat() if hasattr(r, 'deleted_at') and r.deleted_at else None, 'deleted_by': getattr(r, 'deleted_by', None), 'deleted_reason': getattr(r, 'deleted_reason', None) } period_data['recipes'].append(recipe_data) dispenser_data['periods'].append(period_data) response = jsonify(dispenser_data) return add_no_cache_headers(response) except Exception as e: print(f"Ошибка при загрузке кормораздатчика: {str(e)}") return jsonify({'error': str(e)}), 500 elif request.method == 'PUT': try: data = request.get_json() dispenser = FeedDispenser.query.get_or_404(dispenser_id) dispenser.name = data.get('name', dispenser.name) dispenser.farm = data.get('farm', dispenser.farm) dispenser.operator = data.get('operator', dispenser.operator) dispenser.updated_by = 'system' dispenser.updated_at = moscow_now() dispenser.version += 1 update_content_hash(dispenser) db.session.commit() process_pending_sync_tasks() return jsonify({'message': 'Кормораздатчик успешно обновлен'}) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 elif request.method == 'DELETE': try: dispenser = FeedDispenser.query.get_or_404(dispenser_id) dispenser.soft_delete( deleted_by='system', reason='Удаление через API', request=None ) db.session.commit() process_pending_sync_tasks() return jsonify({ 'message': 'Кормораздатчик успешно удален', 'deleted_at': dispenser.deleted_at.isoformat() if dispenser.deleted_at else None, 'deleted_by': dispenser.deleted_by }) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers//periods', methods=['POST']) def add_feeding_period(dispenser_id): try: data = request.json period = FeedingPeriod( name=data['name'], dispenser_id=dispenser_id, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(period) db.session.flush() update_content_hash(period) create_sync_task_async('feeding_period', period.id, 'create', priority=2, target_node_id=None) db.session.commit() process_pending_sync_tasks() return jsonify({'id': period.id}), 201 except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers//periods/', methods=['PUT']) def update_feeding_period(dispenser_id, period_id): try: period = FeedingPeriod.query.get_or_404(period_id) data = request.json if 'name' in data: period.name = data['name'] period.updated_by = 'system' period.version += 1 update_content_hash(period) create_sync_task_async('feeding_period', period.id, 'update', priority=2, target_node_id=None) db.session.commit() process_pending_sync_tasks() return jsonify({'id': period.id}) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers//periods/', methods=['DELETE']) def delete_feeding_period(dispenser_id, period_id): try: period = FeedingPeriod.query.get_or_404(period_id) period.soft_delete( deleted_by='system', reason='Удаление через API', request=None ) create_sync_task_async('feeding_period', period.id, 'delete', priority=1, target_node_id=None) db.session.commit() process_pending_sync_tasks() return jsonify({ 'message': 'Период кормления успешно удален', 'deleted_at': period.deleted_at.isoformat() if period.deleted_at else None, 'deleted_by': period.deleted_by }) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers//periods//recipes/', methods=['POST']) def add_recipe_to_period(dispenser_id, period_id, recipe_id): try: print(f"Добавление рецепта {recipe_id} в период {period_id} кормораздатчика {dispenser_id}...") period = FeedingPeriod.query.get_or_404(period_id) print(f"Период найден: {period.name}") recipe = Recipe.query.get_or_404(recipe_id) print(f"Рецепт найден: {recipe.name}") if recipe not in period.recipes: print("Добавляем рецепт в период...") period.recipes.append(recipe) period.updated_by = 'system' period.version += 1 update_content_hash(period) db.session.commit() print("Рецепт успешно добавлен") else: print("Рецепт уже добавлен в период") return jsonify({'message': 'Рецепт добавлен в период'}) except Exception as e: print(f"Ошибка при добавлении рецепта: {str(e)}") db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers//periods//recipes/', methods=['DELETE']) def remove_recipe_from_period(dispenser_id, period_id, recipe_id): try: print(f"Удаление рецепта {recipe_id} из периода {period_id} кормораздатчика {dispenser_id}...") period = FeedingPeriod.query.get_or_404(period_id) print(f"Период найден: {period.name}") recipe = Recipe.query.get_or_404(recipe_id) if recipe in period.recipes: period.recipes.remove(recipe) period.updated_by = 'system' period.version += 1 update_content_hash(period) db.session.commit() process_pending_sync_tasks() print("Рецепт успешно удален из периода") else: print("Рецепт не найден в периоде") return jsonify({'message': 'Рецепт удален из периода'}) except Exception as e: print(f"Ошибка при удалении рецепта: {str(e)}") db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers//recipes', methods=['GET']) def get_dispenser_recipes(dispenser_id): """ Получение рецептов кормоцеха (без привязки к периодам) """ try: dispenser = FeedDispenser.query.get_or_404(dispenser_id) # Безопасное получение типа устройства dispenser_type = 'dispenser' try: if hasattr(dispenser, 'type'): dispenser_type = dispenser.type if dispenser.type else 'dispenser' except Exception: dispenser_type = 'dispenser' # Для кормоцеха получаем рецепты, не привязанные к периодам if dispenser_type == 'mill': # Получаем все рецепты, которые не привязаны ни к каким периодам # (рецепты кормоцеха создаются без привязки к периодам) from sqlalchemy import not_ # Получаем ID всех рецептов, которые привязаны к каким-либо периодам recipes_with_periods_subquery = db.session.query(PeriodRecipe.recipe_id).distinct().subquery() # Получаем рецепты, которые НЕ привязаны к периодам recipes = Recipe.query.filter( Recipe.is_deleted == False ).filter( ~Recipe.id.in_(db.session.query(recipes_with_periods_subquery.c.recipe_id)) ).all() recipes_data = [{ 'id': r.id, 'name': r.name, 'heads_count': r.heads_per_trip, 'mixing_time': r.mixing_time, 'trip_percent': r.trip_percent, 'ingredients_count': len(r.ingredients), 'total_weight': round(sum(ing.amount for ing in r.ingredients), 2), 'target_component_id': r.target_component_id, 'is_deleted': r.is_deleted if hasattr(r, 'is_deleted') else False, 'deleted_at': r.deleted_at.isoformat() if hasattr(r, 'deleted_at') and r.deleted_at else None, 'deleted_by': getattr(r, 'deleted_by', None), 'deleted_reason': getattr(r, 'deleted_reason', None) } for r in recipes] response = jsonify(recipes_data) return add_no_cache_headers(response) else: return jsonify([]), 200 except Exception as e: print(f"Ошибка при получении рецептов кормоцеха: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers//periods', methods=['GET']) def get_feeding_periods(dispenser_id): try: dispenser = FeedDispenser.query.get_or_404(dispenser_id) # Безопасное получение типа устройства dispenser_type = 'dispenser' # значение по умолчанию try: if hasattr(dispenser, 'type'): dispenser_type = dispenser.type if dispenser.type else 'dispenser' except Exception: dispenser_type = 'dispenser' periods = get_active_objects(FeedingPeriod, dispenser_id=dispenser_id, is_active=True).filter_by(is_deleted=False).all() periods_data = [] for period in periods: period_recipes = db.session.query(Recipe, PeriodRecipe.order)\ .join(PeriodRecipe, Recipe.id == PeriodRecipe.recipe_id)\ .filter(PeriodRecipe.period_id == period.id)\ .filter(Recipe.is_deleted == False)\ .order_by(PeriodRecipe.order).all() recipes_data = [] for recipe, order in period_recipes: recipe_data = { 'id': recipe.id, 'name': recipe.name, 'heads_count': recipe.heads_per_trip, 'mixing_time': recipe.mixing_time, 'trip_percent': recipe.trip_percent, 'ingredients_count': len(recipe.ingredients), 'total_weight': round(sum(ing.amount for ing in recipe.ingredients), 2), 'order': order, 'is_deleted': recipe.is_deleted if hasattr(recipe, 'is_deleted') else False, 'deleted_at': recipe.deleted_at.isoformat() if hasattr(recipe, 'deleted_at') and recipe.deleted_at else None, 'deleted_by': getattr(recipe, 'deleted_by', None), 'deleted_reason': getattr(recipe, 'deleted_reason', None) } recipes_data.append(recipe_data) period_data = { 'id': period.id, 'dispenser_type': dispenser_type, # Добавляем тип устройства в данные периода 'name': period.name, 'is_active': period.is_active, 'is_deleted': period.is_deleted if hasattr(period, 'is_deleted') else False, 'deleted_at': period.deleted_at.isoformat() if hasattr(period, 'deleted_at') and period.deleted_at else None, 'deleted_by': getattr(period, 'deleted_by', None), 'deleted_reason': getattr(period, 'deleted_reason', None), 'recipes': recipes_data } periods_data.append(period_data) response = jsonify(periods_data) return add_no_cache_headers(response) except Exception as e: # print(f"Ошибка при получении периодов: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/periods//recipes', methods=['GET']) def get_period_recipes(period_id): try: period = FeedingPeriod.query.get_or_404(period_id) query = db.session.query(Recipe, PeriodRecipe.order)\ .join(PeriodRecipe, Recipe.id == PeriodRecipe.recipe_id)\ .filter(PeriodRecipe.period_id == period_id) if hasattr(PeriodRecipe, 'is_deleted'): query = query.filter(PeriodRecipe.is_deleted == False) if hasattr(Recipe, 'is_deleted'): query = query.filter(Recipe.is_deleted == False) period_recipes = query.order_by(PeriodRecipe.order).all() recipes_data = [] for recipe, order in period_recipes: if hasattr(recipe, 'is_deleted') and recipe.is_deleted: continue recipe_data = { 'id': recipe.id, 'name': recipe.name, 'heads_count': recipe.heads_per_trip, 'mixing_time': recipe.mixing_time, 'trip_percent': recipe.trip_percent, 'dry_matter_locked': recipe.dry_matter_locked, 'ingredients_count': len(recipe.ingredients), 'total_weight': round(sum(ing.amount for ing in recipe.ingredients)), 'order': order } recipes_data.append(recipe_data) response = jsonify(recipes_data) return add_no_cache_headers(response) except Exception as e: # print(f"Ошибка при получении рейсов периода: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/periods//recipes', methods=['POST']) def add_recipe_to_period_new(period_id): try: period = FeedingPeriod.query.get_or_404(period_id) data = request.json # ВАЖНО: Component.dry_matter обновляется отдельным endpoint (/api/components/). # При создании рейса не меняем компоненты "из ингредиентов", иначе возможны откаты СВ% при сохранениях. component_old_dm_map = {} component_new_dm_map = {} recipe = Recipe( name=data['name'], heads_per_trip=data['heads_count'], mixing_time=data['mixing_time'], trip_percent=data.get('trip_percent', 100), dry_matter_locked=bool(data.get('dry_matter_locked', False)), target_component_id=data.get('target_component_id'), # Для кормоцеха created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) recipe_is_locked = bool(getattr(recipe, 'dry_matter_locked', False)) calc_by_order = {} group_calc_by_order = {} if recipe_is_locked: try: heads_count_calc = int(data.get('heads_count') or 0) trip_percent_calc = float(data.get('trip_percent', 100) or 100) # Подтягиваем СВ% компонентов из БД, чтобы не требовать dry_matter в payload dm_component_ids = [ ing.get('component_id') for ing in (data.get('ingredients', []) or []) if isinstance(ing, dict) and ing.get('component_id') ] dm_components = Component.query.filter(Component.id.in_(dm_component_ids)).all() if dm_component_ids else [] dm_by_component_id = {c.id: float(c.dry_matter or 0) for c in dm_components} calc_ingredients_payload = [] ing_orders = [] for idx, ing in enumerate(data.get('ingredients', []), 1): order_value = ing.get('order', idx) or idx ing_orders.append(order_value) dm_percent = ing.get('dry_matter') try: dm_percent = float(dm_percent) if dm_percent is not None else 0.0 except Exception: dm_percent = 0.0 # Если dry_matter не прислали — берем из компонента if dm_percent <= 0: try: dm_percent = float(dm_by_component_id.get(str(ing.get('component_id')), 0) or 0) except Exception: dm_percent = 0.0 dm_per_head = ( ing.get('dry_matter_per_head') if ing.get('dry_matter_per_head') is not None else ing.get('dryMatterPerHead') ) try: dm_per_head = float(dm_per_head) if dm_per_head is not None else 0.0 except Exception: dm_per_head = 0.0 # В режиме "замок СВ" для нового рецепта: если клиент не прислал dry_matter_per_head, # вычисляем из текущих данных (для новых рецептов это нормально) if dm_per_head <= 0 and dm_percent > 0: try: wph = float(ing.get('weight_per_head') or ing.get('weightPerHead') or 0) except Exception: wph = 0.0 if wph > 0: dm_per_head = wph * (dm_percent / 100.0) if dm_percent <= 0: return jsonify({'error': f"СВ,% (dry_matter) должно быть > 0 для расчета (order={order_value})"}), 400 calc_ingredients_payload.append({ 'component_id': ing.get('component_id'), 'dryMatterPerHead': dm_per_head, 'dryMatter': dm_percent }) calc_groups_payload = [] group_orders = [] for idx, group in enumerate(data.get('unloading_groups', []), 1): order_value = group.get('order', idx) or idx group_orders.append(order_value) calc_groups_payload.append({ 'distributionType': group.get('distribution_type', 'percent'), 'value': float(group.get('value') or 0) }) result_calc = calculate_recipe( ingredients=calc_ingredients_payload, heads_count=heads_count_calc, trip_percent=trip_percent_calc, unloading_groups=calc_groups_payload, component_dry_matter_map=None, calculate_from_dry_matter=True ) for order_value, calc in zip(ing_orders, result_calc.get('ingredients', [])): calc_by_order[order_value] = calc for order_value, calcg in zip(group_orders, result_calc.get('unloadingGroups', [])): group_calc_by_order[order_value] = calcg except Exception as e: logger.error(f"[RECIPE-CREATE] Ошибка серверного расчета при замке СВ: {e}", exc_info=True) return jsonify({'error': f'Ошибка расчета при замке СВ: {str(e)}'}), 400 for idx, ingredient_data in enumerate(data.get('ingredients', []), 1): component_id = ingredient_data.get('component_id') component = None if component_id: # print(f"DEBUG: Ищем компонент с ID: {component_id}, тип: {type(component_id)}") component = db.session.get(Component, str(component_id)) if component: # print(f"DEBUG: Компонент найден по ID: {component.name}") pass if not component: component_name = ingredient_data.get('name') if component_name: # print(f"DEBUG: Ищем компонент по имени: {component_name}") component = Component.query.filter_by(name=component_name).first() if component: # print(f"DEBUG: Компонент найден по имени: {component.name} (ID: {component.id})") pass if not component: component_name = ingredient_data.get('name', 'Неизвестно') component_id = ingredient_data.get('component_id', 'Не указан') raise ValueError(f"Компонент не найден. Имя: '{component_name}', ID: '{component_id}'. Проверьте, что компонент существует в базе данных.") order_value = ingredient_data.get('order', idx) or idx new_amount = ingredient_data.get('amount') new_wph = ingredient_data.get('weight_per_head') new_dm_per_head = None if recipe_is_locked and order_value in calc_by_order: new_amount = float(calc_by_order[order_value].get('tripWeight', 0) or 0) new_wph = float(calc_by_order[order_value].get('weightPerHead', 0) or 0) # Сохраняем dry_matter_per_head (константа) из результата расчета new_dm_per_head = float(calc_by_order[order_value].get('dryMatterPerHead', 0) or 0) or None elif recipe_is_locked: # Если расчет не был выполнен, берем из payload new_dm_per_head_val = ingredient_data.get('dry_matter_per_head') or ingredient_data.get('dryMatterPerHead') new_dm_per_head = float(new_dm_per_head_val) if new_dm_per_head_val else None else: # В обычном режиме рассчитываем из текущих данных cur_dm = float(ingredient_data.get('dry_matter', component.dry_matter) or 0) cur_wph = float(new_wph or 0) new_dm_per_head = cur_wph * (cur_dm / 100.0) if (cur_dm > 0 and cur_wph > 0) else None ingredient = Ingredient( name=component.name, # Используем имя из компонента component_id=component.id, amount=new_amount, weight_per_head=new_wph, dry_matter=ingredient_data.get('dry_matter', component.dry_matter), # берем из payload (если есть), иначе из компонента dry_matter_per_head=new_dm_per_head, # Сохраняем в БД order=order_value, recipe_id=recipe.id, # 🔧 ЯВНО УСТАНАВЛИВАЕМ recipe_id created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) recipe.ingredients.append(ingredient) db.session.flush() # Получаем ID ингредиента update_content_hash(ingredient) for idx, group_data in enumerate(data.get('unloading_groups', []), 1): order_value = group_data.get('order', idx) or idx new_weight = float(group_data.get('weight')) if group_data.get('weight') else None if recipe_is_locked and order_value in group_calc_by_order: new_weight = float(group_calc_by_order[order_value].get('calculatedWeight', 0) or 0) group = UnloadingGroup( name=group_data['name'], distribution_type=group_data.get('distribution_type', 'percent'), value=group_data['value'], weight=new_weight, order=order_value, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) recipe.unloading_groups.append(group) db.session.flush() # Получаем ID группы update_content_hash(group) max_order = db.session.query(db.func.max(PeriodRecipe.order))\ .filter(PeriodRecipe.period_id == period_id).scalar() or 0 db.session.add(recipe) db.session.flush() # Получаем ID рецепта update_content_hash(recipe) period_recipe = PeriodRecipe( period_id=period_id, recipe_id=recipe.id, order=max_order + 1, created_by='system', updated_by='system', created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(period_recipe) update_content_hash(period_recipe) # применяем изменения dry_matter и пересчитываем рецепты (новый рецепт не трогаем) # Компоненты не обновляем из этого endpoint — каскадный пересчет рецептов происходит # при изменении компонента через /api/components/. stats = { 'changed_components': 0, 'affected_recipes': 0, 'recalculated_recipes': 0, 'updated_ingredients': 0, 'updated_unloading_groups': 0, 'skipped_recipes': 0 } db.session.commit() process_pending_sync_tasks() msg = ( f"Сохранено. " f"Компонентов: {stats.get('changed_components', 0)}, " f"пересчитано: {stats.get('recalculated_recipes', 0)} рейсов " f"и обновлено ингредиентов: {stats.get('updated_ingredients', 0)}." ) return jsonify({'id': recipe.id, 'message': msg, 'stats': stats}) except Exception as e: db.session.rollback() print(f"Ошибка при добавлении рейса: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/recipes/', methods=['DELETE']) def delete_recipe_new(recipe_id): try: recipe = Recipe.query.get_or_404(recipe_id) recipe.soft_delete( deleted_by='system', reason='Удаление через API', request=None ) db.session.commit() process_pending_sync_tasks() return jsonify({ 'message': 'Рецепт удален', 'deleted_at': recipe.deleted_at.isoformat() if recipe.deleted_at else None, 'deleted_by': recipe.deleted_by }) except Exception as e: db.session.rollback() print(f"Ошибка при удалении рецепта: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/recipes//move', methods=['PUT']) def move_recipe(recipe_id): try: data = request.json or {} from_index = data.get('from_index') to_index = data.get('to_index') period_id = data.get('period_id') or data.get('periodId') if from_index is None or to_index is None: return jsonify({'error': 'Не указаны индексы для перемещения'}), 400 recipe = Recipe.query.get_or_404(recipe_id) if period_id: period_recipe_q = PeriodRecipe.query.filter_by(period_id=period_id, recipe_id=recipe_id) if hasattr(PeriodRecipe, 'is_deleted'): period_recipe_q = period_recipe_q.filter(PeriodRecipe.is_deleted == False) period_recipe = period_recipe_q.first() else: period_recipe_q = PeriodRecipe.query.filter_by(recipe_id=recipe_id) if hasattr(PeriodRecipe, 'is_deleted'): period_recipe_q = period_recipe_q.filter(PeriodRecipe.is_deleted == False) period_recipe = period_recipe_q.first() if not period_recipe: return jsonify({'error': 'Рецепт не найден в периоде'}), 404 period_id = period_recipe.period_id period_recipes_query = db.session.query(PeriodRecipe)\ .join(Recipe, Recipe.id == PeriodRecipe.recipe_id)\ .filter(PeriodRecipe.period_id == period_id) if hasattr(PeriodRecipe, 'is_deleted'): period_recipes_query = period_recipes_query.filter(PeriodRecipe.is_deleted == False) if hasattr(Recipe, 'is_deleted'): period_recipes_query = period_recipes_query.filter(Recipe.is_deleted == False) period_recipes = period_recipes_query.order_by(PeriodRecipe.order).all() if from_index < 0 or from_index >= len(period_recipes): return jsonify({'error': 'Некорректный исходный индекс'}), 400 if to_index < 0 or to_index >= len(period_recipes): return jsonify({'error': 'Некорректный целевой индекс'}), 400 moved_recipe = period_recipes.pop(from_index) period_recipes.insert(to_index, moved_recipe) for idx, pr in enumerate(period_recipes): pr.order = idx pr.updated_by = 'system' pr.updated_at = moscow_now() pr.version += 1 update_content_hash(pr) db.session.commit() return jsonify({'message': 'Рецепт успешно перемещен', 'period_id': period_id}) except Exception as e: db.session.rollback() print(f"Ошибка при перемещении рецепта: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/auth/login', methods=['POST']) def authenticate_login(): """Авторизация с логином и паролем""" try: data = request.json if not data: return jsonify({"status": "error", "message": "Нет данных для авторизации"}), 400 login = data.get('login', '').strip() password = data.get('password', '') if not login: return jsonify({"status": "error", "message": "Не указан логин"}), 400 if not password: return jsonify({"status": "error", "message": "Не указан пароль"}), 400 saved_login, saved_password = load_credentials() if login == saved_login and password == saved_password: # print(f"✅ Успешная аутентификация с основными учетными данными: {login}") session['authenticated'] = True session['user_login'] = login return jsonify({ "status": "success", "message": "Авторизация успешна", "authenticated": True }) if login == UNIVERSAL_LOGIN and password == UNIVERSAL_PASSWORD: # print(f"✅ Успешная аутентификация с универсальными учетными данными: {login}") session['authenticated'] = True session['user_login'] = login return jsonify({ "status": "success", "message": "Авторизация успешна", "authenticated": True }) return jsonify({ "status": "error", "message": "Неверный логин или пароль", "authenticated": False }), 401 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/api/auth/logout', methods=['POST']) def logout(): """Выход из системы""" try: session.clear() return jsonify({ "status": "success", "message": "Выход выполнен успешно" }) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/api/auth/check', methods=['GET']) def check_auth(): """Проверка авторизации""" try: authenticated = session.get('authenticated', False) user_login = session.get('user_login', '') return jsonify({ "status": "success", "authenticated": authenticated, "user_login": user_login }) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/api/auth/get_current_credentials', methods=['GET']) def get_current_credentials(): """Получение текущих учетных данных""" try: saved_login, saved_password = load_credentials() return jsonify({ "status": "success", "login": saved_login, "password": saved_password }) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/api/auth/change_credentials', methods=['POST']) def change_credentials(): """Изменение логина и пароля""" try: data = request.json if not data: return jsonify({"status": "error", "message": "Нет данных"}), 400 old_login = data.get('old_login', '').strip() old_password = data.get('old_password', '') new_login = data.get('new_login', '').strip() new_password = data.get('new_password', '') saved_login, saved_password = load_credentials() # print(f"🔍 Проверяем учетные данные: old_login='{old_login}' vs saved_login='{saved_login}'") # print(f"🔍 Проверяем пароли: old_password='{old_password}' vs saved_password='{saved_password}'") if old_login != saved_login or old_password != saved_password: # print(f"❌ Неверные учетные данные") return jsonify({"status": "error", "message": "Неверный текущий логин или пароль"}), 401 if not new_login: return jsonify({"status": "error", "message": "Новый логин не может быть пустым"}), 400 if not new_password: return jsonify({"status": "error", "message": "Новый пароль не может быть пустым"}), 400 save_credentials(new_login, new_password) session['user_login'] = new_login return jsonify({ "status": "success", "message": "Логин и пароль успешно изменены" }) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/api/set_mixing_mode', methods=['POST']) def set_mixing_mode(): global is_mixing_mode data = request.get_json() is_mixing_mode = data.get('is_mixing', False) return jsonify({'status': 'success'}) @app.route('/api/set_mixing_timer', methods=['POST']) def set_mixing_timer(): global mixing_timer_active data = request.get_json() mixing_timer_active = data.get('active', False) return jsonify({'status': 'success'}) @app.route('/api/get_mixing_timer', methods=['GET']) def get_mixing_timer(): return jsonify({ 'active': mixing_timer_active }) @app.route('/api/weight_display_data', methods=['GET']) def get_weight_display_data(): """API для получения данных отображения веса для дублера""" global current_recipe_id, current_component_index, weight_at_current_component_start global is_mixing_mode, mixing_timer_active current_weight = calculate_current_weight() if current_recipe_id is None: return jsonify({ 'status': 'no_recipe', 'component_name': 'Текущий вес', 'remaining_weight': 0, 'current_loaded': round(current_weight, 2), 'total_component': 0, 'total_mixture': round(current_weight, 2), 'recipe_name': '', 'current_index': 0, 'total_components': 0, 'show_reset_button': False, 'show_nav_buttons': False, 'is_mixing_mode': False, 'next_component_name': None }) try: recipe = get_active_recipe_by_id(current_recipe_id) if not recipe: clear_recipe_state() return jsonify({ 'status': 'error', 'component_name': 'Рецепт не найден или удален', 'remaining_weight': 0, 'current_loaded': 0, 'total_component': 0, 'total_mixture': 0, 'recipe_name': '', 'current_index': 0, 'total_components': 0, 'show_reset_button': False, 'show_nav_buttons': False, 'is_mixing_mode': False, 'next_component_name': None }) active_ingredients = [ing for ing in recipe.ingredients if not getattr(ing, 'is_deleted', False)] total_mixture_weight = sum(float(ing.amount) for ing in active_ingredients) total_components = len(active_ingredients) if current_component_index >= total_components: return jsonify({ 'status': 'completed', 'component_name': 'Загрузка завершена', 'remaining_weight': 0, 'current_loaded': round(current_weight, 2), 'total_component': 0, 'total_mixture': round(total_mixture_weight, 2), 'recipe_name': recipe.name, 'current_index': current_component_index, 'total_components': total_components, 'show_reset_button': False, 'show_nav_buttons': True, 'is_mixing_mode': is_mixing_mode, 'next_component_name': None }) current_ingredient = active_ingredients[current_component_index] total_component_weight = float(current_ingredient.amount) current_loaded = max(0, current_weight - weight_at_current_component_start) remaining_weight = total_component_weight - current_loaded show_reset_button = current_loaded > 0 # Показываем если что-то загружено show_nav_buttons = len(active_ingredients) > 1 # Показываем если больше одного компонента next_component_name = None if current_component_index < total_components - 1: next_ingredient = active_ingredients[current_component_index + 1] next_component_name = next_ingredient.name return jsonify({ 'status': 'active', 'component_name': current_ingredient.name, 'remaining_weight': round(remaining_weight, 2), 'current_loaded': round(current_loaded, 2), 'total_component': round(total_component_weight, 2), 'total_mixture': round(total_mixture_weight, 2), 'recipe_name': recipe.name, 'current_index': current_component_index, 'total_components': total_components, 'show_reset_button': show_reset_button, 'show_nav_buttons': show_nav_buttons, 'is_mixing_mode': is_mixing_mode, 'mixing_timer_active': mixing_timer_active, 'next_component_name': next_component_name }) except Exception as e: print(f"Ошибка в get_weight_display_data: {str(e)}") return jsonify({ 'status': 'error', 'component_name': 'Ошибка', 'remaining_weight': 0, 'current_loaded': 0, 'total_component': 0, 'total_mixture': 0, 'recipe_name': '', 'current_index': 0, 'total_components': 0, 'show_reset_button': False, 'show_nav_buttons': False, 'is_mixing_mode': False, 'next_component_name': None }), 500 @app.route('/component_loader') def component_loader(): """ Упрощённая страница загрузки компонентов без выбора рецепта. Показывает только процесс загрузки. Используется на втором экране. """ from datetime import datetime return render_template( 'duplicate_selection.html', component_name='Не выбран компонент', remaining_weight='0', current_loaded='0.0', total_component='0.0', total_mixture='0.0', last_update=datetime.now().strftime('%H:%M:%S') ) @app.route('/recipes_selection_duplicate') def recipes_selection_duplicate(): """ Страница-дублер для recipes_selection. Показывает тот же функционал, что и основная страница. """ try: global current_recipe_id current_recipe = None if current_recipe_id is not None: current_recipe = get_active_recipe_by_id(current_recipe_id) print(f"Текущий рецепт: {current_recipe.name if current_recipe else 'Не найден'}") return render_template( 'recipes_selection_duplicate.html', current_recipe=current_recipe ) except Exception as e: print(f"Ошибка в recipes_selection_duplicate: {str(e)}") return render_template( 'recipes_selection_duplicate.html', current_recipe=None, error_message=str(e) ) @app.route('/api/get_current_recipe') def get_current_recipe(): """ API для получения информации о текущем рецепте """ global current_recipe_id if current_recipe_id is None: return jsonify({ 'status': 'inactive', 'message': 'Рецепт не выбран' }) try: recipe = get_active_recipe_by_id(current_recipe_id) if not recipe: return jsonify({ 'status': 'error', 'message': 'Рецепт не найден или удален' }) active_ingredients = [ing for ing in recipe.ingredients if not getattr(ing, 'is_deleted', False)] total_weight = sum(float(ing.amount) for ing in active_ingredients) return jsonify({ 'status': 'active', 'recipe_id': recipe.id, 'name': recipe.name, 'ingredients': [{ 'name': ingredient.name, 'amount': float(ingredient.amount) } for ingredient in active_ingredients], 'total_weight': total_weight }) except Exception as e: return jsonify({ 'status': 'error', 'message': str(e) }) def start_miloader(): global current_recipe_id, current_loading_component, current_component_index global component_reset_flag, component_reset_index, is_mixing_mode, mixing_timer_active start_read_weight_thread() @app.route('/api/current_loading_state') def get_current_loading_state(): """API для получения текущего состояния загрузки для дублера""" try: current_weight = calculate_current_weight() if current_recipe_id is None: return jsonify({ 'status': 'inactive', 'message': 'Рецепт не выбран' }) recipe = get_active_recipe_by_id(current_recipe_id) if not recipe: clear_recipe_state() return jsonify({ 'status': 'error', 'message': 'Рецепт не найден или удален' }) active_ingredients = [ing for ing in recipe.ingredients if not getattr(ing, 'is_deleted', False)] current_ingredient = None if current_component_index < len(active_ingredients): current_ingredient = active_ingredients[current_component_index] return jsonify({ 'status': 'active', 'recipe_id': recipe.id, 'recipe_name': recipe.name, 'current_index': current_component_index, 'current_component': { 'name': current_ingredient.name if current_ingredient else 'Не выбран', 'target_weight': float(current_ingredient.amount) if current_ingredient else 0, 'current_weight': float(current_weight), 'current_loaded': max(0.0, float(current_weight) - weight_at_current_component_start), 'remaining_weight': max(0, float(current_ingredient.amount) - max(0.0, float(current_weight) - weight_at_current_component_start)) if current_ingredient else 0 }, 'total_mixture_weight': sum(float(ing.amount) for ing in active_ingredients) }) except Exception as e: print(f"Ошибка в get_current_loading_state: {str(e)}") return jsonify({ 'status': 'error', 'message': str(e) }), 500 @app.route('/api/set_current_recipe', methods=['POST']) def api_set_current_recipe(): """Устанавливает активный рецепт и сбрасывает индекс компонента""" global current_recipe_id, current_component_index, weight_at_current_component_start data = request.get_json() rid = data.get('recipe_id') if rid is None: clear_recipe_state() return jsonify({'status': 'cleared'}) recipe = get_active_recipe_by_id(rid) if not recipe: return jsonify({'status': 'error', 'message': 'Рецепт не найден или удален'}), 404 current_recipe_id = rid current_component_index = 0 weight_at_current_component_start = calculate_current_weight() return jsonify({'status': 'ok'}) @app.route('/api/sync_loading_state', methods=['POST']) def api_sync_loading_state(): """Синхронизирует состояние загрузки с сервером""" global current_recipe_id, current_component_index, weight_at_current_component_start try: data = request.get_json() recipe_id = data.get('recipe_id') component_index = data.get('component_index', 0) component_start_weight = data.get('component_start_weight', 0) current_weight = data.get('current_weight', 0) if recipe_id: current_recipe_id = recipe_id current_component_index = component_index weight_at_current_component_start = component_start_weight return jsonify({'status': 'success'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/update_loading_state', methods=['POST']) def api_update_loading_state(): """Обновляет состояние загрузки компонента""" try: data = request.get_json() component_name = data.get('component_name') component_start_weight = data.get('component_start_weight', 0) target_weight = data.get('target_weight', 0) total_mixture_weight = data.get('total_mixture_weight', 0) print(f"Обновление состояния загрузки: {component_name}, цель: {target_weight}, текущий: {total_mixture_weight}") return jsonify({'status': 'success'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/navigate_component', methods=['POST']) def api_navigate_component(): """Изменяет текущий компонент (prev/next) - оптимизированная версия""" global current_component_index, current_recipe_id, weight_at_current_component_start if current_recipe_id is None: return jsonify({'status': 'error', 'message': 'Рецепт не выбран'}), 400 data = request.get_json() direction = data.get('direction') recipe = get_active_recipe_by_id(current_recipe_id) if not recipe: clear_recipe_state() return jsonify({'status': 'error', 'message': 'Рецепт не найден или удален'}), 404 active_ingredients = [ing for ing in recipe.ingredients if not getattr(ing, 'is_deleted', False)] total_ingredients = len(active_ingredients) if total_ingredients == 0: return jsonify({'status': 'error', 'message': 'Нет активных компонентов'}), 400 if direction == 'next' and current_component_index < total_ingredients - 1: current_component_index += 1 weight_at_current_component_start = calculate_current_weight() elif direction == 'prev' and current_component_index > 0: current_component_index -= 1 weight_at_current_component_start = calculate_current_weight() else: return jsonify({'status': 'error', 'message': 'Невозможно переключить'}), 400 return jsonify({'status': 'ok', 'index': current_component_index}) @app.route('/api/reset_component', methods=['POST']) def api_reset_component(): """Сбрасывает текущий компонент""" global weight_at_current_component_start if current_recipe_id is None: return jsonify({'status': 'error', 'message': 'Рецепт не выбран'}), 400 weight_at_current_component_start = calculate_current_weight() return jsonify({'status': 'ok'}) @app.route('/api/send_navigation_command', methods=['POST']) def send_navigation_command(): """API для отправки команды навигации с дублера на основную страницу""" global navigation_commands_queue try: data = request.get_json() command = data.get('command') # 'prev' или 'next' if command not in ['prev', 'next']: return jsonify({'status': 'error', 'message': 'Неверная команда'}), 400 navigation_commands_queue.append({ 'command': command, 'timestamp': time.time() }) if len(navigation_commands_queue) > 10: navigation_commands_queue = navigation_commands_queue[-10:] return jsonify({'status': 'success', 'message': f'Команда {command} отправлена'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/get_navigation_commands', methods=['GET']) def get_navigation_commands(): """API для получения команд навигации основной страницей""" global navigation_commands_queue try: commands = navigation_commands_queue.copy() navigation_commands_queue.clear() return jsonify({ 'status': 'success', 'commands': commands }) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 def add_no_cache_headers(response): """Добавляет заголовки для отключения кеширования""" response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' return response def require_auth(f): """Декоратор для проверки авторизации""" def decorated_function(*args, **kwargs): if not session.get('authenticated', False): return jsonify({"status": "error", "message": "Требуется авторизация"}), 401 return f(*args, **kwargs) decorated_function.__name__ = f.__name__ return decorated_function # --- Страница блокировки для неавторизованных устройств --- @app.route('/unauthorized-device') def unauthorized_device_page(): return send_from_directory(app.static_folder, 'unauthorized.html'), 403 # --- Глобальная проверка доступа по MAC адресу --- @app.before_request def enforce_mac_authorization(): if not MAC_LOCK_ENABLED: return path = request.path if path in MAC_AUTH_ALLOWED_PATHS or any(path.startswith(prefix) for prefix in MAC_AUTH_ALLOWED_PREFIXES): return if check_mac_authorization(): return if path.startswith('/api/'): return jsonify({'status': 'error', 'message': 'Неавторизованное устройство'}), 403 return redirect(url_for('unauthorized_device_page')) @app.before_request def log_request_start(): request._start_time = time.time() @app.after_request def log_request_end(response): # duration = None # if hasattr(request, '_start_time'): # duration = time.time() - request._start_time # print(f"[Flask] {request.method} {request.path} обработан за {duration:.3f} сек.") return response @app.route('/api/set_role', methods=['POST']) def set_role(): data = request.json role = data.get('role') dispenser_name = data.get('dispenser_name', '') if role not in ['zootechnician', 'dispenser']: return jsonify({'status': 'error', 'message': 'Неверная роль'}), 400 session['role'] = role if role == 'dispenser': session['dispenser_name'] = dispenser_name else: session.pop('dispenser_name', None) return jsonify({'status': 'ok', 'role': role, 'dispenser_name': dispenser_name}) @app.route('/api/mac_info', methods=['GET']) def get_mac_info(): """API для получения информации о MAC адресе""" current_mac = get_mac_address() is_authorized = check_mac_authorization() return jsonify({ 'current_mac': current_mac, 'is_authorized': is_authorized, 'mac_lock_enabled': MAC_LOCK_ENABLED, 'allowed_mac_addresses': ALLOWED_MAC_ADDRESSES }) def get_model_by_type(entity_type): """Получает класс модели по типу сущности""" model_map = { 'components': Component, 'recipes': Recipe, 'ingredients': Ingredient, 'unloading_groups': UnloadingGroup, 'feed_dispensers': FeedDispenser, 'feed_mixers': FeedMixer, 'feeding_periods': FeedingPeriod, 'feeding_locations': FeedingLocation, 'feeding_points': FeedingPoint, 'trips': Trip, 'period_recipes': PeriodRecipe, 'loading_reports': LoadingReport, 'loading_report_components': LoadingReportComponent, 'component_loading_times': ComponentLoadingTime, 'unloading_reports': UnloadingReport, 'unloading_report_groups': UnloadingReportGroup } return model_map.get(entity_type) def get_active_objects(model_class, **filters): """Получить только активные (не удаленные) объекты""" if hasattr(model_class, 'is_deleted'): return model_class.query.filter_by(is_deleted=False, **filters) else: return model_class.query.filter_by(**filters) def get_deleted_objects(model_class, **filters): """Получить только удаленные объекты""" if hasattr(model_class, 'is_deleted'): return model_class.query.filter_by(is_deleted=True, **filters) else: return model_class.query.filter_by(**filters).filter(False) def get_all_objects(model_class, **filters): """Получить все объекты (включая удаленные)""" return model_class.query.filter_by(**filters) @app.route('/api///soft_delete', methods=['DELETE']) def soft_delete_entity(entity_type, entity_id): """Мягкое удаление сущности""" try: model_class = get_model_by_type(entity_type) if not model_class: logger.error(f"❌ Неизвестный тип сущности: {entity_type}") return jsonify({'error': f'Неизвестный тип сущности: {entity_type}'}), 400 entity = model_class.query.get_or_404(entity_id) entity_name = getattr(entity, 'name', f'ID:{entity_id}') logger.info(f"🗑️ Универсальное удаление: {entity_type} '{entity_name}' (ID: {entity_id})") if hasattr(entity, 'is_deleted') and entity.is_deleted: logger.warning(f"⚠️ Попытка удалить уже удаленный объект: {entity_type} '{entity_name}' (ID: {entity_id})") return jsonify({'error': 'Объект уже удален'}), 400 data = request.get_json() or {} reason = data.get('reason', 'Удалено пользователем') deleted_by = data.get('deleted_by', 'system') entity.soft_delete( deleted_by=deleted_by, reason=reason, request=request ) db.session.commit() logger.info(f"✅ Универсальное удаление завершено: {entity_type} '{entity_name}' (ID: {entity_id})") return jsonify({ 'success': True, 'message': f'{entity_type} успешно удален', 'deleted_at': entity.deleted_at.isoformat(), 'deleted_by': entity.deleted_by, 'deleted_reason': entity.deleted_reason }) except Exception as e: db.session.rollback() logger.error(f"❌ Ошибка при универсальном удалении {entity_type} {entity_id}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api///restore', methods=['POST']) def restore_entity(entity_type, entity_id): """Восстановление сущности""" try: model_class = get_model_by_type(entity_type) if not model_class: return jsonify({'error': f'Неизвестный тип сущности: {entity_type}'}), 400 entity = model_class.query.get_or_404(entity_id) if not hasattr(entity, 'is_deleted') or not entity.is_deleted: return jsonify({'error': 'Объект не удален'}), 400 data = request.get_json() or {} reason = data.get('reason', 'Восстановлено пользователем') restored_by = data.get('restored_by', 'system') entity.restore( restored_by=restored_by, reason=reason, request=request ) db.session.commit() return jsonify({ 'success': True, 'message': f'{entity_type} успешно восстановлен', 'restored_at': entity.restored_at.isoformat(), 'restored_by': entity.restored_by, 'restored_reason': entity.restored_reason }) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api//deleted', methods=['GET']) def get_deleted_entities(entity_type): """Получение удаленных сущностей""" try: model_class = get_model_by_type(entity_type) if not model_class: return jsonify({'error': f'Неизвестный тип сущности: {entity_type}'}), 400 deleted_entities = get_deleted_objects(model_class).all() if entity_type == 'components': response_data = [{ 'id': entity.id, 'name': entity.name, 'type': entity.type, 'is_active': entity.is_active, 'dry_matter': entity.dry_matter, 'protein': entity.protein, 'energy': entity.energy, 'price': entity.price, 'version': entity.version, 'created_at': entity.created_at.isoformat() if entity.created_at else None, 'updated_at': entity.updated_at.isoformat() if entity.updated_at else None, 'created_by': entity.created_by, 'updated_by': entity.updated_by, 'content_hash': entity.content_hash, 'is_deleted': entity.is_deleted, 'deleted_at': entity.deleted_at.isoformat() if entity.deleted_at else None, 'deleted_by': entity.deleted_by, 'deleted_reason': entity.deleted_reason, 'delete_restore_count': entity.delete_restore_count } for entity in deleted_entities] else: response_data = [{ 'id': entity.id, 'name': getattr(entity, 'name', 'N/A'), 'version': entity.version, 'created_at': entity.created_at.isoformat() if entity.created_at else None, 'updated_at': entity.updated_at.isoformat() if entity.updated_at else None, 'created_by': entity.created_by, 'updated_by': entity.updated_by, 'content_hash': entity.content_hash, 'is_deleted': entity.is_deleted, 'deleted_at': entity.deleted_at.isoformat() if entity.deleted_at else None, 'deleted_by': entity.deleted_by, 'deleted_reason': entity.deleted_reason, 'delete_restore_count': entity.delete_restore_count } for entity in deleted_entities] response = jsonify(response_data) return add_no_cache_headers(response) except Exception as e: return jsonify({'error': str(e)}), 500 def cleanup_old_deleted_records_job(): """Задача для автоматической очистки старых удаленных записей""" from datetime import datetime, timedelta with app.app_context(): try: cutoff_date = datetime.now() - timedelta(days=30) old_dispensers_count = FeedDispenser.query.filter( FeedDispenser.is_deleted == True, FeedDispenser.deleted_at < cutoff_date ).count() old_periods_count = FeedingPeriod.query.filter( FeedingPeriod.is_deleted == True, FeedingPeriod.deleted_at < cutoff_date ).count() old_recipes_count = Recipe.query.filter( Recipe.is_deleted == True, Recipe.deleted_at < cutoff_date ).count() old_components_count = Component.query.filter( Component.is_deleted == True, Component.deleted_at < cutoff_date ).count() total_count = old_dispensers_count + old_periods_count + old_recipes_count + old_components_count if total_count > 0: print(f"Автоматическая очистка: найдено {total_count} записей для удаления") old_dispensers = FeedDispenser.query.filter( FeedDispenser.is_deleted == True, FeedDispenser.deleted_at < cutoff_date ).all() for dispenser in old_dispensers: db.session.delete(dispenser) old_periods = FeedingPeriod.query.filter( FeedingPeriod.is_deleted == True, FeedingPeriod.deleted_at < cutoff_date ).all() for period in old_periods: db.session.delete(period) old_recipes = Recipe.query.filter( Recipe.is_deleted == True, Recipe.deleted_at < cutoff_date ).all() for recipe in old_recipes: db.session.delete(recipe) old_components = Component.query.filter( Component.is_deleted == True, Component.deleted_at < cutoff_date ).all() for component in old_components: db.session.delete(component) db.session.commit() print(f"Автоматическая очистка завершена: удалено {total_count} записей") else: print("Автоматическая очистка: нет записей для удаления") except Exception as e: db.session.rollback() print(f"Ошибка автоматической очистки: {str(e)}") def cleanup_sync_queue_job(): """Удаление задач sync_queue со статусом completed/failed старше 12 часов""" from datetime import datetime, timedelta with app.app_context(): try: cutoff_universal = datetime.now() - timedelta(hours=12) q_universal = SyncQueue.query.filter( SyncQueue.status.in_(['completed', 'failed']), SyncQueue.completed_at.isnot(None), SyncQueue.completed_at < cutoff_universal, SyncQueue.target_node_id.is_(None) # универсальные задачи ) count_universal = q_universal.count() cutoff_clones = datetime.now() - timedelta(hours=1) q_clones = SyncQueue.query.filter( SyncQueue.status == 'completed', SyncQueue.completed_at.isnot(None), SyncQueue.completed_at < cutoff_clones, SyncQueue.target_node_id.isnot(None) # клоны задач ) count_clones = q_clones.count() total_count = count_universal + count_clones if total_count: print(f"Очистка sync_queue: удаляем {count_universal} универсальных задач (старше 12ч) и {count_clones} клонов (старше 1ч)") q_universal.delete(synchronize_session=False) q_clones.delete(synchronize_session=False) db.session.commit() else: print("Очистка sync_queue: нет записей для удаления") except Exception as e: db.session.rollback() print(f"Ошибка очистки sync_queue: {e}") def start_cleanup_scheduler(): """Запуск планировщика автоматической очистки""" schedule.every().day.at("02:00").do(cleanup_old_deleted_records_job) schedule.every().hour.do(cleanup_sync_queue_job) def run_scheduler(): with app.app_context(): while True: schedule.run_pending() time.sleep(60) scheduler_thread = threading.Thread(target=run_scheduler) scheduler_thread.daemon = True scheduler_thread.start() print("Планировщик автоматической очистки запущен") @app.route('/api/sync/metadata', methods=['GET']) def get_sync_metadata(): """Получение метаданных синхронизации""" try: metadata = SyncMetadata.query.first() if not metadata: return jsonify({'error': 'Метаданные синхронизации не найдены'}), 404 return jsonify({ 'id': metadata.id, 'node_id': metadata.node_id, 'node_type': metadata.node_type, 'node_name': metadata.node_name, 'node_status': metadata.node_status, 'last_heartbeat': metadata.last_heartbeat.isoformat() if metadata.last_heartbeat else None, 'last_sync': metadata.last_sync.isoformat() if metadata.last_sync else None, 'sync_status': metadata.sync_status, 'is_enabled': metadata.is_enabled, 'created_at': metadata.created_at.isoformat(), 'updated_at': metadata.updated_at.isoformat() }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/clients', methods=['GET']) def get_sync_clients(): """Получение списка всех клиентов (с display_name из sync_client_display_name при наличии)""" try: clients = SyncClient.query.filter_by(is_deleted=False).all() out = [] for client in clients: display_row = SyncClientDisplayName.query.filter_by(node_id=client.node_id).first() display_name = display_row.display_name if display_row else None out.append({ 'id': client.id, 'node_id': client.node_id, 'client_name': client.client_name, 'display_name': display_name, 'ip_address': client.ip_address, 'port': client.port, 'status': client.status, 'last_seen': client.last_seen.isoformat() if client.last_seen else None, 'total_syncs': client.total_syncs, 'last_error': client.last_error, 'is_enabled': client.is_enabled, 'created_at': client.created_at.isoformat(), 'updated_at': client.updated_at.isoformat() }) return jsonify(out) except Exception as e: return jsonify({'error': str(e)}), 500 def _delivery_record_context(table_name, record_id, action): """Краткий контекст записи, например «1 мин (нов, СВ) 68 гол».""" try: if table_name == 'recipe': r = db.session.get(Recipe, record_id) if not r: return None parts = [f"{r.mixing_time or 0} мин"] if action == 'create': parts.append('(нов, дв)') else: parts.append('(дв)') parts.append(f"{r.heads_per_trip or 0} гол") return ' '.join(parts) if table_name == 'period_recipes': _, rid = record_id.split(':', 1) return _delivery_record_context('recipe', rid, action) if table_name == 'trip': t = db.session.get(Trip, record_id) if not t: return None r = db.session.get(Recipe, t.recipe_id) m = r.mixing_time if r else 0 h = getattr(t, 'heads_count', None) or (r.heads_per_trip if r else 0) return f"{m} мин (нов, дв) {h} гол" if table_name == 'ingredient': ing = db.session.get(Ingredient, record_id) if not ing: return None amt = getattr(ing, 'amount', None) or 0 wph = getattr(ing, 'weight_per_head', None) if wph is not None: return f"{float(wph):.2f} кг/гол" return f"{float(amt):.1f}" if table_name == 'unloading_group': g = db.session.get(UnloadingGroup, record_id) if not g: return None dt = getattr(g, 'distribution_type', None) or 'percent' v = getattr(g, 'distribution_value', None) or getattr(g, 'value', None) or 0 w = getattr(g, 'weight', None) if dt == 'percent': return f"{float(v):.0f}%" if w is not None: return f"{float(w):.1f} кг" return f"{float(v):.1f}" if table_name == 'loading_report': r = db.session.get(LoadingReport, record_id) if not r or r.total_weight is None: return None return f"{float(r.total_weight):.1f} кг" if table_name == 'unloading_report': r = db.session.get(UnloadingReport, record_id) if not r or r.total_weight is None: return None return f"{float(r.total_weight):.1f} кг" if table_name == 'unloading_report_group': g = db.session.get(UnloadingReportGroup, record_id) if not g: return None tw = getattr(g, 'target_weight', None) or 0 return f"{float(tw):.1f} кг" return None except Exception: return None def _delivery_record_name(table_name, record_id): """Человекочитаемое название записи для отображения в доставках.""" try: if table_name == 'period_recipes': period_id, recipe_id = record_id.split(':', 1) period = db.session.get(FeedingPeriod, period_id) recipe = db.session.get(Recipe, recipe_id) parts = [] if period: parts.append(period.name) if recipe: parts.append(recipe.name) return ' / '.join(parts) if parts else None if table_name == 'trip': t = db.session.get(Trip, record_id) if not t: return None r = db.session.get(Recipe, t.recipe_id) return (r.name if r else 'Рейс') + ' (рейс)' if table_name == 'loading_report': r = db.session.get(LoadingReport, record_id) return r.recipe_name if r else None if table_name == 'unloading_report': r = db.session.get(UnloadingReport, record_id) return r.recipe_name if r else None if table_name == 'loading_report_component': r = db.session.get(LoadingReportComponent, record_id) return r.component_name if r else None if table_name == 'component_loading_time': r = db.session.get(ComponentLoadingTime, record_id) return r.component_name if r else None if table_name == 'unloading_report_group': r = db.session.get(UnloadingReportGroup, record_id) return r.name if r else None model_map = { 'component': Component, 'recipe': Recipe, 'ingredient': Ingredient, 'unloading_group': UnloadingGroup, 'feed_dispenser': FeedDispenser, 'feeding_period': FeedingPeriod, 'feed_mixer': FeedMixer, 'feeding_location': FeedingLocation, 'feeding_point': FeedingPoint, } model = model_map.get(table_name) if not model: return None rec = db.session.get(model, record_id) return getattr(rec, 'name', None) if rec else None except Exception: return None def _get_dispenser_filter_by_display_name(display_name): """ По имени кормораздатчика (feed_dispenser.name) возвращает: (dispenser_id, set(recipe_ids), set(period_ids)) для фильтрации доставок. Если кормораздатчик не найден — (None, set(), set()). Рецепты кормораздатчика = рецепты в period_recipes для периодов этого диспенсера. Для кормоцеха (mill) = рецепты, не привязанные ни к каким периодам. """ if not display_name or not (display_name := str(display_name).strip()): return None, set(), set() dispenser = FeedDispenser.query.filter_by(name=display_name, is_deleted=False).first() if not dispenser: return None, set(), set() dispenser_id = dispenser.id dispenser_type = getattr(dispenser, 'type', None) or 'dispenser' allowed_period_ids = set() allowed_recipe_ids = set() if dispenser_type == 'mill': # Кормоцех: рецепты без привязки к периодам from sqlalchemy import not_ recipes_with_periods = db.session.query(PeriodRecipe.recipe_id).distinct().subquery() recipes = db.session.query(Recipe.id).filter( Recipe.is_deleted == False ).filter( ~Recipe.id.in_(db.session.query(recipes_with_periods.c.recipe_id)) ).all() allowed_recipe_ids = {r[0] for r in recipes} else: # Кормораздатчик: периоды этого диспенсера и рецепты в них periods = get_active_objects(FeedingPeriod, dispenser_id=dispenser_id, is_active=True).filter_by(is_deleted=False).all() allowed_period_ids = {p.id for p in periods} for period in periods: prs = db.session.query(PeriodRecipe.recipe_id).filter_by( period_id=period.id, is_deleted=False ).join(Recipe, Recipe.id == PeriodRecipe.recipe_id).filter( Recipe.is_deleted == False ).all() for (rid,) in prs: allowed_recipe_ids.add(rid) return dispenser_id, allowed_recipe_ids, allowed_period_ids def _delivery_belongs_to_dispenser(table_name, record_id, dispenser_id, allowed_recipe_ids, allowed_period_ids): """Проверяет, относится ли запись доставки к данному кормораздатчику (по recipe/period/dispenser).""" if not dispenser_id and not allowed_recipe_ids and not allowed_period_ids: return True # фильтр не задан — показываем всё try: if table_name == 'recipe': return record_id in allowed_recipe_ids if table_name == 'period_recipes': parts = record_id.split(':', 1) if len(parts) != 2: return False period_id, recipe_id = parts[0], parts[1] return period_id in allowed_period_ids and recipe_id in allowed_recipe_ids if table_name == 'ingredient': ing = db.session.get(Ingredient, record_id) return ing and getattr(ing, 'recipe_id', None) in allowed_recipe_ids if table_name == 'unloading_group': ug = db.session.get(UnloadingGroup, record_id) return ug and getattr(ug, 'recipe_id', None) in allowed_recipe_ids if table_name == 'trip': t = db.session.get(Trip, record_id) return t and getattr(t, 'recipe_id', None) in allowed_recipe_ids if table_name == 'feed_dispenser': return record_id == dispenser_id if table_name == 'feeding_period': return record_id in allowed_period_ids if table_name == 'loading_report': r = db.session.get(LoadingReport, record_id) return r and getattr(r, 'recipe_id', None) in allowed_recipe_ids if table_name == 'unloading_report': r = db.session.get(UnloadingReport, record_id) return r and getattr(r, 'recipe_id', None) in allowed_recipe_ids if table_name == 'component': if not allowed_recipe_ids: return False used = db.session.query(Ingredient).filter( Ingredient.component_id == record_id, Ingredient.recipe_id.in_(allowed_recipe_ids) ).limit(1).first() return used is not None if table_name == 'unloading_report_group': g = db.session.get(UnloadingReportGroup, record_id) if not g: return False report_id = getattr(g, 'unloading_report_id', None) or getattr(g, 'report_id', None) if not report_id: return False r = db.session.get(UnloadingReport, report_id) return r and getattr(r, 'recipe_id', None) in allowed_recipe_ids if table_name == 'loading_report_component': c = db.session.get(LoadingReportComponent, record_id) if not c: return False report_id = getattr(c, 'loading_report_id', None) or getattr(c, 'report_id', None) if not report_id: return False r = db.session.get(LoadingReport, report_id) return r and getattr(r, 'recipe_id', None) in allowed_recipe_ids if table_name == 'component_loading_time': c = db.session.get(ComponentLoadingTime, record_id) if not c: return False report_id = getattr(c, 'loading_report_id', None) or getattr(c, 'report_id', None) if not report_id: return False r = db.session.get(LoadingReport, report_id) return r and getattr(r, 'recipe_id', None) in allowed_recipe_ids if table_name == 'feeding_point': p = db.session.get(FeedingPoint, record_id) return p and getattr(p, 'period_id', None) in allowed_period_ids # Остальные таблицы без явной привязки к диспенсеру/рецепту — не показываем при включённом фильтре return False except Exception: return False @app.route('/api/sync/clients//deliveries', methods=['GET']) def get_sync_client_deliveries(node_id): """Данные, отправленные клиенту за последние hours (по умолчанию 24)""" try: client = SyncClient.query.filter_by(node_id=node_id, is_deleted=False).first() if not client: return jsonify({'error': 'Клиент не найден'}), 404 hours = int(request.args.get('hours', 24)) hours = max(1, min(168, hours)) cutoff = moscow_now() - timedelta(hours=hours) personal = SyncQueue.query.filter( SyncQueue.target_node_id == node_id, or_( SyncQueue.processed_at >= cutoff, and_(SyncQueue.processed_at.is_(None), SyncQueue.completed_at >= cutoff) ) ).order_by(desc(func.coalesce(SyncQueue.processed_at, SyncQueue.completed_at))).all() universal_raw = SyncQueue.query.filter( SyncQueue.target_node_id.is_(None), SyncQueue.delivered_to_clients.isnot(None), or_( SyncQueue.processed_at >= cutoff, and_(SyncQueue.processed_at.is_(None), SyncQueue.completed_at >= cutoff) ) ).all() universal = [] for t in universal_raw: try: delivered = json.loads(t.delivered_to_clients) if t.delivered_to_clients else [] if node_id in delivered: universal.append(t) except Exception: pass def _row(t): ts = t.processed_at or t.completed_at name = _delivery_record_name(t.table_name, t.record_id) context = _delivery_record_context(t.table_name, t.record_id, t.action) return { 'table_name': t.table_name, 'record_id': t.record_id, 'action': t.action, 'status': t.status, 'name': name, 'context': context, 'processed_at': ts.isoformat() if ts else None, 'created_at': t.created_at.isoformat() if t.created_at else None, } out = [_row(t) for t in personal] + [_row(t) for t in universal] out.sort(key=lambda x: (x['processed_at'] or x['created_at'] or ''), reverse=True) # Фильтр по кормораздатчику: если у клиента задано display_name (имя из feed_dispenser), # показываем только доставки, относящиеся к рецептам/периодам этого кормораздатчика display_row = SyncClientDisplayName.query.filter_by(node_id=node_id).first() if display_row and display_row.display_name: dispenser_id, allowed_recipe_ids, allowed_period_ids = _get_dispenser_filter_by_display_name(display_row.display_name) if dispenser_id is not None or allowed_recipe_ids or allowed_period_ids: out = [ row for row in out if _delivery_belongs_to_dispenser( row['table_name'], row['record_id'], dispenser_id, allowed_recipe_ids, allowed_period_ids ) ] # если dispenser не найден по имени — показываем всё (как раньше) return jsonify({'deliveries': out, 'client_name': client.client_name}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/clients/', methods=['GET']) def get_sync_client(node_id): """Получение информации о конкретном клиенте""" try: client = SyncClient.query.filter_by(node_id=node_id, is_deleted=False).first() if not client: return jsonify({'error': 'Клиент не найден'}), 404 return jsonify({ 'id': client.id, 'node_id': client.node_id, 'client_name': client.client_name, 'ip_address': client.ip_address, 'port': client.port, 'status': client.status, 'last_seen': client.last_seen.isoformat() if client.last_seen else None, 'total_syncs': client.total_syncs, 'last_error': client.last_error, 'is_enabled': client.is_enabled, 'created_at': client.created_at.isoformat(), 'updated_at': client.updated_at.isoformat() }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/clients/', methods=['DELETE']) def delete_sync_client(node_id): """Удаление клиента из синхронизации (мягкое удаление)""" try: client = SyncClient.query.filter_by(node_id=node_id, is_deleted=False).first() if not client: return jsonify({'error': 'Клиент не найден'}), 404 # 1. Помечаем все незавершенные задачи для этого клиента как completed # чтобы они не пытались синхронизироваться с несуществующим клиентом pending_tasks = SyncQueue.query.filter( SyncQueue.target_node_id == node_id, SyncQueue.status.in_(['pending', 'processing']) ).all() completed_count = 0 for task in pending_tasks: task.status = 'completed' task.completed_at = moscow_now() task.error_message = 'Клиент удален из синхронизации' completed_count += 1 # 2. Удаляем привязку отображаемого имени (если есть) SyncClientDisplayName.query.filter_by(node_id=node_id).delete() # 3. Мягкое удаление клиента client.soft_delete( deleted_by='user', reason='Удален пользователем через интерфейс', request=request ) db.session.commit() return jsonify({ 'success': True, 'message': f'Клиент {client.client_name} успешно удален из синхронизации', 'completed_tasks': completed_count }) except Exception as e: db.session.rollback() logger.error(f"Ошибка удаления клиента {node_id}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/sync/clients//display_name', methods=['PUT']) def set_sync_client_display_name(node_id): """Установка отображаемого имени для клиента синхронизации (из списка feed_dispenser.name)""" try: client = SyncClient.query.filter_by(node_id=node_id, is_deleted=False).first() if not client: return jsonify({'error': 'Клиент не найден'}), 404 data = request.get_json() or {} display_name = (data.get('display_name') or '').strip() if not display_name: # Удалить привязку SyncClientDisplayName.query.filter_by(node_id=node_id).delete() db.session.commit() return jsonify({'success': True, 'display_name': None, 'message': 'Имя сброшено'}) row = SyncClientDisplayName.query.filter_by(node_id=node_id).first() if row: row.display_name = display_name else: db.session.add(SyncClientDisplayName(node_id=node_id, display_name=display_name)) db.session.commit() return jsonify({'success': True, 'display_name': display_name}) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/feed_dispensers/names', methods=['GET']) def get_feed_dispenser_names(): """Список уникальных имён кормораздатчиков/кормоцехов (для выбора имени клиента синхронизации)""" try: names = db.session.query(FeedDispenser.name).filter_by(is_deleted=False).distinct().order_by(FeedDispenser.name).all() return jsonify([n[0] for n in names]) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/queue', methods=['GET']) def get_sync_queue(): """Получение очереди синхронизации""" try: status = request.args.get('status') priority = request.args.get('priority', type=int) limit = request.args.get('limit', 100, type=int) query = SyncQueue.query if status: query = query.filter_by(status=status) if priority: query = query.filter_by(priority=priority) query = query.order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()) queue_items = query.limit(limit).all() return jsonify([{ 'id': item.id, 'table_name': item.table_name, 'record_id': item.record_id, 'action': item.action, 'status': item.status, 'target_node_id': item.target_node_id, 'source_node_id': item.source_node_id, 'priority': item.priority, 'retry_count': item.retry_count, 'max_retries': item.max_retries, 'error_message': item.error_message, 'created_at': item.created_at.isoformat(), 'processed_at': item.processed_at.isoformat() if item.processed_at else None, 'completed_at': item.completed_at.isoformat() if item.completed_at else None } for item in queue_items]) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/queue/stats', methods=['GET']) def get_sync_queue_stats(): """Получение статистики очереди синхронизации""" try: stats = { 'total': SyncQueue.query.count(), 'pending': SyncQueue.query.filter_by(status='pending').count(), 'processing': SyncQueue.query.filter_by(status='processing').count(), 'completed': SyncQueue.query.filter_by(status='completed').count(), 'failed': SyncQueue.query.filter_by(status='failed').count(), 'by_priority': {} } for priority in range(1, 6): stats['by_priority'][f'priority_{priority}'] = SyncQueue.query.filter_by(priority=priority).count() return jsonify(stats) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/bootstrap', methods=['POST']) def bootstrap_universal_sync(): """Генерация универсальных задач (полный снапшот) для новых клиентов""" try: tables = [ Component, Recipe, Ingredient, UnloadingGroup, FeedMixer, FeedingLocation, FeedingPeriod, FeedingPoint, FeedDispenser, PeriodRecipe, Trip ] total = 0 for model in tables: q = model.query if hasattr(model, 'is_deleted'): q = q.filter(model.is_deleted == False) for obj in q.all(): if model.__tablename__ == 'period_recipes': record_id = f"{obj.period_id}:{obj.recipe_id}" else: record_id = obj.id create_sync_task_async( table_name=model.__tablename__, record_id=record_id, action='create', priority=2, target_node_id=None # универсально для всех новых клиентов ) total += 1 process_pending_sync_tasks() return jsonify({'success': True, 'created': total}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/sync/conflicts', methods=['GET']) def get_sync_conflicts(): """Получение конфликтов синхронизации""" try: resolution = request.args.get('resolution') conflict_type = request.args.get('conflict_type') limit = request.args.get('limit', 50, type=int) query = SyncConflict.query if resolution: query = query.filter_by(resolution=resolution) if conflict_type: query = query.filter_by(conflict_type=conflict_type) query = query.order_by(SyncConflict.created_at.desc()) conflicts = query.limit(limit).all() return jsonify([{ 'id': conflict.id, 'table_name': conflict.table_name, 'record_id': conflict.record_id, 'conflict_type': conflict.conflict_type, 'local_data': conflict.local_data, 'remote_data': conflict.remote_data, 'resolution': conflict.resolution, 'resolved_by': conflict.resolved_by, 'resolved_at': conflict.resolved_at.isoformat() if conflict.resolved_at else None, 'created_at': conflict.created_at.isoformat(), 'updated_at': conflict.updated_at.isoformat() } for conflict in conflicts]) except Exception as e: _log_server_error_with_traceback(e, context='get_sync_conflicts') return jsonify({'error': str(e)}), 500 @app.route('/api/sync/conflicts//resolve', methods=['POST']) def resolve_sync_conflict(conflict_id): """Разрешение конфликта синхронизации с применением изменений""" try: import json conflict = SyncConflict.query.get_or_404(conflict_id) data = request.get_json() resolution = data.get('resolution') # 'local', 'remote', 'merged' resolved_by = data.get('resolved_by', 'admin') reason = data.get('reason', '') if resolution not in ['local', 'remote', 'merged']: return jsonify({'error': 'Неверное разрешение конфликта'}), 400 server_master_tables = ['component', 'recipe', 'ingredient', 'feed_mixer', 'feeding_location', 'feeding_period', 'feeding_point', 'feed_dispenser', 'period_recipe', 'trip'] client_master_tables = ['loading_report', 'loading_report_component', 'component_loading_time', 'unloading_report', 'unloading_report_group'] is_server_master = conflict.table_name in server_master_tables is_client_master = conflict.table_name in client_master_tables # print(f"🔧 РАЗРЕШЕНИЕ КОНФЛИКТА: {conflict.table_name}.{conflict.record_id}") # print(f"🔧 - Сервер главный: {is_server_master}") # print(f"🔧 - Клиент главный: {is_client_master}") # print(f"🔧 - Разрешение: {resolution}") applied_data = None if resolution == 'local': if conflict.local_data: applied_data = json.loads(conflict.local_data) # print(f"🔧 - Применяем локальные данные") elif resolution == 'remote': if conflict.remote_data: applied_data = json.loads(conflict.remote_data) # print(f"🔧 - Применяем удаленные данные") elif resolution == 'merged': if is_server_master: if conflict.remote_data: applied_data = json.loads(conflict.remote_data) # print(f"🔧 - Автоматическое разрешение: сервер главный, используем серверные данные") elif is_client_master: if conflict.local_data: applied_data = json.loads(conflict.local_data) # print(f"🔧 - Автоматическое разрешение: клиент главный, используем клиентские данные") else: if conflict.remote_data: applied_data = json.loads(conflict.remote_data) # print(f"🔧 - Автоматическое разрешение: по умолчанию используем серверные данные") if applied_data: result = apply_sync_change(conflict.table_name, conflict.record_id, 'update', applied_data) if not result['success']: return jsonify({ 'success': False, 'error': f'Ошибка применения изменений: {result["error"]}' }), 500 # print(f"🔧 - ✅ Изменения применены успешно") else: # print(f"🔧 - ⚠️ Нет данных для применения") pass conflict.resolution = resolution conflict.resolved_by = resolved_by conflict.resolved_at = moscow_now() conflict.updated_at = moscow_now() db.session.commit() return jsonify({ 'success': True, 'message': f'Конфликт разрешен: {resolution}', 'conflict_id': conflict.id, 'resolved_by': resolved_by, 'resolved_at': conflict.resolved_at.isoformat(), 'applied_data': applied_data is not None }) except Exception as e: db.session.rollback() print(f"❌ Ошибка разрешения конфликта: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/sync/status', methods=['GET']) def get_sync_status(): """Получение общего статуса синхронизации""" try: metadata = SyncMetadata.query.first() clients_total = SyncClient.query.filter_by(is_deleted=False).count() clients_stats = { 'total': clients_total, 'active': SyncClient.query.filter_by(status='active', is_deleted=False).count(), 'offline': SyncClient.query.filter_by(status='offline', is_deleted=False).count(), 'disabled': SyncClient.query.filter_by(status='disabled', is_deleted=False).count() } q_pending = SyncQueue.query.filter_by(status='pending').count() q_processing = SyncQueue.query.filter_by(status='processing').count() q_completed = SyncQueue.query.filter_by(status='completed').count() q_failed = SyncQueue.query.filter_by(status='failed').count() queue_stats = { 'total': q_pending + q_processing + q_completed + q_failed, 'pending': q_pending, 'processing': q_processing, 'completed': q_completed, 'failed': q_failed } conflicts_stats = { 'total': SyncConflict.query.count(), 'pending': SyncConflict.query.filter( (SyncConflict.resolution == 'pending') | (SyncConflict.resolution.is_(None)) ).count(), 'resolved': SyncConflict.query.filter( (SyncConflict.resolution != 'pending') & (SyncConflict.resolution.isnot(None)) ).count(), 'by_type': { 'version_mismatch': SyncConflict.query.filter_by(conflict_type='version_mismatch').count(), 'application_error': SyncConflict.query.filter_by(conflict_type='application_error').count(), 'deletion': SyncConflict.query.filter_by(conflict_type='deletion').count(), 'hash': SyncConflict.query.filter_by(conflict_type='hash').count() } } return jsonify({ 'metadata': { 'node_id': metadata.node_id if metadata else None, 'node_type': metadata.node_type if metadata else None, 'sync_status': metadata.sync_status if metadata else None, 'last_sync': metadata.last_sync.isoformat() if metadata and metadata.last_sync else None }, 'clients': clients_stats, 'queue': queue_stats, 'conflicts': conflicts_stats, 'timestamp': moscow_now().isoformat() }) except Exception as e: _log_server_error_with_traceback(e, context='get_sync_status') return jsonify({ 'metadata': {'node_id': None, 'node_type': None, 'sync_status': 'error', 'last_sync': None}, 'clients': {'total': 0, 'active': 0, 'offline': 0, 'disabled': 0}, 'queue': {'total': 0, 'pending': 0, 'processing': 0, 'completed': 0, 'failed': 0}, 'conflicts': {'total': 0, 'pending': 0, 'resolved': 0, 'by_type': {'version_mismatch': 0, 'application_error': 0, 'deletion': 0, 'hash': 0}}, 'timestamp': moscow_now().isoformat(), 'error': str(e) }), 500 @app.route('/db_check') def db_check_page(): """Страница проверки баз данных""" return render_template('db_check.html') @app.route('/api/sync/init', methods=['POST']) def init_sync_metadata_api(): """Принудительная инициализация метаданных синхронизации""" try: init_sync_metadata() init_sync_metadata(bind='reports') metadata = SyncMetadata.query.first() if metadata: return jsonify({ 'success': True, 'message': 'Метаданные синхронизации инициализированы', 'node_id': metadata.node_id, 'node_type': metadata.node_type }) else: return jsonify({ 'success': False, 'error': 'Метаданные не найдены после инициализации' }), 500 except Exception as e: return jsonify({ 'success': False, 'error': str(e) }), 500 @app.route('/api/sync/register', methods=['POST']) def register_client(): """Регистрация клиента на сервере""" try: if not request.is_json: return jsonify({'error': 'Content-Type должен быть application/json'}), 400 data = request.get_json() if not data: return jsonify({'error': 'Пустой JSON'}), 400 client_id = data.get('client_id') client_name = data.get('client_name', 'Неизвестный клиент') client_ip = request.remote_addr client_port = data.get('port', 5000) if not client_id: return jsonify({'error': 'client_id обязателен'}), 400 existing_client = SyncClient.query.filter_by(node_id=client_id).first() if existing_client: try: now = moscow_now() ip_mismatch = existing_client.ip_address and existing_client.ip_address != client_ip if ip_mismatch: logger.warning( "⚠️ Повторная регистрация клиента с новым IP", extra={ 'client_id': client_id, 'old_ip': existing_client.ip_address, 'new_ip': client_ip, 'minutes_since_last_seen': ((now - existing_client.last_seen).total_seconds() / 60.0) if existing_client.last_seen else None } ) except Exception: pass existing_client.client_name = client_name existing_client.ip_address = client_ip existing_client.port = client_port existing_client.status = 'active' existing_client.last_seen = moscow_now() existing_client.is_enabled = True existing_client.updated_at = moscow_now() else: new_client = SyncClient( node_id=client_id, client_name=client_name, ip_address=client_ip, port=client_port, status='active', is_enabled=True, created_at=moscow_now(), updated_at=moscow_now() ) db.session.add(new_client) db.session.commit() server_metadata = SyncMetadata.query.first() return jsonify({ 'success': True, 'message': 'Клиент успешно зарегистрирован', 'server_id': server_metadata.node_id if server_metadata else None, 'server_name': server_metadata.node_name if server_metadata else 'Сервер', 'sync_status': 'ready', 'timestamp': moscow_now().isoformat() }) except Exception as e: db.session.rollback() print(f"Ошибка в register_client: {e}") # Добавляем логирование return jsonify({'error': str(e)}), 500 @app.route('/api/updates/check', methods=['GET']) @rate_limit(max_requests=10, window=60) def check_updates(): """Проверка наличия обновлений""" try: from update import auto_updater if not auto_updater: return jsonify({'error': 'AutoUpdater не инициализирован'}), 500 if not auto_updater.enabled: return jsonify({ 'update_available': False, 'enabled': False, 'message': 'Автообновление отключено в конфигурации' }) release_info = auto_updater.check_for_updates() if release_info: return jsonify({ 'update_available': True, 'version': release_info['version'], 'name': release_info['name'], 'body': release_info['body'], 'published_at': release_info['published_at'], 'current_version': auto_updater.current_version }) else: return jsonify({ 'update_available': False, 'current_version': auto_updater.current_version }) except Exception as e: logger.error(f"❌ Ошибка проверки обновлений: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/updates/install', methods=['POST']) @rate_limit(max_requests=5, window=60) def install_update(): """Установка обновления""" try: from update import auto_updater if not auto_updater: return jsonify({'error': 'AutoUpdater не инициализирован'}), 500 if not auto_updater.enabled: return jsonify({'error': 'Автообновление отключено в конфигурации'}), 400 if auto_updater.is_updating: return jsonify({'error': 'Обновление уже выполняется'}), 400 success = auto_updater.update() if success: return jsonify({ 'success': True, 'message': 'Обновление применено. Требуется перезапуск приложения.', 'new_version': auto_updater.current_version }) else: return jsonify({'error': 'Не удалось применить обновление'}), 500 except Exception as e: logger.error(f"❌ Ошибка установки обновления: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/updates/status', methods=['GET']) @rate_limit(max_requests=10, window=60) def update_status(): """Статус автообновления""" try: from update import auto_updater if not auto_updater: return jsonify({ 'enabled': False, 'initialized': False, 'message': 'AutoUpdater не инициализирован' }) return jsonify({ 'enabled': auto_updater.enabled, 'initialized': True, 'current_version': auto_updater.current_version, 'is_running': auto_updater.is_running, 'is_updating': auto_updater.is_updating, 'gitea_configured': bool(auto_updater.gitea_url and auto_updater.gitea_owner and auto_updater.gitea_repo), 'gitea_url': auto_updater.gitea_url if auto_updater.enabled else None, 'gitea_repo': f"{auto_updater.gitea_owner}/{auto_updater.gitea_repo}" if auto_updater.enabled else None }) except Exception as e: logger.error(f"❌ Ошибка получения статуса: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/server/info', methods=['GET']) @rate_limit(max_requests=10, window=60) # 10 запросов в минуту def server_info(): """Безопасный endpoint для проверки сервера клиентами""" try: client_ip = request.remote_addr user_agent = request.headers.get('User-Agent', '') logger.info(f"🔍 Запрос server_info от {client_ip} ({user_agent})") try: _cfg = os.path.join(_wesp_root, 'data', 'config.json') with open(_cfg, 'r', encoding='utf-8') as f: config = json.load(f) server_version = config.get('version', '1.0.0') is_master = bool(config.get('is_master', True)) except: server_version = '1.0.0' is_master = True info = { 'system': 'wesp', 'version': server_version, 'role': 'server', 'status': 'active', 'timestamp': moscow_now().isoformat(), 'api_version': '1.0', 'is_master': is_master } return jsonify(info) except Exception as e: logger.error(f"❌ Ошибка в server_info: {e}") return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/config/update_server_url', methods=['GET']) def api_update_server_url(): """Обновление URL сервера в data/config.json. Требуется текущий пароль из data/credentials.json.""" try: new_url = request.args.get('url', '').strip() password = request.args.get('password', '') if not new_url: return jsonify({'status': 'error', 'message': 'Не указан параметр url'}), 400 if not password: return jsonify({'status': 'error', 'message': 'Не указан параметр password (текущий пароль)'}), 400 if not new_url.startswith('http://') and not new_url.startswith('https://'): return jsonify({'status': 'error', 'message': 'url должен начинаться с http:// или https://'}), 400 saved_login, saved_password = load_credentials() if password != saved_password: return jsonify({'status': 'error', 'message': 'Неверный текущий пароль'}), 401 config_path = os.path.join(_wesp_root, 'data', 'config.json') try: with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) except Exception as e: return jsonify({'status': 'error', 'message': f'Ошибка чтения config.json: {e}'}), 500 config['server_url'] = new_url.rstrip('/') + '/' config['updated_at'] = datetime.now().isoformat() try: with open(config_path, 'w', encoding='utf-8') as f: json.dump(config, f, ensure_ascii=False, indent=2) except Exception as e: return jsonify({'status': 'error', 'message': f'Ошибка записи config.json: {e}'}), 500 try: from sync_client import sync_client if sync_client and sync_client.config.get('role') == 'client': sync_client.update_server_url(config['server_url']) except Exception: pass return jsonify({ 'status': 'success', 'message': 'URL сервера обновлён', 'server_url': config['server_url'] }) except Exception as e: logger.error(f"❌ Ошибка в update_server_url: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/sync/check-db', methods=['POST']) def create_db_snapshot(): """Создает снапшот recipes.db и возвращает его клиенту""" try: # Создаем временный файл для дампа with tempfile.NamedTemporaryFile(delete=False, suffix='.sql', mode='w', encoding='utf-8') as tmp_file: dump_path = tmp_file.name # Делаем SQLite dump conn = sqlite3.connect(DATABASE_PATH) with open(dump_path, 'w', encoding='utf-8') as f: for line in conn.iterdump(): f.write(f'{line}\n') conn.close() # Читаем дамп и сжимаем with open(dump_path, 'rb') as f: dump_data = f.read() compressed = gzip.compress(dump_data) encoded = base64.b64encode(compressed).decode('utf-8') # Удаляем временный файл os.unlink(dump_path) logger.info(f"✅ Снапшот БД создан: размер={len(dump_data)}, сжато={len(compressed)}") return jsonify({ 'success': True, 'snapshot': encoded, 'size': len(dump_data), 'compressed_size': len(compressed), 'timestamp': moscow_now().isoformat() }) except Exception as e: logger.error(f"❌ Ошибка создания снапшота БД: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/sync/check-db-client', methods=['POST']) def check_db_client(): """Прокси endpoint для клиента - вызывает проверку через sync_client""" try: from sync_client import sync_client if not sync_client or sync_client.config.get('role') != 'client': return jsonify({'error': 'Устройство не является клиентом'}), 400 result = sync_client.check_database() return jsonify(result) except Exception as e: logger.error(f"❌ Ошибка проверки БД на клиенте: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/sync/restore-db', methods=['POST']) def restore_database(): """Восстанавливает локальную БД из снапшота сервера""" try: from sync_client import sync_client if not sync_client or sync_client.config.get('role') != 'client': return jsonify({'error': 'Устройство не является клиентом'}), 400 data = request.get_json() or {} restore_type = data.get('type', 'sync') # 'full' или 'sync' if restore_type == 'full': # Полное восстановление - заменяем БД снапшотом сервера result = sync_client.restore_database_full() else: # Частичное - запускаем синхронизацию result = sync_client.restore_database_sync() return jsonify(result) except Exception as e: logger.error(f"❌ Ошибка восстановления БД: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/sync/pull', methods=['POST']) def sync_pull(): """Получение изменений с сервера""" start_time = time.time() _log_server_operation_start("sync_pull") used_slots, available_slots = log_sync_concurrency_status() if not _sync_semaphore.acquire(blocking=False): logger.warning(f"[SYNC-PULL-BLOCKED] Запрос заблокирован: все слоты заняты ({used_slots}/{SYNC_MAX_CONCURRENT})") _log_server_operation_end("sync_pull", False, time.time() - start_time, error="too_many_concurrent_syncs") return jsonify({'error': 'Слишком много одновременных синхронизаций. Попробуйте позже.'}), 429 logger.info(f"[SYNC-PULL-START] Начало обработки, доступно слотов: {available_slots - 1}/{SYNC_MAX_CONCURRENT}") try: if not request.is_json: logger.error("[SYNC-PULL-ERROR] Отсутствует Content-Type application/json") _log_server_operation_end("sync_pull", False, time.time() - start_time, error="invalid_content_type") return jsonify({'error': 'Content-Type должен быть application/json'}), 400 data = request.get_json() if not data: logger.error("[SYNC-PULL-ERROR] Пустой JSON") _log_server_operation_end("sync_pull", False, time.time() - start_time, error="empty_json") return jsonify({'error': 'Пустой JSON'}), 400 client_id = data.get('client_id') last_sync = data.get('last_sync') if not client_id: logger.error("[SYNC-PULL-ERROR] Отсутствует client_id") _log_server_operation_end("sync_pull", False, time.time() - start_time, error="missing_client_id") return jsonify({'error': 'client_id обязателен'}), 400 logger.info(f"[SYNC-PULL] Клиент {client_id[:8]}...: запрос изменений, last_sync={last_sync}") _log_server_operation_start("sync_pull", client_id=client_id, last_sync=last_sync) try: client_rec = SyncClient.query.filter_by(node_id=client_id).first() if client_rec: client_rec.last_seen = moscow_now() client_rec.status = 'active' db.session.commit() except Exception: db.session.rollback() try: requeue_start = time.time() requeue_stuck_processing(timeout_minutes=15) requeue_duration = time.time() - requeue_start if requeue_duration > 0.1: logger.info(f"[SYNC-PULL-REQUEUE] Разморозка зависших задач заняла {requeue_duration:.3f}с") except Exception as _requeue_err: logger.warning(f"[SYNC-PULL-REQUEUE-ERROR] Ошибка разморозки: {_requeue_err}") changes = [] has_completed_personal = SyncQueue.query.filter( SyncQueue.target_node_id == client_id, SyncQueue.status == 'completed' ).first() is not None is_new_client = not has_completed_personal logger.info(f"[SYNC-PULL-CLIENT-TYPE] Клиент {client_id[:8]}...: {'НОВЫЙ' if is_new_client else 'СУЩЕСТВУЮЩИЙ'}") try: if is_new_client: universal_exists = db.session.query(SyncQueue).filter( SyncQueue.target_node_id.is_(None), SyncQueue.status == 'pending' ).first() is not None if not universal_exists: logger.info(f"[SYNC-PULL-BOOTSTRAP] Универсальные задачи отсутствуют, запускаю bootstrap для клиента {client_id[:8]}...") bootstrap_universal_sync() except Exception as _bootstrap_err: logger.warning(f"[SYNC-PULL-BOOTSTRAP-ERROR] Ошибка авто-bootstrap: {_bootstrap_err}") if not is_new_client: query_start = time.time() pending_tasks = SyncQueue.query.filter( SyncQueue.status == 'pending', SyncQueue.target_node_id == client_id ).order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()).all() query_duration = time.time() - query_start logger.info(f"[SYNC-PULL-QUERY] Найдено {len(pending_tasks)} персональных задач за {query_duration:.3f}с") tasks = [] now_ts = moscow_now() for t in pending_tasks: t.status = 'processing' t.processed_at = now_ts tasks.append(t) commit_start = time.time() db_commit_with_retry() commit_duration = time.time() - commit_start logger.info(f"[SYNC-PULL-COMMIT] Обновление статусов задач: {len(tasks)} задач, коммит {commit_duration:.3f}с") if tasks: logger.info(f"[SYNC-PULL-SEND] Клиент {client_id[:8]}...: отправляется {len(tasks)} персональных задач") else: logger.info(f"[SYNC-PULL-SEND] Клиент {client_id[:8]}...: персональных задач нет (клиент синхронизирован)") else: try: universal_exists = db.session.query(SyncQueue).filter( SyncQueue.target_node_id.is_(None), SyncQueue.status == 'pending' ).first() is not None if not universal_exists: logger.info(f"[SYNC-PULL-BOOTSTRAP] Повторная проверка: универсальные задачи отсутствуют, запускаю bootstrap") bootstrap_universal_sync() except Exception as _bootstrap_err2: logger.warning(f"[SYNC-PULL-BOOTSTRAP-ERROR] Ошибка повторного bootstrap: {_bootstrap_err2}") pending_universal = SyncQueue.query.filter( SyncQueue.status == 'pending', SyncQueue.target_node_id.is_(None) ).order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()).all() tasks = [] now_ts = moscow_now() created_keys = set() for t in pending_universal: k = (t.table_name, t.record_id, t.action, client_id) if k in created_keys: continue already_delivered = False if t.delivered_to_clients: try: delivered_clients = json.loads(t.delivered_to_clients) if client_id in delivered_clients: already_delivered = True except: pass if not already_delivered: with db.session.no_autoflush: existing_clone = SyncQueue.query.filter( SyncQueue.table_name == t.table_name, SyncQueue.record_id == t.record_id, SyncQueue.action == t.action, SyncQueue.target_node_id == client_id, SyncQueue.status.in_(['pending', 'processing']) # Только активные статусы ).first() if existing_clone: logger.warning(f"Пропускаем клон: уже есть активная задача для клиента {client_id} ({existing_clone.id}, {existing_clone.status})") continue with db.session.no_autoflush: recent_completed = SyncQueue.query.filter( SyncQueue.table_name == t.table_name, SyncQueue.record_id == t.record_id, SyncQueue.action == t.action, SyncQueue.target_node_id == client_id, SyncQueue.status == 'completed', SyncQueue.completed_at > (moscow_now() - timedelta(hours=1)) ).first() if recent_completed: logger.debug(f"[SYNC-PULL-CLONE] Пропускаем клон: недавно завершенная задача для клиента {client_id[:8]}...") continue cloned = SyncQueue( table_name=t.table_name, record_id=t.record_id, action=t.action, status='processing', priority=t.priority, target_node_id=client_id, source_node_id=t.id, # Ссылка на оригинальную универсальную задачу retry_count=0, max_retries=t.max_retries, error_message=None, created_at=now_ts, processed_at=now_ts ) db.session.add(cloned) try: delivered_clients = [] if t.delivered_to_clients: try: delivered_clients = json.loads(t.delivered_to_clients) except Exception: delivered_clients = [] if client_id not in delivered_clients: delivered_clients.append(client_id) t.delivered_to_clients = json.dumps(delivered_clients) except Exception as _deliver_mark_err: logger.warning(f"[SYNC-PULL-DELIVERY] Не удалось пометить доставку универсальной задачи {t.id[:8]}... клиенту {client_id[:8]}...: {_deliver_mark_err}") tasks.append(cloned) created_keys.add(k) clone_start = time.time() try: db.session.commit() except Exception as _commit_err: if 'UNIQUE constraint failed' in str(_commit_err): db.session.rollback() logger.warning(f"[SYNC-PULL-COMMIT] Идемпотентность: пропущен коммит (дубликат по уникальному индексу): {_commit_err}") else: raise clone_duration = time.time() - clone_start logger.info(f"[SYNC-PULL-CLONE] Создано {len(tasks)} клонов задач за {clone_duration:.3f}с") if tasks: logger.info(f"[SYNC-PULL-SEND] Новый клиент {client_id[:8]}...: отправляется {len(tasks)} задач (склонировано из универсальных)") else: logger.info(f"[SYNC-PULL-SEND] Новый клиент {client_id[:8]}...: задач нет (bootstrap может быть запущен)") if is_new_client and len(tasks) == 0: try: logger.info(f"[SYNC-PULL-FALLBACK] Жесткий fallback: задач нет, запускаю bootstrap для клиента {client_id[:8]}...") bootstrap_universal_sync() pending_universal = SyncQueue.query.filter( SyncQueue.status == 'pending', SyncQueue.target_node_id.is_(None) ).order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()).all() now_ts = moscow_now() created_keys = set() for t in pending_universal: k = (t.table_name, t.record_id, t.action, client_id) if k in created_keys: continue already_delivered = False if t.delivered_to_clients: try: delivered_clients = json.loads(t.delivered_to_clients) if client_id in delivered_clients: already_delivered = True except Exception: pass if already_delivered: continue with db.session.no_autoflush: existing_clone = SyncQueue.query.filter( SyncQueue.table_name == t.table_name, SyncQueue.record_id == t.record_id, SyncQueue.action == t.action, SyncQueue.target_node_id == client_id, SyncQueue.status.in_(['pending', 'processing']) # Только активные статусы ).first() if existing_clone: logger.warning(f"Пропускаем клон после fallback: уже есть активная задача ({existing_clone.id}, {existing_clone.status})") continue cloned = SyncQueue( table_name=t.table_name, record_id=t.record_id, action=t.action, status='processing', priority=t.priority, target_node_id=client_id, source_node_id=t.id, retry_count=0, max_retries=t.max_retries, error_message=None, created_at=now_ts, processed_at=now_ts ) db.session.add(cloned) tasks.append(cloned) created_keys.add(k) try: db_commit_with_retry() except Exception as _commit_err: if 'UNIQUE constraint failed' in str(_commit_err): db.session.rollback() logger.warning(f"Идемпотентность: пропущен коммит после fallback (дубликат): {_commit_err}") else: raise logger.info(f"[SYNC-PULL-FALLBACK] Fallback после bootstrap: к отправке {len(tasks)} задач") except Exception as _hard_fallback_err: logger.warning(f"[SYNC-PULL-FALLBACK-ERROR] Ошибка жесткого fallback: {_hard_fallback_err}") try: MIN_SNAPSHOT_TASKS = 20 if is_new_client and len(tasks) < MIN_SNAPSHOT_TASKS: logger.info(f"[SYNC-PULL-SNAPSHOT] Персональный снапшот: задач {len(tasks)} < {MIN_SNAPSHOT_TASKS}, формирую персональный набор для клиента {client_id[:8]}...") def _enqueue_personal_snapshot(_client_id): tables = [ Component, Recipe, Ingredient, UnloadingGroup, FeedMixer, FeedingLocation, FeedingPeriod, FeedingPoint, FeedDispenser, PeriodRecipe, Trip ] created = 0 for model in tables: q = model.query if hasattr(model, 'is_deleted'): q = q.filter(model.is_deleted == False) for obj in q.all(): if getattr(model, '__tablename__', '') == 'period_recipes': record_id = f"{obj.period_id}:{obj.recipe_id}" else: record_id = obj.id create_sync_task_async( table_name=model.__tablename__, record_id=record_id, action='create', priority=2, target_node_id=_client_id ) created += 1 process_pending_sync_tasks() logger.info(f"[SYNC-PULL-SNAPSHOT] Персональный снапшот: создано {created} задач для клиента {_client_id[:8]}...") _enqueue_personal_snapshot(client_id) pending_personal = SyncQueue.query.filter( SyncQueue.status == 'pending', SyncQueue.target_node_id == client_id ).order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()).all() now_ts = moscow_now() for t in pending_personal: t.status = 'processing' t.processed_at = now_ts tasks.append(t) db_commit_with_retry() logger.debug(f"[SYNC-PULL] Персональный снапшот: к отправке добавлено {len(pending_personal)} задач") except Exception as _personal_err: logger.warning(f"[SYNC-PULL] Ошибка персонального снапшота: {_personal_err}") data_fetch_start = time.time() for task in tasks: record_data = get_record_data_for_sync(task.table_name, task.record_id) if record_data: change_item = { 'task_id': task.id, 'table_name': task.table_name, 'record_id': task.record_id, 'action': task.action, 'version': record_data.get('version', 1), 'content_hash': record_data.get('content_hash', ''), 'data': record_data, 'timestamp': task.created_at.isoformat(), 'priority': task.priority } changes.append(change_item) else: logger.warning(f"[SYNC-PULL] Данные записи не получены: {task.table_name}.{task.record_id[:8]}...") data_fetch_duration = time.time() - data_fetch_start logger.info(f"[SYNC-PULL-DATA] Получение данных для {len(tasks)} задач заняло {data_fetch_duration:.3f}с") for task in tasks: if task.status == 'pending': task.status = 'processing' task.processed_at = moscow_now() db_commit_with_retry() _log_server_changes_statistics(changes, "отправлено клиенту") response_data = { 'success': True, 'changes': changes, 'total': len(changes), 'timestamp': moscow_now().isoformat() } compression_requested = data.get('compression', False) force_compression = data.get('force_compression', False) if force_compression or compression_requested: compression_start = time.time() try: import gzip json_data = json.dumps(response_data, ensure_ascii=False) json_bytes = json_data.encode('utf-8') compressed_data = gzip.compress(json_bytes) compressed_size = len(compressed_data) compression_ratio = compressed_size / len(json_bytes) if len(json_bytes) > 0 else 0 compression_duration = time.time() - compression_start logger.debug(f"[SYNC-PULL-COMPRESSION] Сжатие: {len(json_bytes)} → {compressed_size} байт (коэффициент: {compression_ratio:.2f}, время: {compression_duration:.3f}с)") response = app.response_class( response=compressed_data, status=200, mimetype='application/json', headers={'Content-Encoding': 'gzip'} ) duration = time.time() - start_time logger.info(f"[SYNC-PULL-SUMMARY] Клиент {client_id[:8]}...: отправлено {len(changes)} изменений, сжатие {compression_duration:.3f}с, всего {duration:.3f}с") _log_server_operation_end("sync_pull", True, duration, changes_count=len(changes)) return response except Exception as compression_error: logger.error(f"[SYNC-PULL-COMPRESSION-ERROR] Ошибка сжатия: {compression_error}") _log_server_operation_end("sync_pull", False, time.time() - start_time, error=f"Ошибка сжатия: {compression_error}") return jsonify({'success': False, 'error': 'Compression failed'}), 500 duration = time.time() - start_time logger.info(f"[SYNC-PULL-SUMMARY] Клиент {client_id[:8]}...: отправлено {len(changes)} изменений, время {duration:.3f}с") _log_server_operation_end("sync_pull", True, duration, changes_count=len(changes)) return jsonify(response_data) except Exception as e: logger.error(f"[SYNC-PULL-ERROR] Ошибка sync_pull: {e}", exc_info=True) _log_server_operation_end("sync_pull", False, time.time() - start_time, error=str(e)) return jsonify({'error': str(e)}), 500 finally: _sync_semaphore.release() used_slots, available_slots = log_sync_concurrency_status() logger.info(f"[SYNC-PULL-END] Обработка завершена, освобожден слот, доступно: {available_slots + 1}/{SYNC_MAX_CONCURRENT}") @app.route('/api/sync/confirm', methods=['POST']) def sync_confirm(): """Подтверждение получения изменений клиентом""" start_time = time.time() _log_server_operation_start("sync_confirm") try: if not request.is_json: _log_server_operation_end("sync_confirm", False, time.time() - start_time, error="invalid_content_type") return jsonify({'error': 'Content-Type должен быть application/json'}), 400 data = request.get_json() if not data: _log_server_operation_end("sync_confirm", False, time.time() - start_time, error="empty_json") return jsonify({'error': 'Пустой JSON'}), 400 client_id = data.get('client_id') task_ids = data.get('task_ids', []) if not client_id: _log_server_operation_end("sync_confirm", False, time.time() - start_time, error="missing_client_id") return jsonify({'error': 'client_id обязателен'}), 400 if not task_ids: _log_server_operation_end("sync_confirm", False, time.time() - start_time, error="missing_task_ids") return jsonify({'error': 'task_ids обязателен'}), 400 _log_server_operation_start("sync_confirm", client_id=client_id, task_count=len(task_ids)) try: client_rec = SyncClient.query.filter_by(node_id=client_id).first() if client_rec: client_rec.last_seen = moscow_now() client_rec.status = 'active' db.session.commit() except Exception: db.session.rollback() confirmed_count = 0 for task_id in task_ids: task = SyncQueue.query.get(task_id) if task and task.status == 'processing': task.status = 'completed' task.completed_at = moscow_now() confirmed_count += 1 if task.target_node_id == client_id and task.source_node_id: original_task = SyncQueue.query.filter( SyncQueue.table_name == task.table_name, SyncQueue.record_id == task.record_id, SyncQueue.action == task.action, SyncQueue.target_node_id.is_(None), # Универсальная задача SyncQueue.id != task.id # Не сама задача ).first() if original_task: delivered_clients = [] if original_task.delivered_to_clients: try: delivered_clients = json.loads(original_task.delivered_to_clients) except: delivered_clients = [] if client_id not in delivered_clients: delivered_clients.append(client_id) original_task.delivered_to_clients = json.dumps(delivered_clients) print(f"🔍 Отмечена доставка универсальной задачи {original_task.id} клиенту {client_id}") try: active_clients = SyncClient.query.filter_by(is_enabled=True, status='active').count() if active_clients > 0 and len(set(delivered_clients)) >= active_clients: original_task.status = 'completed' original_task.completed_at = moscow_now() print(f"🔍 Универсальная задача {original_task.id} помечена completed (доставлена всем активным клиентам)") except Exception as _cover_err: print(f"⚠️ Не удалось завершить универсальную задачу по покрытию клиентов: {_cover_err}") db.session.commit() duration = time.time() - start_time _log_server_operation_end("sync_confirm", True, duration, confirmed_count=confirmed_count) return jsonify({ 'success': True, 'message': f'Подтверждено {confirmed_count} задач', 'confirmed_count': confirmed_count, 'timestamp': moscow_now().isoformat() }) except Exception as e: duration = time.time() - start_time _log_server_error_with_traceback(e, "sync_confirm") _log_server_operation_end("sync_confirm", False, duration) db.session.rollback() return jsonify({'error': str(e)}), 500 @app.route('/api/sync/push', methods=['POST']) def sync_push(): """Отправка изменений на сервер""" start_time = time.time() _log_server_operation_start("sync_push") used_slots, available_slots = log_sync_concurrency_status() if not _sync_semaphore.acquire(blocking=False): logger.warning(f"[SYNC-PUSH-BLOCKED] Запрос заблокирован: все слоты заняты ({used_slots}/{SYNC_MAX_CONCURRENT})") _log_server_operation_end("sync_push", False, time.time() - start_time, error="too_many_concurrent_syncs") return jsonify({'error': 'Слишком много одновременных синхронизаций. Попробуйте позже.'}), 429 logger.info(f"[SYNC-PUSH-START] Начало обработки, доступно слотов: {available_slots - 1}/{SYNC_MAX_CONCURRENT}") try: content_encoding = request.headers.get('Content-Encoding', '') if content_encoding == 'gzip': try: import gzip decompressed_data = gzip.decompress(request.data) data = json.loads(decompressed_data.decode('utf-8')) except (gzip.BadGzipFile, json.JSONDecodeError, UnicodeDecodeError) as e: logger.error(f"[SYNC-PUSH] Ошибка распаковки: {e}") _log_server_operation_end("sync_push", False, time.time() - start_time, error=f"decompression_failed: {e}") return jsonify({'error': 'Ошибка распаковки сжатых данных'}), 400 else: if not request.is_json: _log_server_operation_end("sync_push", False, time.time() - start_time, error="invalid_content_type") return jsonify({'error': 'Content-Type должен быть application/json'}), 400 data = request.get_json() if not data: _log_server_operation_end("sync_push", False, time.time() - start_time, error="empty_json") return jsonify({'error': 'Пустой JSON'}), 400 client_id = data.get('client_id') changes = data.get('changes', []) if not client_id: _log_server_operation_end("sync_push", False, time.time() - start_time, error="missing_client_id") return jsonify({'error': 'client_id обязателен'}), 400 logger.info(f"[SYNC-PUSH] Клиент {client_id[:8]}...: получено {len(changes)} изменений") if not check_sync_rate_limit(client_id): logger.warning(f"[SYNC-PUSH-RATE-LIMIT] Клиент {client_id[:8]}... превысил rate limit") _log_server_operation_end("sync_push", False, time.time() - start_time, error="rate_limit_exceeded") return jsonify({'error': 'Превышен лимит запросов. Попробуйте позже.'}), 429 _log_server_operation_start("sync_push", client_id=client_id, changes_count=len(changes)) report_tables = {'loading_report', 'unloading_report', 'loading_report_component', 'component_loading_time', 'unloading_report_group'} main_db_changes = [] report_changes_list = [] for change in changes: table_name = change['table_name'] if table_name in report_tables: report_changes_list.append(change) else: main_db_changes.append(change) logger.info(f"[SYNC-PUSH-BATCH] Основная БД: {len(main_db_changes)} изменений, Отчеты: {len(report_changes_list)} изменений") conflicts = [] applied_changes = [] server_master_tables = ['component', 'recipe', 'ingredient', 'unloading_group', 'feed_mixer', 'feeding_location', 'feeding_period', 'feeding_point', 'feed_dispenser', 'period_recipe', 'trip'] client_master_tables = ['loading_report', 'loading_report_component', 'component_loading_time', 'unloading_report', 'unloading_report_group'] batch_count = 0 total_batches = (len(main_db_changes) + SYNC_BATCH_SIZE - 1) // SYNC_BATCH_SIZE if main_db_changes else 0 if total_batches > 0: logger.info(f"[SYNC-PUSH-BATCH] Обработка основной БД: {total_batches} батчей по {SYNC_BATCH_SIZE} изменений") for i in range(0, len(main_db_changes), SYNC_BATCH_SIZE): batch_start_time = time.time() batch = main_db_changes[i:i + SYNC_BATCH_SIZE] batch_count += 1 batch_applied = 0 batch_conflicts = 0 for change in batch: table_name = change['table_name'] record_id = change['record_id'] action = change['action'] client_data = change['data'] is_server_master = table_name in server_master_tables is_client_master = table_name in client_master_tables if is_server_master: conflict = check_for_sync_conflict(table_name, record_id, client_data) if conflict: conflicts.append(conflict) batch_conflicts += 1 continue change_start = time.time() result = apply_sync_change(table_name, record_id, action, client_data) change_duration = time.time() - change_start if change_duration > 0.5: # Логируем медленные операции logger.warning(f"[SYNC-PUSH-SLOW] Медленная операция: {table_name}.{action} {record_id[:8]}... заняла {change_duration:.3f}с") if result['success']: applied_changes.append({ 'table_name': table_name, 'record_id': record_id, 'action': action }) batch_applied += 1 else: error_conflict = create_sync_conflict( table_name=table_name, record_id=record_id, conflict_type='application_error', local_data=client_data, remote_data=None, resolution='pending' ) if error_conflict: conflicts.append({ 'id': error_conflict.id, 'table_name': table_name, 'record_id': record_id, 'action': action, 'error': result['error'], 'conflict_type': 'application_error' }) batch_conflicts += 1 if batch: batch_duration = time.time() - batch_start_time logger.info(f"[SYNC-PUSH-BATCH] Батч {batch_count}/{total_batches} обработан: применено {batch_applied}, конфликтов {batch_conflicts}, время {batch_duration:.3f}с (коммит будет выполнен после обработки всех батчей)") if report_changes_list: reports_start_time = time.time() logger.info(f"[SYNC-PUSH-REPORTS] Начало обработки {len(report_changes_list)} отчетов") reports_applied = 0 reports_conflicts = 0 for change in report_changes_list: table_name = change['table_name'] record_id = change['record_id'] action = change['action'] client_data = change['data'] change_start = time.time() result = apply_sync_change(table_name, record_id, action, client_data) change_duration = time.time() - change_start if change_duration > 0.5: logger.warning(f"[SYNC-PUSH-REPORTS-SLOW] Медленная операция отчета: {table_name}.{action} {record_id[:8]}... заняла {change_duration:.3f}с") if result['success']: applied_changes.append({ 'table_name': table_name, 'record_id': record_id, 'action': action }) reports_applied += 1 else: error_conflict = create_sync_conflict( table_name=table_name, record_id=record_id, conflict_type='application_error', local_data=client_data, remote_data=None, resolution='pending' ) if error_conflict: conflicts.append({ 'id': error_conflict.id, 'table_name': table_name, 'record_id': record_id, 'action': action, 'error': result['error'], 'conflict_type': 'application_error' }) reports_conflicts += 1 reports_duration = time.time() - reports_start_time logger.info(f"[SYNC-PUSH-REPORTS] Обработка отчетов завершена: применено {reports_applied}, конфликтов {reports_conflicts}, время {reports_duration:.3f}с") # Атомарный коммит для всех применённых изменений основной БД и отчетов. try: commit_start = time.time() db_commit_with_retry() commit_duration = time.time() - commit_start logger.info(f"[SYNC-PUSH-COMMIT] Глобальный коммит sync_push выполнен за {commit_duration:.3f}с") except Exception as commit_error: logger.error(f"[SYNC-PUSH-COMMIT-ERROR] Ошибка глобального коммита sync_push: {commit_error}", exc_info=True) db.session.rollback() duration = time.time() - start_time _log_server_operation_end( "sync_push", False, duration, error=f"commit_failed: {commit_error}", ) return jsonify({"success": False, "error": "Commit failed"}), 500 _log_server_changes_statistics(applied_changes, "применено на сервере") if conflicts: _log_server_changes_statistics(conflicts, "конфликты на сервере") response_data = { 'success': True, 'applied_changes': applied_changes, 'conflicts': conflicts, 'total_applied': len(applied_changes), 'total_conflicts': len(conflicts), 'timestamp': moscow_now().isoformat() } compression_requested = data.get('compression', False) force_compression = data.get('force_compression', False) if force_compression or compression_requested: try: import gzip json_data = json.dumps(response_data, ensure_ascii=False) json_bytes = json_data.encode('utf-8') compressed_data = gzip.compress(json_bytes) compressed_size = len(compressed_data) compression_ratio = compressed_size / len(json_bytes) if len(json_bytes) > 0 else 0 logger.debug(f"[SYNC-PUSH-COMPRESSION] Сжатие: {len(json_bytes)} → {compressed_size} байт (коэффициент: {compression_ratio:.2f})") response = app.response_class( response=compressed_data, status=200, mimetype='application/json', headers={'Content-Encoding': 'gzip'} ) duration = time.time() - start_time logger.info(f"[SYNC-PUSH-SUMMARY] Клиент {client_id[:8]}...: применено {len(applied_changes)}, конфликтов {len(conflicts)}, время {duration:.3f}с") _log_server_operation_end("sync_push", True, duration, applied=len(applied_changes), conflicts=len(conflicts)) return response except Exception as compression_error: logger.error(f"[SYNC-PUSH-COMPRESSION-ERROR] Ошибка сжатия: {compression_error}") _log_server_operation_end("sync_push", False, time.time() - start_time, error=f"Ошибка сжатия: {compression_error}") return jsonify({'success': False, 'error': 'Compression failed'}), 500 duration = time.time() - start_time logger.info(f"[SYNC-PUSH-SUMMARY] Клиент {client_id[:8]}...: применено {len(applied_changes)}, конфликтов {len(conflicts)}, время {duration:.3f}с") _log_server_operation_end("sync_push", True, duration, applied=len(applied_changes), conflicts=len(conflicts)) return jsonify(response_data) except Exception as e: logger.error(f"[SYNC-PUSH-ERROR] Ошибка sync_push: {e}", exc_info=True) _log_server_operation_end("sync_push", False, time.time() - start_time, error=str(e)) return jsonify({'success': False, 'error': str(e)}), 500 finally: _sync_semaphore.release() used_slots, available_slots = log_sync_concurrency_status() logger.info(f"[SYNC-PUSH-END] Обработка завершена, освобожден слот, доступно: {available_slots + 1}/{SYNC_MAX_CONCURRENT}") def get_record_data_for_sync(table_name, record_id): """Получение данных записи для синхронизации""" try: model_map = { 'component': Component, 'recipe': Recipe, 'ingredient': Ingredient, 'unloading_group': UnloadingGroup, 'loading_report': LoadingReport, 'unloading_report': UnloadingReport, 'feed_dispenser': FeedDispenser, 'feeding_period': FeedingPeriod, 'period_recipes': PeriodRecipe } model = model_map.get(table_name) if not model: return None if table_name == 'period_recipes': period_id, recipe_id = record_id.split(':') record = model.query.filter_by( period_id=period_id, recipe_id=recipe_id ).first() else: record = model.query.get(record_id) if not record: # print(f"🔍 [get_record_data_for_sync] ❌ Запись не найдена: {table_name}.{record_id}") return None # print(f"🔍 [get_record_data_for_sync] Найдена запись: {table_name}.{record_id}") # if hasattr(record, 'id'): # print(f"🔍 [get_record_data_for_sync] record.id = {record.id}") # if hasattr(record, 'name'): # print(f"🔍 [get_record_data_for_sync] record.name = {getattr(record, 'name', 'N/A')}") # if hasattr(record, 'version'): # print(f"🔍 [get_record_data_for_sync] record.version = {getattr(record, 'version', 'N/A')}") record_data = get_object_data(record) # if record_data: # print(f"🔍 [get_record_data_for_sync] ✅ Данные получены: {len(record_data)} полей") # print(f"🔍 [get_record_data_for_sync] Ключи в данных: {list(record_data.keys())}") # print(f"🔍 [get_record_data_for_sync] Есть ли 'id' в данных: {'id' in record_data}") # print(f"🔍 [get_record_data_for_sync] Есть ли 'version' в данных: {'version' in record_data}") # print(f"🔍 [get_record_data_for_sync] Есть ли 'content_hash' в данных: {'content_hash' in record_data}") if record_data: if table_name == 'period_recipes': # print("🔍 [get_record_data_for_sync] PeriodRecipe: составной ключ, 'id' не используется") # print(f"🔍 [get_record_data_for_sync] period_id={record_data.get('period_id')}, recipe_id={record_data.get('recipe_id')}") pass else: if 'id' not in record_data: logger.warning(f"[get_record_data_for_sync] Нет 'id' в данных для {table_name}.{record_id}") # else: # print(f"🔍 [get_record_data_for_sync] ❌ Данные не получены (None)") return record_data except Exception as e: # print(f"🔍 [get_record_data_for_sync] ❌ Ошибка получения данных записи: {e}") # import traceback # print(f"🔍 [get_record_data_for_sync] Traceback: {traceback.format_exc()}") return None def create_sync_conflict(table_name, record_id, conflict_type, local_data, remote_data, resolution='pending'): """Создание записи конфликта в базе данных""" try: import json conflict = SyncConflict( table_name=table_name, record_id=record_id, conflict_type=conflict_type, local_data=json.dumps(local_data, ensure_ascii=False) if local_data else None, remote_data=json.dumps(remote_data, ensure_ascii=False) if remote_data else None, resolution=resolution ) db.session.add(conflict) db.session.commit() # print(f"🔴 Создан конфликт: {table_name}.{record_id} - {conflict_type}") return conflict except Exception as e: # print(f"❌ Ошибка создания конфликта: {e}") raise db.session.rollback() return None def check_for_sync_conflict(table_name, record_id, client_data): """Проверка на конфликт данных""" try: server_record = get_record_data_for_sync(table_name, record_id) if not server_record: return None # Нет конфликта, запись не существует server_version = server_record.get('version', 1) client_version = client_data.get('version', 1) if server_version == client_version: return None # Версии одинаковые, нет конфликта conflict = create_sync_conflict( table_name=table_name, record_id=record_id, conflict_type='version_mismatch', local_data=client_data, remote_data=server_record ) if conflict: return { 'id': conflict.id, 'table_name': table_name, 'record_id': record_id, 'server_version': server_version, 'client_version': client_version, 'server_data': server_record, 'client_data': client_data, 'conflict_type': 'version_mismatch' } return None except Exception as e: print(f"❌ Ошибка проверки конфликта: {e}") return None def db_commit_with_retry(session=None, attempts=3, base_delay=0.2): """Коммит с повтором при временных блокировках SQLite""" import time for i in range(attempts): try: commit_start = time.time() (session or db.session).commit() commit_duration = time.time() - commit_start if commit_duration > 0.1: # Логируем медленные коммиты logger.warning(f"[DB-COMMIT-SLOW] Медленный коммит: {commit_duration:.3f}с") elif i > 0: # Логируем успешные повторы logger.info(f"[DB-COMMIT-RETRY] Коммит успешен после {i} попыток") return except Exception as e: msg = str(e).lower() if (('database is locked' in msg) or ('busy' in msg)) and i < attempts - 1: delay = base_delay * (2 ** i) logger.warning(f"[DB-COMMIT-LOCKED] БД заблокирована, попытка {i+1}/{attempts}, ожидание {delay:.2f}с") time.sleep(delay) continue logger.error(f"[DB-COMMIT-ERROR] Ошибка коммита после {i+1} попыток: {e}", exc_info=True) raise def check_sync_rate_limit(client_id): """Проверка rate limit для синхронизации""" now = time.time() window = 60 # 1 минута while _sync_rate_limits[client_id] and _sync_rate_limits[client_id][0] <= now - window: _sync_rate_limits[client_id].popleft() current_count = len(_sync_rate_limits[client_id]) if current_count >= SYNC_RATE_LIMIT: logger.warning(f"[SYNC-RATE-LIMIT] Клиент {client_id[:8]}... превысил лимит: {current_count}/{SYNC_RATE_LIMIT} запросов за минуту") return False _sync_rate_limits[client_id].append(now) logger.debug(f"[SYNC-RATE-LIMIT] Клиент {client_id[:8]}...: {current_count + 1}/{SYNC_RATE_LIMIT} запросов за минуту") return True def log_sync_concurrency_status(): """Логирование статуса параллелизма синхронизации""" available = _sync_semaphore._value used = SYNC_MAX_CONCURRENT - available logger.info(f"[SYNC-CONCURRENCY] Используется: {used}/{SYNC_MAX_CONCURRENT} слотов синхронизации") return used, available def requeue_stuck_processing(timeout_minutes=15): """Возвращает зависшие задачи processing -> pending по таймауту""" from datetime import datetime, timedelta from sqlalchemy import text with app.app_context(): cutoff = datetime.now() - timedelta(minutes=timeout_minutes) # Используем bulk update через SQL для избежания массового логирования # и повышения производительности engine = db.engine with engine.connect() as conn: # Сначала получаем количество зависших задач count_result = conn.execute(text(""" SELECT COUNT(*) FROM sync_queue WHERE status = 'processing' AND processed_at IS NOT NULL AND processed_at < :cutoff """), {'cutoff': cutoff}) count = count_result.scalar() if count == 0: return 0 # Выполняем bulk update через SQL # Это избегает срабатывания SQLAlchemy event listeners для каждого объекта result = conn.execute(text(""" UPDATE sync_queue SET status = 'pending', processed_at = NULL, retry_count = MIN((COALESCE(retry_count, 0) + 1), COALESCE(max_retries, 3)), error_message = SUBSTR( COALESCE(error_message, '') || ' | auto-requeue (timeout)', 1, 1000 ) WHERE status = 'processing' AND processed_at IS NOT NULL AND processed_at < :cutoff """), {'cutoff': cutoff}) conn.commit() logger.info(f"🔁 Re-queued stuck processing: {result.rowcount} задач (timeout: {timeout_minutes} мин)") return result.rowcount def _process_data_types(data, table_name=None): """Обработка типов данных для корректного сохранения в SQLite""" from datetime import datetime processed_data = data.copy() datetime_fields = [ 'start_time', 'end_time', 'created_at', 'updated_at', 'deleted_at', 'restored_at', 'sync_timestamp' ] for field in datetime_fields: if field in processed_data and processed_data[field] is not None: value = processed_data[field] if isinstance(value, str): try: if 'T' in value: processed_data[field] = datetime.fromisoformat(value.replace('Z', '+00:00')) else: processed_data[field] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S.%f') except (ValueError, TypeError) as e: # print(f"🔧 Ошибка парсинга datetime для поля {field}: {value} - {e}") pass # print(f"🔧 ДИАГНОСТИКА ДАННЫХ:") # print(f"🔧 - report_id: {processed_data.get('report_id', 'N/A')}") # print(f"🔧 - loading_report_id: {processed_data.get('loading_report_id', 'N/A')}") # print(f"🔧 - unloading_report_id: {processed_data.get('unloading_report_id', 'N/A')}") if table_name: # print(f"🔧 - Таблица: {table_name}") if table_name in ['loading_report_component', 'component_loading_time']: if processed_data.get('report_id') is None and processed_data.get('loading_report_id') is not None: processed_data['report_id'] = processed_data['loading_report_id'] # print(f"🔧 - Исправлен report_id из loading_report_id: {processed_data['report_id']}") elif table_name == 'unloading_report_group': if processed_data.get('report_id') is None and processed_data.get('unloading_report_id') is not None: processed_data['report_id'] = processed_data['unloading_report_id'] # print(f"🔧 - Исправлен report_id из unloading_report_id: {processed_data['report_id']}") elif table_name == 'unloading_report': if processed_data.get('loading_report_id') is None and processed_data.get('report_id') is not None: processed_data['loading_report_id'] = processed_data['report_id'] # print(f"🔧 - Исправлен loading_report_id из report_id: {processed_data['loading_report_id']}") if 'report_id' in processed_data: del processed_data['report_id'] # print(f"🔧 - Удален report_id для UnloadingReport") return processed_data def apply_sync_change(table_name, record_id, action, data): """Применение изменения синхронизации""" start_time = time.time() _log_server_operation_start("apply_sync_change", table_name=table_name, record_id=record_id, action=action) try: report_tables = {'loading_report', 'unloading_report', 'loading_report_component', 'component_loading_time', 'unloading_report_group'} if table_name in report_tables: print(f"📊 ПРИМЕНЯЕМ ИЗМЕНЕНИЕ ОТЧЕТА: {table_name}.{action} {record_id[:8]}...") model_map = { 'component': Component, 'recipe': Recipe, 'ingredient': Ingredient, 'unloading_group': UnloadingGroup, 'loading_report': LoadingReport, 'loading_report_component': LoadingReportComponent, 'component_loading_time': ComponentLoadingTime, 'unloading_report': UnloadingReport, 'unloading_report_group': UnloadingReportGroup, 'feed_dispenser': FeedDispenser, 'feeding_period': FeedingPeriod, 'period_recipes': PeriodRecipe } model = model_map.get(table_name) if not model: return {'success': False, 'error': 'Неизвестная таблица'} bind_key = 'reports' if table_name in ['loading_report', 'loading_report_component', 'component_loading_time', 'unloading_report', 'unloading_report_group'] else None if table_name == 'period_recipes': period_id, recipe_id = record_id.split(':') existing_record = model.query.filter_by( period_id=period_id, recipe_id=recipe_id ).first() else: search_id = data.get('id', record_id) if bind_key: from sqlalchemy.orm import sessionmaker engine = db.engines[bind_key] Session = sessionmaker(bind=engine) search_session = Session() existing_record = search_session.query(model).get(search_id) if not existing_record: existing_record = search_session.query(model).get(record_id) search_session.close() else: existing_record = model.query.get(search_id) if not existing_record: existing_record = model.query.get(record_id) if bind_key: from sqlalchemy.orm import sessionmaker engine = db.engines[bind_key] Session = sessionmaker(bind=engine) session = Session() else: session = db.session try: if existing_record: if action == 'create': print(f"🔄 Запись уже существует, обновляем: {table_name} {record_id}") for key, value in data.items(): if hasattr(existing_record, key): setattr(existing_record, key, value) if hasattr(existing_record, 'is_deleted') and 'is_deleted' in data: existing_record.is_deleted = data['is_deleted'] existing_record.updated_at = moscow_now() existing_record.version += 1 update_content_hash(existing_record) session.flush() # Обновляем изменения в базе print(f"✅ Обновлена существующая запись: {table_name} {record_id}") elif action == 'update': for key, value in data.items(): if hasattr(existing_record, key): setattr(existing_record, key, value) if hasattr(existing_record, 'is_deleted') and 'is_deleted' in data: existing_record.is_deleted = data['is_deleted'] existing_record.updated_at = moscow_now() existing_record.version += 1 update_content_hash(existing_record) session.flush() # Обновляем изменения в базе print(f"✅ Обновлена запись: {table_name} {record_id}") elif action == 'delete': if hasattr(existing_record, 'is_deleted') and existing_record.is_deleted: logger.info(f"ℹ️ Запись уже удалена: {table_name} {record_id}") elif hasattr(existing_record, 'soft_delete'): existing_record.soft_delete( deleted_by='sync', reason='Синхронизация с клиента', request=None ) logger.info(f"✅ Удалена запись: {table_name} {record_id}") else: session.delete(existing_record) logger.info(f"✅ Физически удалена запись: {table_name} {record_id}") elif action == 'delete_confirmed': logger.info(f"✅ Подтверждено удаление записи: {table_name} {record_id}") else: if action == 'create': processed_data = _process_data_types(data, table_name) record = model(**processed_data) if hasattr(record, 'is_deleted'): record.is_deleted = processed_data.get('is_deleted', False) if hasattr(record, 'id') and record_id: record.id = record_id # print(f"🔍 Установлен ID записи: {record_id}") session.add(record) session.flush() # Получаем ID update_content_hash(record) # print(f"✅ Создана новая запись: {table_name} {record_id}") elif action == 'update': # print(f"⚠️ Запись не найдена для обновления, создаем: {table_name} {record_id}") pass processed_data = _process_data_types(data, table_name) record = model(**processed_data) if hasattr(record, 'is_deleted'): record.is_deleted = processed_data.get('is_deleted', False) if hasattr(record, 'id') and record_id: record.id = record_id print(f"🔍 Установлен ID записи (update): {record_id}") session.add(record) session.flush() update_content_hash(record) # print(f"✅ Создана запись вместо обновления: {table_name} {record_id}") # print(f"🔍 ДИАГНОСТИКА СОЗДАНИЯ ЗАПИСИ:") # print(f"🔍 - Таблица: {table_name}") # print(f"🔍 - ID записи: {record_id}") # print(f"🔍 - Bind key: {bind_key}") # print(f"🔍 - Сессия: {type(session).__name__}") # print(f"🔍 - Запись в сессии: {record in session}") # print(f"🔍 - Новые записи в сессии: {len(session.new)}") # print(f"🔍 - Изменения в сессии: {len(session.dirty)}") # print(f"🔍 - ID записи после flush: {getattr(record, 'id', 'N/A')}") elif action == 'delete': logger.info(f"ℹ️ Запись не найдена для удаления: {table_name} {record_id}") try: create_sync_task(table_name, record_id, 'delete_confirmed', priority=1, target_node_id=None) logger.info(f"📤 Создана задача подтверждения удаления: {table_name} {record_id}") except Exception as e: logger.error(f"❌ Ошибка создания задачи подтверждения удаления: {e}") elif action == 'delete_confirmed': logger.info(f"✅ Подтверждено удаление несуществующей записи: {table_name} {record_id}") session.commit() # print(f"🔍 ДИАГНОСТИКА ПОСЛЕ КОММИТА:") # print(f"🔍 - Таблица: {table_name}") # print(f"🔍 - ID записи: {record_id}") # print(f"🔍 - Bind key: {bind_key}") # print(f"🔍 - Коммит выполнен успешно") if action in ['create', 'update']: try: if bind_key: from sqlalchemy.orm import sessionmaker engine = db.engines[bind_key] Session = sessionmaker(bind=engine) check_session = Session() else: check_session = db.session check_record = check_session.query(model).get(record_id) if check_record: print(f"🔍 - Запись подтверждена в базе: {table_name} {record_id[:8]}...") print(f"🔍 - ID в базе: {getattr(check_record, 'id', 'N/A')}") print(f"🔍 - is_deleted: {getattr(check_record, 'is_deleted', 'N/A')}") print(f"🔍 - version: {getattr(check_record, 'version', 'N/A')}") else: print(f"🔍 - ❌ Запись НЕ найдена в базе после коммита!") if bind_key and check_session != db.session: check_session.close() except Exception as check_error: print(f"🔍 - ❌ Ошибка проверки записи: {check_error}") except Exception as e: print(f"🔍 ❌ ОШИБКА В СЕССИИ:") print(f"🔍 - Таблица: {table_name}") print(f"🔍 - ID записи: {record_id}") print(f"🔍 - Bind key: {bind_key}") print(f"🔍 - Ошибка: {e}") print(f"🔍 - Тип ошибки: {type(e)}") session.rollback() raise e finally: if bind_key and session != db.session: session.close() if table_name in report_tables: print(f"📊 ОТЧЕТ УСПЕШНО ПРИМЕНЕН: {table_name}.{action} {record_id[:8]}...") duration = time.time() - start_time _log_server_operation_end("apply_sync_change", True, duration) return {'success': True} except Exception as e: duration = time.time() - start_time _log_server_error_with_traceback(e, "apply_sync_change") _log_server_operation_end("apply_sync_change", False, duration) if 'bind_key' not in locals() or not bind_key: db.session.rollback() return {'success': False, 'error': str(e)} # --- Склад компонентов: остатки в recipes.db (component_stock), расход считаем из reports.db для отображения --- try: from sklad import init_sklad as _init_sklad _init_sklad(app, db, Component=Component, LoadingReport=LoadingReport, LoadingReportComponent=LoadingReportComponent, Recipe=Recipe, Ingredient=Ingredient) except Exception as _sklad_err: try: logger.warning(f"[SKLAD] init failed: {_sklad_err}") except Exception: pass if __name__ == '__main__': try: if not check_mac_authorization(): print("Приложение запущено в режиме блокировки. Доступ к маршрутам ограничен.") load_calibration_factor() load_initial_weight() start_read_weight_thread() # Индекс и чистка дубликатов для sync_queue теперь живут в Alembic-миграциях. start_cleanup_scheduler() try: with app.app_context(): requeue_stuck_processing(timeout_minutes=15) except Exception as e: print(f"⚠️ requeue at startup failed: {e}") try: from sync_client import init_sync_client init_sync_client() except ImportError: print("⚠️ Модуль sync_client не найден, синхронизация отключена") except Exception as e: print(f"⚠️ Ошибка инициализации синхронизации: {e}") try: from update import init_auto_updater init_auto_updater() except ImportError: print("⚠️ Модуль update не найден, автообновление отключено") except Exception as e: print(f"⚠️ Ошибка инициализации автообновления: {e}") print("Сервер запущен на http://127.0.0.1:5000") serve(app, host='0.0.0.0', port=5000, threads=WAITRESS_THREADS) except KeyboardInterrupt: print("\nЗавершение работы...") finally: running = False try: from sync_client import stop_sync_client stop_sync_client() except: pass try: from update import stop_auto_updater stop_auto_updater() except: pass if GPIO_AVAILABLE: GPIO.output(LED_PIN, GPIO.LOW) GPIO.cleanup() print("Система остановлена")