8878 lines
417 KiB
Python
8878 lines
417 KiB
Python
"""
|
||
Легаси-монолит 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/<string:id>', 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/<string:id>', 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/<string:id>', 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/<id>).
|
||
# При создании рецепта не меняем компоненты "из ингредиентов", иначе возможны откаты СВ% (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/<id>. Здесь намеренно не трогаем 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/<string:recipe_id>', 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/<id>).
|
||
# При сохранении рецепта не меняем компоненты "из ингредиентов", иначе возможны откаты СВ% при сохранениях.
|
||
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/<id>.
|
||
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/<string:recipe_id>', 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/<string:recipe_id>', 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/<path:filename>')
|
||
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/<state>', 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/<string:report_id>/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/<string:report_id>/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/<string:dispenser_id>', 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/<string:dispenser_id>/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/<string:dispenser_id>/periods/<string:period_id>', 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/<string:dispenser_id>/periods/<string:period_id>', 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/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>', 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/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>', 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/<string:dispenser_id>/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/<string:dispenser_id>/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/<string:period_id>/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/<string:period_id>/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/<id>).
|
||
# При создании рейса не меняем компоненты "из ингредиентов", иначе возможны откаты СВ% при сохранениях.
|
||
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/<id>.
|
||
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/<string:recipe_id>', 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/<string:recipe_id>/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/<string:entity_type>/<string:entity_id>/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/<string:entity_type>/<string:entity_id>/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/<string:entity_type>/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/<node_id>/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/<node_id>', 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/<node_id>', 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/<node_id>/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/<conflict_id>/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("Система остановлена")
|