@@ -0,0 +1,40 @@
|
||||
from flask import Blueprint, Flask
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
"""Регистрирует все blueprints приложения.
|
||||
|
||||
Каркас маршрутов; легаси-сверка — legacy/proga_monolith.py.
|
||||
"""
|
||||
# Заглушки для будущих модулей
|
||||
from . import auth, components, recipes, reports, reports_legacy, equipment, periods, sync, scales, sklad, pages, legacy_misc, kiosk, admin, setup, startup, feed_accounting, notifications, feed_quality, daily_plan, analytics, lab # noqa: F401
|
||||
|
||||
# Каждый модуль должен предоставить blueprint переменной bp
|
||||
for module in (
|
||||
startup,
|
||||
auth,
|
||||
components,
|
||||
recipes,
|
||||
reports,
|
||||
reports_legacy,
|
||||
equipment,
|
||||
periods,
|
||||
sync,
|
||||
scales,
|
||||
sklad,
|
||||
pages,
|
||||
legacy_misc,
|
||||
kiosk,
|
||||
admin,
|
||||
setup,
|
||||
feed_accounting,
|
||||
notifications,
|
||||
feed_quality,
|
||||
daily_plan,
|
||||
analytics,
|
||||
lab,
|
||||
):
|
||||
bp = getattr(module, "bp", None)
|
||||
if bp is not None:
|
||||
app.register_blueprint(bp)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from app.routes.auth_decorators import require_auth
|
||||
from app.services.analytics.export_sections import export_filename_base, parse_export_sections
|
||||
from app.services.analytics.finance_summary import build_finance_summary
|
||||
from app.services.analytics.pdf_export import build_analytics_pdf
|
||||
from app.services.analytics.plan_fact_layers import build_plan_fact_rows
|
||||
from app.services.analytics.stock_forecast import build_stock_forecast
|
||||
from app.services.analytics.xlsx_summary import build_finance_summary_xlsx
|
||||
|
||||
bp = Blueprint("analytics", __name__, url_prefix="/api/analytics")
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
def _export_params():
|
||||
date_from = (request.args.get("date_from") or "").strip()
|
||||
date_to = (request.args.get("date_to") or "").strip()
|
||||
if not date_from or not date_to:
|
||||
return None, None, None, None, _error("Укажите date_from и date_to (YYYY-MM-DD)", 400)
|
||||
recipe_id = (request.args.get("recipe_id") or "").strip() or None
|
||||
recipe_ids = (request.args.get("recipe_ids") or "").strip() or None
|
||||
return date_from, date_to, recipe_id, recipe_ids, None
|
||||
|
||||
|
||||
@bp.get("/finance")
|
||||
@require_auth
|
||||
def finance_summary():
|
||||
return jsonify(
|
||||
build_finance_summary(
|
||||
date_from=request.args.get("date_from"),
|
||||
date_to=request.args.get("date_to"),
|
||||
recipe_id=(request.args.get("recipe_id") or "").strip() or None,
|
||||
recipe_ids=(request.args.get("recipe_ids") or "").strip() or None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/plan-fact")
|
||||
@require_auth
|
||||
def plan_fact():
|
||||
return jsonify(
|
||||
build_plan_fact_rows(
|
||||
date_from=request.args.get("date_from"),
|
||||
date_to=request.args.get("date_to"),
|
||||
recipe_id=(request.args.get("recipe_id") or "").strip() or None,
|
||||
recipe_ids=(request.args.get("recipe_ids") or "").strip() or None,
|
||||
client_id=(request.args.get("client_id") or "").strip() or None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/stock-forecast")
|
||||
@require_auth
|
||||
def stock_forecast():
|
||||
from app.models import Component
|
||||
|
||||
return jsonify(
|
||||
build_stock_forecast(
|
||||
plan_date=request.args.get("plan_date"),
|
||||
Component=Component,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _filter_context_kwargs():
|
||||
return {
|
||||
"filter_farms": (request.args.get("filter_farms") or "").strip() or None,
|
||||
"filter_dispensers": (request.args.get("filter_dispensers") or "").strip() or None,
|
||||
"filter_recipes": (request.args.get("filter_recipes") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/export")
|
||||
@require_auth
|
||||
def analytics_export():
|
||||
date_from, date_to, recipe_id, recipe_ids, err = _export_params()
|
||||
if err:
|
||||
return err
|
||||
fmt = (request.args.get("format") or "xlsx").strip().lower()
|
||||
section = (request.args.get("section") or "all").strip().lower()
|
||||
sections = parse_export_sections(section)
|
||||
base_name = export_filename_base(
|
||||
date_from=date_from, date_to=date_to, sections=sections
|
||||
)
|
||||
ctx = _filter_context_kwargs()
|
||||
if fmt == "pdf":
|
||||
data = build_analytics_pdf(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
recipe_id=recipe_id,
|
||||
recipe_ids=recipe_ids,
|
||||
section=section,
|
||||
**ctx,
|
||||
)
|
||||
return Response(
|
||||
data,
|
||||
mimetype="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{base_name}.pdf"'
|
||||
},
|
||||
)
|
||||
if fmt in ("xlsx", "excel"):
|
||||
data = build_finance_summary_xlsx(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
recipe_id=recipe_id,
|
||||
recipe_ids=recipe_ids,
|
||||
section=section,
|
||||
**ctx,
|
||||
)
|
||||
return Response(
|
||||
data,
|
||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{base_name}.xlsx"'
|
||||
},
|
||||
)
|
||||
return _error("format должен быть xlsx или pdf", 400)
|
||||
|
||||
|
||||
@bp.get("/finance/export")
|
||||
@require_auth
|
||||
def finance_export():
|
||||
date_from, date_to, recipe_id, recipe_ids, err = _export_params()
|
||||
if err:
|
||||
return err
|
||||
section = (request.args.get("section") or "all").strip().lower()
|
||||
sections = parse_export_sections(section)
|
||||
data = build_finance_summary_xlsx(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
recipe_id=recipe_id,
|
||||
recipe_ids=recipe_ids,
|
||||
section=section,
|
||||
**_filter_context_kwargs(),
|
||||
)
|
||||
filename = f"{export_filename_base(date_from=date_from, date_to=date_to, sections=sections)}.xlsx"
|
||||
return Response(
|
||||
data,
|
||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from flask import Blueprint, current_app, jsonify, request, session
|
||||
|
||||
from app import db
|
||||
from app.models import WebUser
|
||||
from config import Config
|
||||
from app.routes.auth_decorators import can_access_lab, require_auth
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
_PASSWORD_MIN_LEN = 8
|
||||
_USER_RULES_PAYLOAD = {
|
||||
"password_min_len": _PASSWORD_MIN_LEN,
|
||||
}
|
||||
|
||||
|
||||
def load_credentials():
|
||||
"""Получение учетных данных из переменных окружения."""
|
||||
return (
|
||||
current_app.config.get("AUTH_LOGIN", getattr(Config, "AUTH_LOGIN", "admin")),
|
||||
current_app.config.get(
|
||||
"AUTH_PASSWORD", getattr(Config, "AUTH_PASSWORD", "admin")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_default_superuser() -> None:
|
||||
saved_login, saved_password = load_credentials()
|
||||
login_key = saved_login.strip()
|
||||
if db.session.scalar(select(WebUser.id).where(WebUser.login == login_key).limit(1)):
|
||||
return
|
||||
if db.session.scalar(select(WebUser.id).limit(1)):
|
||||
return
|
||||
|
||||
user = WebUser(
|
||||
login=login_key,
|
||||
password_hash=generate_password_hash(saved_password),
|
||||
is_superuser=True,
|
||||
)
|
||||
db.session.add(user)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def _get_user_by_login(login: str) -> WebUser | None:
|
||||
return db.session.execute(
|
||||
select(WebUser).where(WebUser.login == login.strip())
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def save_credentials(login: str, password: str) -> None:
|
||||
"""Смена учетных данных через API отключена (env-only)."""
|
||||
_ = (login, password)
|
||||
raise RuntimeError(
|
||||
"Смена учетных данных через API отключена. Используйте WESP_AUTH_LOGIN/WESP_AUTH_PASSWORD."
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def auth_ping():
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@bp.post("/login")
|
||||
def authenticate_login():
|
||||
data = request.json or {}
|
||||
login = (data.get("login") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not login:
|
||||
return jsonify({"status": "error", "message": "Не указан логин"}), 400
|
||||
if not password:
|
||||
return jsonify({"status": "error", "message": "Не указан пароль"}), 400
|
||||
|
||||
_ensure_default_superuser()
|
||||
user = _get_user_by_login(login)
|
||||
if user is not None and check_password_hash(user.password_hash, password):
|
||||
remember = bool(data.get("remember"))
|
||||
session["authenticated"] = True
|
||||
session["user_login"] = user.login
|
||||
session["is_superuser"] = bool(user.is_superuser)
|
||||
session["lab_access"] = bool(user.is_superuser or user.lab_access)
|
||||
session["remember_login"] = remember
|
||||
if remember:
|
||||
session.permanent = True
|
||||
else:
|
||||
session.permanent = False
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Авторизация успешна",
|
||||
"authenticated": True,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Неверный логин или пароль",
|
||||
"authenticated": False,
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify({"status": "success", "message": "Выход выполнен успешно"})
|
||||
|
||||
|
||||
@bp.get("/check")
|
||||
def check_auth():
|
||||
authenticated = session.get("authenticated", False)
|
||||
user_login = session.get("user_login", "")
|
||||
remember_login = bool(session.get("remember_login"))
|
||||
is_superuser = bool(session.get("is_superuser", False))
|
||||
can_lab = can_access_lab() if authenticated else False
|
||||
if authenticated:
|
||||
session["lab_access"] = can_lab
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"authenticated": authenticated,
|
||||
"user_login": user_login,
|
||||
"remember_login": remember_login,
|
||||
"is_superuser": is_superuser,
|
||||
"can_lab": can_lab,
|
||||
"user_rules": _USER_RULES_PAYLOAD,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/get_current_credentials")
|
||||
def get_current_credentials():
|
||||
current_login = (session.get("user_login") or "").strip()
|
||||
if not current_login:
|
||||
saved_login, _saved_password = load_credentials()
|
||||
current_login = saved_login
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"login": current_login,
|
||||
# Никогда не возвращаем пароль в API.
|
||||
"password": "",
|
||||
"user_rules": _USER_RULES_PAYLOAD,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/change_credentials")
|
||||
@require_auth
|
||||
def change_credentials():
|
||||
from app.routes.admin import (
|
||||
_WEB_USER_PASSWORD_MIN_LEN,
|
||||
_reject_if_weak_login_or_password,
|
||||
_valid_login,
|
||||
)
|
||||
|
||||
data = request.json or {}
|
||||
old_login = (data.get("old_login") or "").strip()
|
||||
old_password = data.get("old_password") or ""
|
||||
new_login = (data.get("new_login") or "").strip()
|
||||
new_password = data.get("new_password") or ""
|
||||
confirm_password = data.get("confirm_password") or new_password
|
||||
|
||||
session_login = (session.get("user_login") or "").strip()
|
||||
if not session_login:
|
||||
return jsonify({"status": "error", "message": "Сессия не найдена"}), 401
|
||||
|
||||
user = _get_user_by_login(session_login)
|
||||
if user is None:
|
||||
return jsonify({"status": "error", "message": "Пользователь не найден"}), 404
|
||||
|
||||
if old_login != user.login:
|
||||
return (
|
||||
jsonify({"status": "error", "message": "Неверный текущий логин или пароль"}),
|
||||
401,
|
||||
)
|
||||
if not check_password_hash(user.password_hash, old_password):
|
||||
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
|
||||
if new_password != confirm_password:
|
||||
return jsonify({"status": "error", "message": "Новые пароли не совпадают"}), 400
|
||||
|
||||
if not _valid_login(new_login):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Логин не длиннее 64 символов",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
if len(new_password) < _WEB_USER_PASSWORD_MIN_LEN:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"Пароль должен быть не короче {_WEB_USER_PASSWORD_MIN_LEN} символов",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
err_body, err_code = _reject_if_weak_login_or_password(new_login, new_password)
|
||||
if err_body is not None:
|
||||
return jsonify(err_body), err_code
|
||||
|
||||
if new_login != user.login:
|
||||
existing = _get_user_by_login(new_login)
|
||||
if existing is not None and existing.id != user.id:
|
||||
return (
|
||||
jsonify({"status": "error", "message": "Пользователь с таким логином уже существует"}),
|
||||
409,
|
||||
)
|
||||
|
||||
user.login = new_login
|
||||
user.password_hash = generate_password_hash(new_password)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return (
|
||||
jsonify({"status": "error", "message": "Пользователь с таким логином уже существует"}),
|
||||
409,
|
||||
)
|
||||
|
||||
session["user_login"] = new_login
|
||||
return jsonify({"status": "success", "message": "Учетные данные обновлены"})
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import hmac
|
||||
from functools import wraps
|
||||
|
||||
from flask import current_app, jsonify, request, session
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""Session-based authorization guard for protected API routes."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not session.get("authenticated", False):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Требуется авторизация",
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def is_superuser_session() -> bool:
|
||||
if not session.get("authenticated", False):
|
||||
return False
|
||||
return bool(session.get("is_superuser", False))
|
||||
|
||||
|
||||
def can_access_lab() -> bool:
|
||||
"""Lab доступен суперпользователю или при lab_access в БД."""
|
||||
if not session.get("authenticated", False):
|
||||
return False
|
||||
if is_superuser_session():
|
||||
return True
|
||||
login = (session.get("user_login") or "").strip()
|
||||
if not login:
|
||||
return False
|
||||
try:
|
||||
from app import db
|
||||
from app.models import WebUser
|
||||
|
||||
user = db.session.execute(
|
||||
select(WebUser).where(WebUser.login == login)
|
||||
).scalar_one_or_none()
|
||||
return bool(user and user.lab_access)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def require_lab_access(f):
|
||||
"""Allow only authenticated users with Lab module access."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not session.get("authenticated", False):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Требуется авторизация",
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
if not can_access_lab():
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Модуль Lab недоступен для этого пользователя",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_superuser(f):
|
||||
"""Allow only authenticated superusers for admin APIs."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not session.get("authenticated", False):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Требуется авторизация",
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
if not is_superuser_session():
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Доступ только для суперпользователя",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def _device_token_valid() -> bool:
|
||||
if not bool(current_app.config.get("CALIBRATION_PUBLIC", False)):
|
||||
return False
|
||||
expected = str(current_app.config.get("DEVICE_TOKEN", "") or "").strip()
|
||||
if not expected:
|
||||
return False
|
||||
|
||||
header_name = str(current_app.config.get("DEVICE_TOKEN_HEADER", "X-Device-Token"))
|
||||
provided = (request.headers.get(header_name) or request.args.get("device_token") or "").strip()
|
||||
if not provided:
|
||||
return False
|
||||
return hmac.compare_digest(provided, expected)
|
||||
|
||||
|
||||
def require_session_or_device_token(f):
|
||||
"""Allow either authenticated session or valid kiosk device token."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if session.get("authenticated", False) or _device_token_valid():
|
||||
return f(*args, **kwargs)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Требуется авторизация или device token",
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def _paired_terminal_valid() -> bool:
|
||||
device_id = (request.cookies.get("wesp_kiosk_device_id") or "").strip()
|
||||
if not device_id:
|
||||
return False
|
||||
try:
|
||||
from app import db
|
||||
from app.models import KioskDevice
|
||||
|
||||
row = db.session.execute(
|
||||
select(KioskDevice).where(
|
||||
KioskDevice.id == device_id, KioskDevice.status == "active"
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_localhost_request() -> bool:
|
||||
host = (request.host.split(":")[0] if request.host else "").lower()
|
||||
remote = (request.remote_addr or "").lower()
|
||||
localhost_hosts = {"localhost", "127.0.0.1", "::1"}
|
||||
return host in localhost_hosts or remote in localhost_hosts
|
||||
|
||||
|
||||
def can_issue_kiosk_access_link() -> bool:
|
||||
"""Start URL / QR — только localhost (настройка) или сессия администратора."""
|
||||
if session.get("authenticated", False):
|
||||
return True
|
||||
return _is_localhost_request()
|
||||
|
||||
|
||||
def require_paired_terminal(f):
|
||||
"""Allow only paired kiosk terminals (or bypass when disabled by config)."""
|
||||
|
||||
_SETUP_CALIBRATION_PATHS = {
|
||||
"/calibrate",
|
||||
"/calibrate/continue",
|
||||
"/tare",
|
||||
"/current_weight",
|
||||
"/current_raw_data",
|
||||
"/stream_weight",
|
||||
}
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
path = request.path or ""
|
||||
try:
|
||||
from app.services.setup_state import is_setup_completed
|
||||
|
||||
# Во время мастера настройки — весы без привязки; в pytest (TESTING) правила
|
||||
# привязки не ослабляем, иначе test_requires_token_or_session ломается.
|
||||
if (
|
||||
not is_setup_completed(current_app)
|
||||
and path in _SETUP_CALIBRATION_PATHS
|
||||
and not current_app.config.get("TESTING")
|
||||
):
|
||||
return f(*args, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
if _is_localhost_request():
|
||||
return f(*args, **kwargs)
|
||||
if not bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True)):
|
||||
return f(*args, **kwargs)
|
||||
if _paired_terminal_valid():
|
||||
return f(*args, **kwargs)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Терминал не привязан. Откройте привязку терминала.",
|
||||
"paired": False,
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_auth_or_paired_terminal(f):
|
||||
"""Вход администратора (сессия) ИЛИ доступ как у киоска (localhost / привязанный терминал / без guard)."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if session.get("authenticated", False):
|
||||
return f(*args, **kwargs)
|
||||
if _is_localhost_request():
|
||||
return f(*args, **kwargs)
|
||||
if not bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True)):
|
||||
return f(*args, **kwargs)
|
||||
if _paired_terminal_valid():
|
||||
return f(*args, **kwargs)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Требуется вход в систему или привязанный киоск.",
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
|
||||
return decorated_function
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.lab.calc.feed_groups import is_canonical_feed_type, list_component_feed_types
|
||||
from app.lab.services.component_nutrients import nutrients_api_dict, save_component_nutrients
|
||||
from app.models import Component
|
||||
from app.routes.auth_decorators import can_access_lab, require_auth
|
||||
from app.services.component_external_no import resolve_component_external_no
|
||||
from config import Config
|
||||
|
||||
bp = Blueprint("components", __name__, url_prefix="/api/components")
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def components_ping():
|
||||
"""Health-check для модуля компонентов."""
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@bp.get("/feed-types")
|
||||
@require_auth
|
||||
def get_feed_types():
|
||||
"""Канонические типы сырья для /components и авторациона."""
|
||||
return jsonify({"types": list_component_feed_types()})
|
||||
|
||||
|
||||
def _add_no_cache_headers(response):
|
||||
"""Минимальный аналог add_no_cache_headers из легаси."""
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _component_to_api(component: Component, *, include_nutrients: bool | None = None) -> dict:
|
||||
show_nutrients = include_nutrients if include_nutrients is not None else can_access_lab()
|
||||
payload = {
|
||||
"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,
|
||||
"externalNo": component.external_no,
|
||||
"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,
|
||||
}
|
||||
if show_nutrients:
|
||||
payload["nutrients"] = nutrients_api_dict(component.id)
|
||||
else:
|
||||
payload["nutrients"] = {}
|
||||
return payload
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
def get_components():
|
||||
"""Возвращает список активных компонентов (совместимо с легаси /api/components)."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 1000))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return jsonify({"error": True, "message": "Некорректные параметры пагинации"}), 400
|
||||
|
||||
stmt = (
|
||||
select(Component)
|
||||
.where(Component.is_active.is_(True), Component.is_deleted.is_(False))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
items = db.session.execute(stmt).scalars().all()
|
||||
response = jsonify([_component_to_api(c) for c in items])
|
||||
return _add_no_cache_headers(response)
|
||||
|
||||
|
||||
@bp.get("/deleted")
|
||||
@require_auth
|
||||
def get_deleted_components():
|
||||
rows = db.session.execute(
|
||||
select(Component)
|
||||
.where(Component.is_deleted.is_(True))
|
||||
.order_by(Component.deleted_at.desc())
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"is_deleted": c.is_deleted,
|
||||
"deleted_at": c.deleted_at.isoformat() if c.deleted_at else None,
|
||||
"deleted_by": c.deleted_by,
|
||||
}
|
||||
for c in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<string:component_id>")
|
||||
@require_auth
|
||||
def get_component(component_id: str):
|
||||
"""Возвращает один компонент по ID (совместимо с легаси)."""
|
||||
component = db.session.get(Component, component_id)
|
||||
if component is None:
|
||||
return jsonify({"error": True, "message": "Компонент не найден"}), 404
|
||||
if getattr(component, "is_deleted", False):
|
||||
return jsonify({"error": True, "message": "Компонент удалён"}), 404
|
||||
|
||||
return jsonify(_component_to_api(component))
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
def create_component():
|
||||
"""Создание компонента (минимально совместимо с легаси)."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": True, "message": "Название компонента обязательно"}), 400
|
||||
comp_type = (data.get("type") or "").strip()
|
||||
if not is_canonical_feed_type(comp_type):
|
||||
return jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Выберите тип корма: Грубые / Сочные / Концентрированные / Добавки",
|
||||
}
|
||||
), 400
|
||||
|
||||
component = Component(
|
||||
name=name,
|
||||
type=comp_type,
|
||||
is_active=bool(data.get("is_active", True)),
|
||||
dry_matter=float(data.get("dry_matter", data.get("dryMatter", 0)) or 0),
|
||||
protein=float(data.get("protein", 0) or 0),
|
||||
energy=float(data.get("energy", 0) or 0),
|
||||
price=float(data.get("price", 0) or 0),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
|
||||
external_raw = data.get("externalNo", data.get("external_no"))
|
||||
external_no, ext_error = resolve_component_external_no(external_raw)
|
||||
if ext_error:
|
||||
return jsonify({"error": True, "message": ext_error}), 400
|
||||
component.external_no = external_no
|
||||
|
||||
db.session.add(component)
|
||||
db.session.flush()
|
||||
save_result: dict = {}
|
||||
if "nutrients" in data:
|
||||
if not can_access_lab():
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Модуль Lab недоступен для этого пользователя",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
save_result = save_component_nutrients(component.id, data.get("nutrients") or {})
|
||||
db.session.commit()
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Компонент успешно добавлен",
|
||||
"id": component.id,
|
||||
"affectedRecipeIds": save_result.get("affectedRecipeIds") or [],
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@bp.put("/<string:component_id>")
|
||||
@require_auth
|
||||
def update_component(component_id: str):
|
||||
"""Обновление параметров компонента, включая dry_matter."""
|
||||
component = db.session.get(Component, component_id)
|
||||
if component is None:
|
||||
return jsonify({"error": True, "message": "Компонент не найден"}), 404
|
||||
if getattr(component, "is_deleted", False):
|
||||
return jsonify({"error": True, "message": "Компонент удалён"}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
name = data.get("name")
|
||||
if name is not None:
|
||||
component.name = name.strip()
|
||||
|
||||
if "type" in data:
|
||||
comp_type = (data.get("type") or "").strip()
|
||||
if comp_type and not is_canonical_feed_type(comp_type):
|
||||
return jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Выберите тип корма: Грубые / Сочные / Концентрированные / Добавки",
|
||||
}
|
||||
), 400
|
||||
component.type = comp_type
|
||||
if "is_active" in data:
|
||||
component.is_active = bool(data.get("is_active"))
|
||||
|
||||
if "dry_matter" in data or "dryMatter" in data:
|
||||
dm_raw = data.get("dry_matter", data.get("dryMatter"))
|
||||
try:
|
||||
component.dry_matter = float(dm_raw or 0)
|
||||
except (TypeError, ValueError):
|
||||
return (
|
||||
jsonify({"error": True, "message": "Некорректное значение dry_matter"}),
|
||||
400,
|
||||
)
|
||||
|
||||
if "protein" in data:
|
||||
component.protein = float(data.get("protein") or 0)
|
||||
if "energy" in data:
|
||||
component.energy = float(data.get("energy") or 0)
|
||||
if "price" in data:
|
||||
component.price = float(data.get("price") or 0)
|
||||
save_result: dict = {}
|
||||
if "nutrients" in data:
|
||||
if not can_access_lab():
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Модуль Lab недоступен для этого пользователя",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
save_result = save_component_nutrients(component.id, data.get("nutrients") or {})
|
||||
elif "dry_matter" in data or "dryMatter" in data:
|
||||
save_result = save_component_nutrients(component.id, None)
|
||||
if "externalNo" in data or "external_no" in data:
|
||||
external_raw = data.get("externalNo", data.get("external_no"))
|
||||
external_no, ext_error = resolve_component_external_no(
|
||||
external_raw,
|
||||
exclude_component_id=component.id,
|
||||
current_external_no=component.external_no,
|
||||
)
|
||||
if ext_error:
|
||||
return jsonify({"error": True, "message": ext_error}), 400
|
||||
component.external_no = external_no
|
||||
elif component.external_no is None:
|
||||
external_no, ext_error = resolve_component_external_no(
|
||||
None,
|
||||
exclude_component_id=component.id,
|
||||
current_external_no=None,
|
||||
)
|
||||
if ext_error:
|
||||
return jsonify({"error": True, "message": ext_error}), 400
|
||||
component.external_no = external_no
|
||||
|
||||
component.updated_by = "system"
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Компонент обновлён",
|
||||
"affectedRecipeIds": save_result.get("affectedRecipeIds") or [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.delete("/<string:component_id>")
|
||||
@require_auth
|
||||
def delete_component(component_id: str):
|
||||
"""Мягкое удаление компонента (soft-delete + каскад через события)."""
|
||||
component = db.session.get(Component, component_id)
|
||||
if component is None:
|
||||
return jsonify({"error": True, "message": "Компонент не найден"}), 404
|
||||
if getattr(component, "is_deleted", False):
|
||||
return jsonify({"success": True, "message": "Компонент уже удалён"})
|
||||
|
||||
if hasattr(component, "soft_delete"):
|
||||
component.soft_delete(deleted_by_user="api")
|
||||
else:
|
||||
db.session.delete(component)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "message": "Компонент удалён"})
|
||||
@@ -0,0 +1,519 @@
|
||||
import io
|
||||
from datetime import date as date_cls
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file, session
|
||||
|
||||
from app.routes.auth_decorators import require_auth
|
||||
from app.services.daily_plan.builder import build_daily_plan
|
||||
from app.services.daily_plan.notify import (
|
||||
notify_component_replaced_in_plan,
|
||||
notify_ingredient_replaced,
|
||||
notify_ingredient_replacement_undone,
|
||||
notify_ingredient_skipped,
|
||||
notify_ingredient_unskipped,
|
||||
notify_ingredients_unskipped_all,
|
||||
notify_trip_skipped,
|
||||
notify_trip_unskipped,
|
||||
notify_unloading_group_skipped,
|
||||
notify_unloading_group_unskipped,
|
||||
notify_unloading_groups_unskipped_all,
|
||||
)
|
||||
from app.services.daily_plan.pdf import build_daily_plan_pdf
|
||||
from app.services.daily_plan.adjustments import (
|
||||
adjust_component_norm,
|
||||
list_component_norms_for_plan,
|
||||
undo_component_norm_adjustment,
|
||||
)
|
||||
from app.services.daily_plan.replacements import (
|
||||
find_component_alternatives,
|
||||
replace_component_in_plan,
|
||||
replace_ingredient,
|
||||
undo_ingredient_replacement,
|
||||
)
|
||||
from app.services.daily_plan.skips import (
|
||||
list_all_skips,
|
||||
skip_ingredient,
|
||||
skip_trip,
|
||||
skip_unloading_group,
|
||||
unskip_all_ingredient_parts,
|
||||
unskip_all_unloading_group_parts,
|
||||
unskip_ingredient,
|
||||
unskip_trip,
|
||||
unskip_unloading_group,
|
||||
)
|
||||
|
||||
bp = Blueprint("daily_plan", __name__, url_prefix="/api/daily-plan")
|
||||
|
||||
|
||||
def _dispenser_id_from_request() -> str:
|
||||
return (request.args.get("dispenser_id") or "").strip()
|
||||
|
||||
|
||||
def _skip_duration_kwargs(data: dict) -> dict:
|
||||
return {
|
||||
"duration": data.get("duration") or data.get("skipDuration"),
|
||||
"until_date": data.get("untilDate") or data.get("until_date") or data.get("validUntil"),
|
||||
}
|
||||
|
||||
|
||||
def _skip_row_response(row, *, id_key: str | None = None, extra: dict | None = None):
|
||||
payload = {
|
||||
"id": row.id,
|
||||
"recipeId": row.recipe_id,
|
||||
"date": row.plan_date.isoformat(),
|
||||
}
|
||||
end = row.valid_until or row.plan_date
|
||||
if end and end != row.plan_date:
|
||||
payload["validUntil"] = end.isoformat()
|
||||
if id_key and hasattr(row, id_key):
|
||||
payload[id_key] = getattr(row, id_key)
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
def get_daily_plan():
|
||||
dispenser_id = _dispenser_id_from_request()
|
||||
if not dispenser_id:
|
||||
return jsonify({"error": True, "message": "dispenser_id обязателен"}), 400
|
||||
try:
|
||||
plan = build_daily_plan(
|
||||
dispenser_id=dispenser_id,
|
||||
plan_date=request.args.get("date"),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(plan)
|
||||
|
||||
|
||||
@bp.get("/pdf")
|
||||
@require_auth
|
||||
def get_daily_plan_pdf():
|
||||
dispenser_id = _dispenser_id_from_request()
|
||||
if not dispenser_id:
|
||||
return jsonify({"error": True, "message": "dispenser_id обязателен"}), 400
|
||||
try:
|
||||
plan = build_daily_plan(
|
||||
dispenser_id=dispenser_id,
|
||||
plan_date=request.args.get("date"),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
pdf_bytes = build_daily_plan_pdf(plan)
|
||||
filename = f"plan-{plan.get('date', 'day')}.pdf"
|
||||
return send_file(
|
||||
io.BytesIO(pdf_bytes),
|
||||
mimetype="application/pdf",
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
)
|
||||
|
||||
|
||||
def _current_user() -> str:
|
||||
return str(session.get("user_login") or "system")
|
||||
|
||||
|
||||
def _parse_plan_date_route(value) -> date_cls:
|
||||
try:
|
||||
return date_cls.fromisoformat(str(value).strip()[:10])
|
||||
except (TypeError, ValueError):
|
||||
return date_cls.today()
|
||||
|
||||
|
||||
@bp.get("/skips")
|
||||
@require_auth
|
||||
def get_daily_plan_skips():
|
||||
return jsonify(list_all_skips(request.args.get("date")))
|
||||
|
||||
|
||||
@bp.post("/skips")
|
||||
@require_auth
|
||||
def post_daily_plan_skip():
|
||||
data = request.get_json() or {}
|
||||
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
|
||||
plan_date = data.get("date")
|
||||
if not recipe_id:
|
||||
return jsonify({"error": True, "message": "recipeId обязателен"}), 400
|
||||
try:
|
||||
row = skip_trip(
|
||||
recipe_id,
|
||||
plan_date,
|
||||
user=_current_user(),
|
||||
**_skip_duration_kwargs(data),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
user = _current_user()
|
||||
end = row.valid_until or row.plan_date
|
||||
notify_trip_skipped(
|
||||
recipe_id,
|
||||
row.plan_date,
|
||||
valid_until=end if end and end != row.plan_date else None,
|
||||
user=user,
|
||||
)
|
||||
return jsonify(_skip_row_response(row))
|
||||
|
||||
|
||||
@bp.delete("/skips")
|
||||
@require_auth
|
||||
def delete_daily_plan_skip():
|
||||
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
|
||||
plan_date = request.args.get("date")
|
||||
if not recipe_id:
|
||||
return jsonify({"error": True, "message": "recipe_id обязателен"}), 400
|
||||
if not unskip_trip(recipe_id, plan_date, user=_current_user()):
|
||||
return jsonify({"error": True, "message": "Исключение не найдено"}), 404
|
||||
notify_trip_unskipped(recipe_id, _parse_plan_date_route(plan_date), user=_current_user())
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/skips/ingredients")
|
||||
@require_auth
|
||||
def post_daily_plan_ingredient_skip():
|
||||
data = request.get_json() or {}
|
||||
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
|
||||
ingredient_id = (data.get("ingredientId") or data.get("ingredient_id") or "").strip()
|
||||
plan_date = data.get("date")
|
||||
if not recipe_id or not ingredient_id:
|
||||
return jsonify({"error": True, "message": "recipeId и ingredientId обязательны"}), 400
|
||||
try:
|
||||
row = skip_ingredient(
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
plan_date,
|
||||
user=_current_user(),
|
||||
**_skip_duration_kwargs(data),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(_skip_row_response(row, id_key="ingredient_id", extra={"ingredientId": row.ingredient_id}))
|
||||
|
||||
|
||||
@bp.delete("/skips/ingredients")
|
||||
@require_auth
|
||||
def delete_daily_plan_ingredient_skip():
|
||||
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
|
||||
ingredient_id = (
|
||||
request.args.get("ingredient_id") or request.args.get("ingredientId") or ""
|
||||
).strip()
|
||||
plan_date = request.args.get("date")
|
||||
all_parts = request.args.get("all") in ("1", "true", "yes")
|
||||
if not recipe_id:
|
||||
return jsonify({"error": True, "message": "recipe_id обязателен"}), 400
|
||||
if all_parts:
|
||||
count = unskip_all_ingredient_parts(recipe_id, plan_date, user=_current_user())
|
||||
if not count:
|
||||
return jsonify({"error": True, "message": "Исключения не найдены"}), 404
|
||||
notify_ingredients_unskipped_all(
|
||||
recipe_id,
|
||||
_parse_plan_date_route(plan_date),
|
||||
count=count,
|
||||
user=_current_user(),
|
||||
)
|
||||
return jsonify({"ok": True, "count": count})
|
||||
if not ingredient_id:
|
||||
return jsonify({"error": True, "message": "ingredient_id обязателен"}), 400
|
||||
if not unskip_ingredient(recipe_id, ingredient_id, plan_date, user=_current_user()):
|
||||
return jsonify({"error": True, "message": "Исключение не найдено"}), 404
|
||||
notify_ingredient_unskipped(
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
_parse_plan_date_route(plan_date),
|
||||
user=_current_user(),
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/skips/unloading-groups")
|
||||
@require_auth
|
||||
def post_daily_plan_unloading_group_skip():
|
||||
data = request.get_json() or {}
|
||||
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
|
||||
group_id = (
|
||||
data.get("unloadingGroupId")
|
||||
or data.get("unloading_group_id")
|
||||
or data.get("groupId")
|
||||
or ""
|
||||
).strip()
|
||||
plan_date = data.get("date")
|
||||
if not recipe_id or not group_id:
|
||||
return jsonify({"error": True, "message": "recipeId и unloadingGroupId обязательны"}), 400
|
||||
try:
|
||||
row = skip_unloading_group(
|
||||
recipe_id,
|
||||
group_id,
|
||||
plan_date,
|
||||
user=_current_user(),
|
||||
**_skip_duration_kwargs(data),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
user = _current_user()
|
||||
end = row.valid_until or row.plan_date
|
||||
notify_unloading_group_skipped(
|
||||
recipe_id,
|
||||
group_id,
|
||||
row.plan_date,
|
||||
valid_until=end if end and end != row.plan_date else None,
|
||||
user=user,
|
||||
)
|
||||
return jsonify(
|
||||
_skip_row_response(
|
||||
row,
|
||||
extra={"unloadingGroupId": row.unloading_group_id},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.delete("/skips/unloading-groups")
|
||||
@require_auth
|
||||
def delete_daily_plan_unloading_group_skip():
|
||||
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
|
||||
group_id = (
|
||||
request.args.get("unloading_group_id")
|
||||
or request.args.get("unloadingGroupId")
|
||||
or request.args.get("groupId")
|
||||
or ""
|
||||
).strip()
|
||||
plan_date = request.args.get("date")
|
||||
all_parts = request.args.get("all") in ("1", "true", "yes")
|
||||
if not recipe_id:
|
||||
return jsonify({"error": True, "message": "recipe_id обязателен"}), 400
|
||||
if all_parts:
|
||||
count = unskip_all_unloading_group_parts(recipe_id, plan_date, user=_current_user())
|
||||
if not count:
|
||||
return jsonify({"error": True, "message": "Исключения не найдены"}), 404
|
||||
notify_unloading_groups_unskipped_all(
|
||||
recipe_id,
|
||||
_parse_plan_date_route(plan_date),
|
||||
count=count,
|
||||
user=_current_user(),
|
||||
)
|
||||
return jsonify({"ok": True, "count": count})
|
||||
if not group_id:
|
||||
return jsonify({"error": True, "message": "unloading_group_id обязателен"}), 400
|
||||
if not unskip_unloading_group(recipe_id, group_id, plan_date, user=_current_user()):
|
||||
return jsonify({"error": True, "message": "Исключение не найдено"}), 404
|
||||
notify_unloading_group_unskipped(
|
||||
recipe_id,
|
||||
group_id,
|
||||
_parse_plan_date_route(plan_date),
|
||||
user=_current_user(),
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.get("/component-alternatives")
|
||||
@require_auth
|
||||
def get_component_alternatives():
|
||||
component_id = (request.args.get("component_id") or request.args.get("componentId") or "").strip()
|
||||
if not component_id:
|
||||
return jsonify({"error": True, "message": "component_id обязателен"}), 400
|
||||
try:
|
||||
payload = find_component_alternatives(
|
||||
component_id,
|
||||
query=request.args.get("q") or request.args.get("query") or "",
|
||||
limit=request.args.get("limit") or 20,
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.post("/replacements/ingredients")
|
||||
@require_auth
|
||||
def post_daily_plan_ingredient_replacement():
|
||||
data = request.get_json() or {}
|
||||
recipe_id = (data.get("recipeId") or data.get("recipe_id") or "").strip()
|
||||
ingredient_id = (data.get("ingredientId") or data.get("ingredient_id") or "").strip()
|
||||
replacement_id = (
|
||||
data.get("replacementComponentId")
|
||||
or data.get("replacement_component_id")
|
||||
or data.get("componentId")
|
||||
or ""
|
||||
).strip()
|
||||
plan_date = data.get("date")
|
||||
if not recipe_id or not ingredient_id or not replacement_id:
|
||||
return jsonify(
|
||||
{"error": True, "message": "recipeId, ingredientId и replacementComponentId обязательны"}
|
||||
), 400
|
||||
try:
|
||||
row = replace_ingredient(
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
replacement_id,
|
||||
plan_date,
|
||||
user=_current_user(),
|
||||
**_skip_duration_kwargs(data),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
user = _current_user()
|
||||
end = row.valid_until or row.plan_date
|
||||
notify_ingredient_replaced(
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
replacement_id,
|
||||
row.plan_date,
|
||||
valid_until=end if end and end != row.plan_date else None,
|
||||
user=user,
|
||||
)
|
||||
return jsonify(
|
||||
_skip_row_response(
|
||||
row,
|
||||
extra={
|
||||
"ingredientId": row.ingredient_id,
|
||||
"replacementComponentId": row.replacement_component_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/replacements/components")
|
||||
@require_auth
|
||||
def post_daily_plan_component_replacement():
|
||||
data = request.get_json() or {}
|
||||
component_id = (data.get("componentId") or data.get("component_id") or "").strip()
|
||||
replacement_id = (
|
||||
data.get("replacementComponentId")
|
||||
or data.get("replacement_component_id")
|
||||
or ""
|
||||
).strip()
|
||||
dispenser_id = (data.get("dispenserId") or data.get("dispenser_id") or "").strip()
|
||||
plan_date = data.get("date")
|
||||
if not component_id or not replacement_id:
|
||||
return jsonify(
|
||||
{"error": True, "message": "componentId и replacementComponentId обязательны"}
|
||||
), 400
|
||||
if not dispenser_id:
|
||||
return jsonify({"error": True, "message": "dispenserId обязателен"}), 400
|
||||
try:
|
||||
rows = replace_component_in_plan(
|
||||
component_id,
|
||||
replacement_id,
|
||||
plan_date,
|
||||
dispenser_id=dispenser_id,
|
||||
user=_current_user(),
|
||||
**_skip_duration_kwargs(data),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
if rows:
|
||||
end = rows[0].valid_until or rows[0].plan_date
|
||||
notify_component_replaced_in_plan(
|
||||
component_id,
|
||||
replacement_id,
|
||||
rows[0].plan_date,
|
||||
recipe_count=len({row.recipe_id for row in rows}),
|
||||
valid_until=end if end and end != rows[0].plan_date else None,
|
||||
user=_current_user(),
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"count": len(rows),
|
||||
"componentId": component_id,
|
||||
"replacementComponentId": replacement_id,
|
||||
"recipeIds": sorted({row.recipe_id for row in rows}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.delete("/replacements/ingredients")
|
||||
@require_auth
|
||||
def delete_daily_plan_ingredient_replacement():
|
||||
recipe_id = (request.args.get("recipe_id") or request.args.get("recipeId") or "").strip()
|
||||
ingredient_id = (
|
||||
request.args.get("ingredient_id") or request.args.get("ingredientId") or ""
|
||||
).strip()
|
||||
plan_date = request.args.get("date")
|
||||
if not recipe_id or not ingredient_id:
|
||||
return jsonify({"error": True, "message": "recipe_id и ingredient_id обязательны"}), 400
|
||||
if not undo_ingredient_replacement(recipe_id, ingredient_id, plan_date, user=_current_user()):
|
||||
return jsonify({"error": True, "message": "Замена не найдена"}), 404
|
||||
notify_ingredient_replacement_undone(
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
_parse_plan_date_route(plan_date),
|
||||
user=_current_user(),
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.get("/component-norms")
|
||||
@require_auth
|
||||
def get_component_norms():
|
||||
dispenser_id = _dispenser_id_from_request()
|
||||
if not dispenser_id:
|
||||
return jsonify({"error": True, "message": "dispenser_id обязателен"}), 400
|
||||
rows = list_component_norms_for_plan(
|
||||
request.args.get("date"),
|
||||
dispenser_id=dispenser_id,
|
||||
)
|
||||
return jsonify({"items": rows, "date": request.args.get("date")})
|
||||
|
||||
|
||||
@bp.post("/adjustments/components")
|
||||
@require_auth
|
||||
def post_component_norm_adjustment():
|
||||
data = request.get_json() or {}
|
||||
component_id = (data.get("componentId") or data.get("component_id") or "").strip()
|
||||
if not component_id:
|
||||
return jsonify({"error": True, "message": "componentId обязателен"}), 400
|
||||
wph = data.get("weightPerHead", data.get("weight_per_head"))
|
||||
dm_ph = data.get("dryMatterPerHead", data.get("dry_matter_per_head"))
|
||||
dm = data.get("dryMatter", data.get("dryMatterPct", data.get("dry_matter")))
|
||||
dm_locked = data.get("dryMatterLocked", data.get("dry_matter_locked", False))
|
||||
try:
|
||||
wph_val = float(wph) if wph is not None and wph != "" else None
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": True, "message": "weightPerHead должен быть числом"}), 400
|
||||
try:
|
||||
dm_val = float(dm_ph) if dm_ph is not None and dm_ph != "" else None
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": True, "message": "dryMatterPerHead должен быть числом"}), 400
|
||||
try:
|
||||
dm_pct_val = float(dm) if dm is not None and dm != "" else None
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": True, "message": "dryMatter должен быть числом"}), 400
|
||||
try:
|
||||
row = adjust_component_norm(
|
||||
component_id,
|
||||
data.get("date"),
|
||||
dry_matter=dm_pct_val,
|
||||
dry_matter_locked=bool(dm_locked),
|
||||
weight_per_head=wph_val,
|
||||
dry_matter_per_head=dm_val,
|
||||
user=_current_user(),
|
||||
**_skip_duration_kwargs(data),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"componentId": row.component_id,
|
||||
"dryMatter": row.dry_matter,
|
||||
"dryMatterLocked": bool(row.dry_matter_locked),
|
||||
"weightPerHead": row.weight_per_head,
|
||||
"dryMatterPerHead": row.dry_matter_per_head,
|
||||
"date": row.plan_date.isoformat(),
|
||||
"validUntil": (row.valid_until or row.plan_date).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.delete("/adjustments/components")
|
||||
@require_auth
|
||||
def delete_component_norm_adjustment():
|
||||
component_id = (request.args.get("component_id") or request.args.get("componentId") or "").strip()
|
||||
plan_date = request.args.get("date")
|
||||
if not component_id:
|
||||
return jsonify({"error": True, "message": "component_id обязателен"}), 400
|
||||
if not undo_component_norm_adjustment(component_id, plan_date, user=_current_user()):
|
||||
return jsonify({"error": True, "message": "Правка нормы не найдена"}), 404
|
||||
return jsonify({"ok": True})
|
||||
@@ -0,0 +1,750 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import exists, func, select, update
|
||||
|
||||
from app import db
|
||||
from app.models import FeedDispenser, FeedingPeriod, PeriodRecipe, Recipe
|
||||
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal
|
||||
from app.services.period_recipes_query import recipes_for_period_ordered
|
||||
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
|
||||
from app.services.daily_plan.replacements import get_replaced_recipe_ids
|
||||
from app.services.daily_plan.builder import recipe_total_weights_by_id
|
||||
from app.services.daily_plan.skips import (
|
||||
filter_recipes_for_list_view,
|
||||
get_recipe_ids_with_any_skip,
|
||||
get_skipped_ingredient_ids,
|
||||
get_skipped_unloading_group_ids,
|
||||
is_kiosk_recipe_list_request,
|
||||
is_zootech_recipe_list_view,
|
||||
)
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
bp = Blueprint("equipment", __name__, url_prefix="/api/feed_dispensers")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def equipment_ping():
|
||||
"""Временный health-check эндпоинт для модуля оборудования."""
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth_or_paired_terminal
|
||||
def list_feed_dispensers():
|
||||
"""Список кормораздатчиков с пагинацией."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 100))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return _error("Некорректные параметры пагинации", 400)
|
||||
|
||||
rows = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(FeedDispenser.is_deleted.is_(False))
|
||||
.order_by(FeedDispenser.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
**_serialize_dispenser(d),
|
||||
"periods": _periods_payload_for_dispenser(d.id),
|
||||
"hasSkipToday": _dispenser_has_skip_today(d),
|
||||
}
|
||||
for d in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/names")
|
||||
@require_auth_or_paired_terminal
|
||||
def feed_dispenser_names():
|
||||
"""Legacy-compatible endpoint for dispenser names list."""
|
||||
rows = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(FeedDispenser.is_deleted.is_(False))
|
||||
.order_by(FeedDispenser.name.asc())
|
||||
).scalars().all()
|
||||
names = [r.name for r in rows if r.name]
|
||||
# Preserve order while deduplicating
|
||||
seen = set()
|
||||
unique_names = []
|
||||
for n in names:
|
||||
if n in seen:
|
||||
continue
|
||||
seen.add(n)
|
||||
unique_names.append(n)
|
||||
return jsonify(unique_names)
|
||||
|
||||
|
||||
def _serialize_recipe_short(
|
||||
recipe: Recipe,
|
||||
*,
|
||||
total_weight: float | None = None,
|
||||
skipped_today: bool = False,
|
||||
skipped_ingredient_today: bool = False,
|
||||
skipped_group_today: bool = False,
|
||||
adjusted_ingredient_today: bool = False,
|
||||
replaced_ingredient_today: bool = False,
|
||||
):
|
||||
payload = {
|
||||
"id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"heads_count": recipe.heads_per_trip,
|
||||
"mixing_time": recipe.mixing_time,
|
||||
"trip_percent": recipe.trip_percent,
|
||||
}
|
||||
if total_weight is not None:
|
||||
payload["total_weight"] = total_weight
|
||||
if is_zootech_recipe_list_view():
|
||||
payload["skippedToday"] = skipped_today
|
||||
payload["skippedIngredientToday"] = skipped_ingredient_today
|
||||
payload["skippedGroupToday"] = skipped_group_today
|
||||
payload["adjustedIngredientToday"] = adjusted_ingredient_today
|
||||
payload["replacedIngredientToday"] = replaced_ingredient_today
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_dispenser(dispenser: FeedDispenser):
|
||||
return {
|
||||
"id": dispenser.id,
|
||||
"name": dispenser.name,
|
||||
"farm": dispenser.farm,
|
||||
"operator": dispenser.operator,
|
||||
"type": dispenser.type,
|
||||
"is_active": dispenser.is_active,
|
||||
"version": dispenser.version,
|
||||
}
|
||||
|
||||
|
||||
def _dispenser_has_skip_today(dispenser: FeedDispenser) -> bool:
|
||||
skip_ids = get_recipe_ids_with_any_skip()
|
||||
if not skip_ids:
|
||||
return False
|
||||
if dispenser.type == "mill":
|
||||
recipes = db.session.execute(
|
||||
select(Recipe.id).where(
|
||||
Recipe.is_deleted.is_(False),
|
||||
~exists(
|
||||
select(1).where(
|
||||
PeriodRecipe.recipe_id == Recipe.id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
),
|
||||
)
|
||||
).scalars().all()
|
||||
return any(rid in skip_ids for rid in recipes)
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod.id).where(
|
||||
FeedingPeriod.dispenser_id == dispenser.id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
).scalars().all()
|
||||
for period_id in periods:
|
||||
recipes = recipes_for_period_ordered(period_id)
|
||||
if any(r.id in skip_ids for r in recipes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _periods_payload_for_dispenser(dispenser_id: str) -> list[dict]:
|
||||
"""Активные периоды с рейсами (как в GET .../feed_dispensers/<id>). Для UI списка кормораздатчиков."""
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod)
|
||||
.where(
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
.order_by(FeedingPeriod.created_at.asc())
|
||||
).unique().scalars().all()
|
||||
payload = []
|
||||
zootech = is_zootech_recipe_list_view()
|
||||
skip_ings = get_skipped_ingredient_ids()
|
||||
skip_grps = get_skipped_unloading_group_ids()
|
||||
skip_ids = get_recipe_ids_with_any_skip()
|
||||
adj_ids = get_adjusted_recipe_ids()
|
||||
repl_ids = get_replaced_recipe_ids()
|
||||
for p in periods:
|
||||
recipes = recipes_for_period_ordered(p.id)
|
||||
visible, skipped_today = filter_recipes_for_list_view(recipes)
|
||||
period_has_skip = any(r.id in skip_ids for r in recipes)
|
||||
if zootech:
|
||||
recipe_rows = [
|
||||
_serialize_recipe_short(
|
||||
r,
|
||||
skipped_today=r.id in skipped_today,
|
||||
skipped_ingredient_today=bool(skip_ings.get(r.id)),
|
||||
skipped_group_today=bool(skip_grps.get(r.id)),
|
||||
adjusted_ingredient_today=r.id in adj_ids,
|
||||
replaced_ingredient_today=r.id in repl_ids,
|
||||
)
|
||||
for r in visible
|
||||
]
|
||||
else:
|
||||
recipe_rows = [_serialize_recipe_short(r) for r in visible]
|
||||
payload.append(
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"is_active": p.is_active,
|
||||
"is_deleted": False,
|
||||
"hasSkipToday": period_has_skip,
|
||||
"recipes": recipe_rows,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@bp.get("/<string:dispenser_id>/periods")
|
||||
@require_auth_or_paired_terminal
|
||||
def list_dispenser_periods(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
raw = _periods_payload_for_dispenser(dispenser_id)
|
||||
# Совместимость: в ответе /periods поле is_deleted не требовалось
|
||||
payload = [{k: v for k, v in item.items() if k != "is_deleted"} for item in raw]
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.get("/<string:dispenser_id>/recipes")
|
||||
@require_auth_or_paired_terminal
|
||||
def list_dispenser_recipes(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
recipes = []
|
||||
if dispenser.type == "mill":
|
||||
# Как legacy get_dispenser_recipes: только рецепты без привязки к периодам (кормоцех),
|
||||
# а не все строки recipe — иначе рейсы из периодов «висят» в списке кормоцеха.
|
||||
recipes = db.session.execute(
|
||||
select(Recipe)
|
||||
.where(
|
||||
Recipe.is_deleted.is_(False),
|
||||
~exists(
|
||||
select(1).where(
|
||||
PeriodRecipe.recipe_id == Recipe.id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(Recipe.updated_at.desc())
|
||||
).scalars().all()
|
||||
logger.info(
|
||||
"[RECIPES-DB] list_dispenser_recipes mill dispenser_id=%s orphan_recipes=%s",
|
||||
dispenser_id,
|
||||
len(recipes),
|
||||
)
|
||||
else:
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod)
|
||||
.where(
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
.order_by(FeedingPeriod.created_at.asc())
|
||||
).unique().scalars().all()
|
||||
seen = set()
|
||||
for p in periods:
|
||||
for r in recipes_for_period_ordered(p.id):
|
||||
if r.id in seen:
|
||||
continue
|
||||
seen.add(r.id)
|
||||
recipes.append(r)
|
||||
|
||||
visible, skipped_today = filter_recipes_for_list_view(recipes)
|
||||
skip_ings = get_skipped_ingredient_ids()
|
||||
skip_grps = get_skipped_unloading_group_ids()
|
||||
adj_ids = get_adjusted_recipe_ids()
|
||||
repl_ids = get_replaced_recipe_ids()
|
||||
plan_weights: dict[str, float] = {}
|
||||
if is_kiosk_recipe_list_request():
|
||||
plan_weights = recipe_total_weights_by_id(dispenser_id=dispenser_id)
|
||||
|
||||
def _row(recipe: Recipe) -> dict:
|
||||
return _serialize_recipe_short(
|
||||
recipe,
|
||||
total_weight=plan_weights.get(recipe.id),
|
||||
skipped_today=recipe.id in skipped_today,
|
||||
skipped_ingredient_today=bool(skip_ings.get(recipe.id)),
|
||||
skipped_group_today=bool(skip_grps.get(recipe.id)),
|
||||
adjusted_ingredient_today=recipe.id in adj_ids,
|
||||
replaced_ingredient_today=recipe.id in repl_ids,
|
||||
)
|
||||
|
||||
return jsonify([_row(r) for r in visible])
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
def create_feed_dispenser():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
farm = (data.get("farm") or "").strip()
|
||||
operator = (data.get("operator") or "").strip()
|
||||
if not name or not farm or not operator:
|
||||
return _error("Поля name, farm и operator обязательны", 400)
|
||||
|
||||
device_type = (data.get("type") or "dispenser").strip()
|
||||
if device_type not in {"dispenser", "mill"}:
|
||||
device_type = "dispenser"
|
||||
|
||||
dispenser = FeedDispenser(
|
||||
name=name,
|
||||
farm=farm,
|
||||
operator=operator,
|
||||
type=device_type,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(dispenser)
|
||||
db.session.commit()
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": "Кормораздатчик успешно создан",
|
||||
"id": dispenser.id,
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<string:dispenser_id>")
|
||||
@require_auth_or_paired_terminal
|
||||
def get_feed_dispenser(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
payload = _serialize_dispenser(dispenser)
|
||||
payload["periods"] = _periods_payload_for_dispenser(dispenser_id)
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.put("/<string:dispenser_id>")
|
||||
@require_auth
|
||||
def update_feed_dispenser(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
if "name" in data:
|
||||
dispenser.name = (data.get("name") or dispenser.name).strip()
|
||||
if "farm" in data:
|
||||
dispenser.farm = (data.get("farm") or dispenser.farm).strip()
|
||||
if "operator" in data:
|
||||
dispenser.operator = (data.get("operator") or dispenser.operator).strip()
|
||||
if "type" in data:
|
||||
candidate = (data.get("type") or "").strip()
|
||||
if candidate in {"dispenser", "mill"}:
|
||||
dispenser.type = candidate
|
||||
dispenser.updated_by = "system"
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Кормораздатчик успешно обновлен"})
|
||||
|
||||
|
||||
@bp.delete("/<string:dispenser_id>")
|
||||
@require_auth
|
||||
def delete_feed_dispenser(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
dispenser.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Кормораздатчик успешно удален"})
|
||||
|
||||
|
||||
@bp.post("/<string:dispenser_id>/periods")
|
||||
@require_auth
|
||||
def create_feeding_period(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return _error("Название периода обязательно", 400)
|
||||
|
||||
period = FeedingPeriod(
|
||||
name=name,
|
||||
dispenser_id=dispenser_id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(period)
|
||||
db.session.commit()
|
||||
return jsonify({"id": period.id}), 201
|
||||
|
||||
|
||||
@bp.put("/<string:dispenser_id>/periods/<string:period_id>")
|
||||
@require_auth
|
||||
def update_feeding_period(dispenser_id: str, period_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
if "name" in data:
|
||||
new_name = (data.get("name") or "").strip()
|
||||
if not new_name:
|
||||
return _error("Название периода не может быть пустым", 400)
|
||||
period.name = new_name
|
||||
period.updated_by = "system"
|
||||
db.session.commit()
|
||||
return jsonify({"id": period.id})
|
||||
|
||||
|
||||
@bp.delete("/<string:dispenser_id>/periods/<string:period_id>")
|
||||
@require_auth
|
||||
def delete_feeding_period(dispenser_id: str, period_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
period.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Период кормления успешно удален"})
|
||||
|
||||
|
||||
@bp.post("/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>")
|
||||
@require_auth
|
||||
def add_recipe_to_period(dispenser_id: str, period_id: str, recipe_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not recipe:
|
||||
return _error("Рецепт не найден", 404)
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] add_recipe_to_period dispenser_id=%s period_id=%s recipe_id=%s path=%s remote=%s",
|
||||
dispenser_id,
|
||||
period_id,
|
||||
recipe_id,
|
||||
request.path,
|
||||
request.remote_addr,
|
||||
)
|
||||
|
||||
existing = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
logger.info(
|
||||
"[RECIPES-DB] add_recipe_to_period already_linked period_id=%s recipe_id=%s",
|
||||
period_id,
|
||||
recipe_id,
|
||||
)
|
||||
return jsonify({"message": "Рецепт добавлен в период"})
|
||||
|
||||
max_order = db.session.scalar(
|
||||
select(func.max(PeriodRecipe.order)).where(
|
||||
PeriodRecipe.period_id == period_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
) or 0
|
||||
link = PeriodRecipe(
|
||||
period_id=period_id,
|
||||
recipe_id=recipe_id,
|
||||
order=max_order + 1,
|
||||
created_at=datetime.now(),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(link)
|
||||
period.updated_by = "system"
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] add_recipe_to_period committed period_id=%s recipe_id=%s order=%s",
|
||||
period_id,
|
||||
recipe_id,
|
||||
link.order,
|
||||
)
|
||||
return jsonify({"message": "Рецепт добавлен в период"})
|
||||
|
||||
|
||||
@bp.post("/<string:to_dispenser_id>/periods/<string:to_period_id>/recipes/<string:recipe_id>/transfer")
|
||||
@require_auth
|
||||
def transfer_recipe_between_periods(
|
||||
to_dispenser_id: str, to_period_id: str, recipe_id: str
|
||||
):
|
||||
"""Атомарно снять рейс с исходного периода и вставить в целевой на позицию to_index (0-based)."""
|
||||
data = request.get_json() or {}
|
||||
from_dispenser_id = data.get("from_dispenser_id")
|
||||
from_period_id = data.get("from_period_id")
|
||||
if not from_dispenser_id or not from_period_id:
|
||||
return _error("Укажите from_dispenser_id и from_period_id", 400)
|
||||
try:
|
||||
to_index = int(data.get("to_index"))
|
||||
except (TypeError, ValueError):
|
||||
return _error("to_index должен быть числом", 400)
|
||||
|
||||
if from_period_id == to_period_id and from_dispenser_id == to_dispenser_id:
|
||||
return _error("Для смены порядка внутри периода используйте перестановку в списке", 400)
|
||||
|
||||
to_dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == to_dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not to_dispenser:
|
||||
return _error("Целевое оборудование не найдено", 404)
|
||||
if getattr(to_dispenser, "type", None) == "mill":
|
||||
return _error("Перенос в кормоцех через периоды не поддерживается", 400)
|
||||
|
||||
to_period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == to_period_id,
|
||||
FeedingPeriod.dispenser_id == to_dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not to_period:
|
||||
return _error("Целевой период не найден", 404)
|
||||
|
||||
from_period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == from_period_id,
|
||||
FeedingPeriod.dispenser_id == from_dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not from_period:
|
||||
return _error("Исходный период не найден", 404)
|
||||
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not recipe:
|
||||
return _error("Рецепт не найден", 404)
|
||||
|
||||
source_link = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == from_period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not source_link:
|
||||
return _error("Рейс не привязан к исходному периоду", 404)
|
||||
|
||||
existing_target = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing_target:
|
||||
return _error("Этот рейс уже привязан к целевому периоду — перенос не выполнен.", 409)
|
||||
|
||||
source_link.soft_delete(deleted_by_user="system")
|
||||
from_period.updated_by = "system"
|
||||
db.session.flush()
|
||||
|
||||
others = (
|
||||
db.session.execute(
|
||||
select(PeriodRecipe)
|
||||
.where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PeriodRecipe.order.asc(), PeriodRecipe.created_at.asc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
n = len(others)
|
||||
if to_index < 0:
|
||||
to_index = 0
|
||||
elif to_index > n:
|
||||
to_index = n
|
||||
|
||||
ordered_ids = [pr.recipe_id for pr in others[:to_index]] + [recipe_id] + [
|
||||
pr.recipe_id for pr in others[to_index:]
|
||||
]
|
||||
|
||||
ghost = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if ghost is not None:
|
||||
if not ghost.is_deleted:
|
||||
logger.error(
|
||||
"transfer_recipe_between_periods: unexpected active ghost "
|
||||
"period_id=%s recipe_id=%s",
|
||||
to_period_id,
|
||||
recipe_id,
|
||||
)
|
||||
db.session.rollback()
|
||||
return _error(
|
||||
"Этот рейс уже привязан к целевому периоду — перенос не выполнен.",
|
||||
409,
|
||||
)
|
||||
ghost.is_deleted = False
|
||||
ghost.deleted_at = None
|
||||
ghost.deleted_by = None
|
||||
ghost.updated_by = "system"
|
||||
else:
|
||||
db.session.add(
|
||||
PeriodRecipe(
|
||||
period_id=to_period_id,
|
||||
recipe_id=recipe_id,
|
||||
order=0,
|
||||
created_at=datetime.now(),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
now = datetime.now()
|
||||
with db.session.no_autoflush:
|
||||
for idx, rid in enumerate(ordered_ids, start=1):
|
||||
db.session.execute(
|
||||
update(PeriodRecipe)
|
||||
.where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.recipe_id == rid,
|
||||
)
|
||||
.values(order=idx, updated_by="system", updated_at=now)
|
||||
)
|
||||
|
||||
to_period.updated_by = "system"
|
||||
from_period.updated_by = "system"
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes",
|
||||
f"{from_period_id}:{recipe_id}",
|
||||
"delete",
|
||||
priority=1,
|
||||
)
|
||||
for rid in ordered_ids:
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes",
|
||||
f"{to_period_id}:{rid}",
|
||||
"update",
|
||||
priority=4,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] transfer_recipe_between_periods committed recipe_id=%s from=%s/%s to=%s/%s index=%s",
|
||||
recipe_id,
|
||||
from_dispenser_id,
|
||||
from_period_id,
|
||||
to_dispenser_id,
|
||||
to_period_id,
|
||||
to_index,
|
||||
)
|
||||
return jsonify({"success": True, "message": "Рейс перенесён"})
|
||||
|
||||
|
||||
@bp.delete("/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>")
|
||||
@require_auth
|
||||
def remove_recipe_from_period(dispenser_id: str, period_id: str, recipe_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
|
||||
link = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if link:
|
||||
logger.info(
|
||||
"[RECIPES-DB] remove_recipe_from_period dispenser_id=%s period_id=%s recipe_id=%s path=%s",
|
||||
dispenser_id,
|
||||
period_id,
|
||||
recipe_id,
|
||||
request.path,
|
||||
)
|
||||
link.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
|
||||
period.updated_by = "system"
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes",
|
||||
f"{period_id}:{recipe_id}",
|
||||
"delete",
|
||||
priority=1,
|
||||
)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] remove_recipe_from_period committed period_id=%s recipe_id=%s",
|
||||
period_id,
|
||||
recipe_id,
|
||||
)
|
||||
return jsonify({"message": "Рецепт удален из периода"})
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""API учётных документов (СП-20, остатки, журнал)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import calendar
|
||||
import re
|
||||
from datetime import date
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Blueprint, Response, current_app, jsonify, request
|
||||
|
||||
from app.routes.auth_decorators import require_auth
|
||||
from app.services.feed_accounting.consumption_pdf import build_consumption_pdf
|
||||
from app.services.feed_accounting.consumption_report_builder import (
|
||||
consumption_export_filename,
|
||||
consumption_export_filename_ascii,
|
||||
)
|
||||
from app.services.feed_accounting.consumption_xlsx import build_consumption_xlsx
|
||||
from app.services.feed_accounting.pdf_feed_accounting import build_journal_pdf, build_sp20_pdf
|
||||
from app.services.feed_accounting.sp20_report_builder import parse_month, sp20_report_summary
|
||||
from app.services.feed_accounting.xlsx_feed_accounting import (
|
||||
build_documents_zip,
|
||||
build_journal_xlsx,
|
||||
build_sp20_xlsx,
|
||||
build_stock_balances_xlsx,
|
||||
)
|
||||
from app.services.org_settings import (
|
||||
SIGNATURE_ROLES,
|
||||
delete_signature,
|
||||
get_org_settings,
|
||||
list_signature_status,
|
||||
save_signature_png,
|
||||
signature_path,
|
||||
validate_org_settings_payload,
|
||||
write_org_settings,
|
||||
list_farms_from_db,
|
||||
)
|
||||
|
||||
bp = Blueprint("feed_accounting", __name__, url_prefix="/api/feed-accounting")
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def _bad(message: str, code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), code
|
||||
|
||||
|
||||
def _parse_dates():
|
||||
date_from = (request.args.get("date_from") or "").strip()
|
||||
date_to = (request.args.get("date_to") or "").strip()
|
||||
if not date_from or not date_to:
|
||||
today = date.today()
|
||||
start = date(today.year, today.month, 1)
|
||||
last = calendar.monthrange(today.year, today.month)[1]
|
||||
end = date(today.year, today.month, last)
|
||||
return start.isoformat(), end.isoformat(), None
|
||||
if not _DATE_RE.match(date_from) or not _DATE_RE.match(date_to):
|
||||
return None, None, _bad("date_from/date_to: формат YYYY-MM-DD")
|
||||
return date_from, date_to, None
|
||||
|
||||
|
||||
def _parse_month_arg():
|
||||
month = (request.args.get("month") or "").strip()
|
||||
if not month:
|
||||
today = date.today()
|
||||
month = f"{today.year}-{today.month:02d}"
|
||||
try:
|
||||
year, mon = parse_month(month)
|
||||
return year, mon, month, None
|
||||
except ValueError as e:
|
||||
return None, None, None, _bad(str(e))
|
||||
|
||||
|
||||
def _content_disposition(filename: str, *, ascii_filename: str | None = None) -> str:
|
||||
"""RFC 5987: кириллица в filename*, latin-1 fallback в filename."""
|
||||
try:
|
||||
filename.encode("ascii")
|
||||
return f'attachment; filename="{filename}"'
|
||||
except UnicodeEncodeError:
|
||||
fallback = ascii_filename or filename
|
||||
quoted = quote(filename, safe="!#$&+-.^_`|~")
|
||||
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quoted}"
|
||||
|
||||
|
||||
def _xlsx_response(data: bytes, filename: str, *, ascii_filename: str | None = None) -> Response:
|
||||
return Response(
|
||||
data,
|
||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": _content_disposition(filename, ascii_filename=ascii_filename)},
|
||||
)
|
||||
|
||||
|
||||
def _pdf_response(data: bytes, filename: str, *, ascii_filename: str | None = None) -> Response:
|
||||
return Response(
|
||||
data,
|
||||
mimetype="application/pdf",
|
||||
headers={"Content-Disposition": _content_disposition(filename, ascii_filename=ascii_filename)},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/org-settings")
|
||||
@require_auth
|
||||
def api_get_org_settings():
|
||||
settings = get_org_settings(current_app)
|
||||
return jsonify(
|
||||
{
|
||||
**settings,
|
||||
"available_farms": list_farms_from_db(),
|
||||
"signatures": list_signature_status(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.put("/org-settings")
|
||||
@require_auth
|
||||
def api_put_org_settings():
|
||||
data = request.get_json(silent=True) or {}
|
||||
ok, err, partial = validate_org_settings_payload(data)
|
||||
if not ok:
|
||||
return _bad(err)
|
||||
write_org_settings(partial)
|
||||
return jsonify({"success": True, "settings": get_org_settings(current_app)})
|
||||
|
||||
|
||||
@bp.get("/signatures")
|
||||
@require_auth
|
||||
def api_list_signatures():
|
||||
return jsonify({"roles": list_signature_status()})
|
||||
|
||||
|
||||
@bp.get("/signatures/<role>.png")
|
||||
@require_auth
|
||||
def api_get_signature_png(role: str):
|
||||
if role not in SIGNATURE_ROLES:
|
||||
return _bad("Неизвестная роль подписи")
|
||||
path = signature_path(role)
|
||||
if not path.is_file():
|
||||
return _bad("Подпись не найдена", 404)
|
||||
return Response(path.read_bytes(), mimetype="image/png")
|
||||
|
||||
|
||||
@bp.post("/signatures/<role>")
|
||||
@require_auth
|
||||
def api_save_signature(role: str):
|
||||
if role not in SIGNATURE_ROLES:
|
||||
return _bad("Неизвестная роль подписи")
|
||||
png_bytes = None
|
||||
if request.content_type and "application/json" in request.content_type:
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw = body.get("png_base64") or body.get("data") or ""
|
||||
if isinstance(raw, str) and "," in raw:
|
||||
raw = raw.split(",", 1)[1]
|
||||
try:
|
||||
png_bytes = base64.b64decode(raw)
|
||||
except Exception:
|
||||
return _bad("Некорректный base64")
|
||||
elif "file" in request.files:
|
||||
f = request.files["file"]
|
||||
png_bytes = f.read()
|
||||
else:
|
||||
png_bytes = request.get_data()
|
||||
try:
|
||||
save_signature_png(role, png_bytes or b"")
|
||||
except ValueError as e:
|
||||
return _bad(str(e))
|
||||
return jsonify({"success": True, "roles": list_signature_status()})
|
||||
|
||||
|
||||
@bp.delete("/signatures/<role>")
|
||||
@require_auth
|
||||
def api_delete_signature(role: str):
|
||||
if role not in SIGNATURE_ROLES:
|
||||
return _bad("Неизвестная роль подписи")
|
||||
try:
|
||||
delete_signature(role)
|
||||
except ValueError as e:
|
||||
return _bad(str(e))
|
||||
return jsonify({"success": True, "roles": list_signature_status()})
|
||||
|
||||
|
||||
@bp.get("/stock-balances.xlsx")
|
||||
@require_auth
|
||||
def api_stock_balances_xlsx():
|
||||
date_from, date_to, err = _parse_dates()
|
||||
if err:
|
||||
return err
|
||||
data = build_stock_balances_xlsx(current_app, date_from, date_to)
|
||||
return _xlsx_response(data, f"WESP_ostatki_{date_from}_{date_to}.xlsx")
|
||||
|
||||
|
||||
@bp.get("/consumption.xlsx")
|
||||
@require_auth
|
||||
def api_consumption_xlsx():
|
||||
date_from, date_to, err = _parse_dates()
|
||||
if err:
|
||||
return err
|
||||
data = build_consumption_xlsx(current_app, date_from, date_to)
|
||||
return _xlsx_response(
|
||||
data,
|
||||
consumption_export_filename(date_from, date_to, "xlsx"),
|
||||
ascii_filename=consumption_export_filename_ascii(date_from, date_to, "xlsx"),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/consumption.pdf")
|
||||
@require_auth
|
||||
def api_consumption_pdf():
|
||||
date_from, date_to, err = _parse_dates()
|
||||
if err:
|
||||
return err
|
||||
data = build_consumption_pdf(current_app, date_from, date_to)
|
||||
return _pdf_response(
|
||||
data,
|
||||
consumption_export_filename(date_from, date_to, "pdf"),
|
||||
ascii_filename=consumption_export_filename_ascii(date_from, date_to, "pdf"),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/sp20-preview")
|
||||
@require_auth
|
||||
def api_sp20_preview():
|
||||
year, month, month_s, err = _parse_month_arg()
|
||||
if err:
|
||||
return err
|
||||
return jsonify(sp20_report_summary(current_app, year, month))
|
||||
|
||||
|
||||
@bp.get("/sp20.xlsx")
|
||||
@require_auth
|
||||
def api_sp20_xlsx():
|
||||
year, month, month_s, err = _parse_month_arg()
|
||||
if err:
|
||||
return err
|
||||
include_journal = request.args.get("include_journal", "1") != "0"
|
||||
data = build_sp20_xlsx(current_app, year, month, include_journal=include_journal)
|
||||
return _xlsx_response(data, f"WESP_SP20_{month_s}.xlsx")
|
||||
|
||||
|
||||
@bp.get("/sp20.pdf")
|
||||
@require_auth
|
||||
def api_sp20_pdf():
|
||||
year, month, month_s, err = _parse_month_arg()
|
||||
if err:
|
||||
return err
|
||||
data = build_sp20_pdf(current_app, year, month)
|
||||
return _pdf_response(data, f"WESP_SP20_{month_s}.pdf")
|
||||
|
||||
|
||||
@bp.get("/journal.xlsx")
|
||||
@require_auth
|
||||
def api_journal_xlsx():
|
||||
year, month, month_s, err = _parse_month_arg()
|
||||
if err:
|
||||
return err
|
||||
data = build_journal_xlsx(current_app, year, month)
|
||||
return _xlsx_response(data, f"WESP_zhurnal_{month_s}.xlsx")
|
||||
|
||||
|
||||
@bp.get("/journal.pdf")
|
||||
@require_auth
|
||||
def api_journal_pdf():
|
||||
year, month, month_s, err = _parse_month_arg()
|
||||
if err:
|
||||
return err
|
||||
data = build_journal_pdf(current_app, year, month)
|
||||
return _pdf_response(data, f"WESP_zhurnal_{month_s}.pdf")
|
||||
|
||||
|
||||
@bp.get("/documents.zip")
|
||||
@require_auth
|
||||
def api_documents_zip():
|
||||
date_from, date_to, err = _parse_dates()
|
||||
if err:
|
||||
return err
|
||||
year, month, month_s, err2 = _parse_month_arg()
|
||||
if err2:
|
||||
return err2
|
||||
data = build_documents_zip(current_app, year, month, date_from, date_to)
|
||||
return Response(
|
||||
data,
|
||||
mimetype="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="WESP_documents_{month_s}.zip"'},
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from app.routes.auth_decorators import require_auth
|
||||
from app.services.feed_quality.evaluator import reevaluate_feed_alerts_for_period
|
||||
from app.services.feed_quality.query import feed_alerts_summary, list_feed_alerts
|
||||
from app.services.feed_quality.settings_store import (
|
||||
normalize_settings,
|
||||
save_feed_quality_settings,
|
||||
settings_for_api,
|
||||
)
|
||||
from app.timeutil import utc_now_naive
|
||||
|
||||
bp = Blueprint("feed_quality", __name__, url_prefix="/api/feed-quality")
|
||||
|
||||
|
||||
@bp.get("/settings")
|
||||
@require_auth
|
||||
def get_settings():
|
||||
return jsonify(settings_for_api())
|
||||
|
||||
|
||||
@bp.put("/settings")
|
||||
@require_auth
|
||||
def put_settings():
|
||||
body = request.get_json(silent=True) or {}
|
||||
partial = body.get("settings") if isinstance(body.get("settings"), dict) else body
|
||||
try:
|
||||
saved = save_feed_quality_settings(normalize_settings(partial))
|
||||
except (TypeError, ValueError) as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
|
||||
today = utc_now_naive().date()
|
||||
week_ago = today - timedelta(days=7)
|
||||
reevaluated = reevaluate_feed_alerts_for_period(
|
||||
date_from=week_ago.isoformat(),
|
||||
date_to=today.isoformat(),
|
||||
limit=500,
|
||||
)
|
||||
return jsonify({"saved": True, "settings": saved, "reevaluatedReports": reevaluated})
|
||||
|
||||
|
||||
@bp.get("/alerts/summary")
|
||||
@require_auth
|
||||
def alerts_summary():
|
||||
return jsonify(
|
||||
feed_alerts_summary(
|
||||
date_from=request.args.get("date_from"),
|
||||
date_to=request.args.get("date_to"),
|
||||
dispenser_id=request.args.get("dispenser_id"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/alerts")
|
||||
@require_auth
|
||||
def alerts_list():
|
||||
try:
|
||||
limit = int(request.args.get("limit", 200))
|
||||
except (TypeError, ValueError):
|
||||
limit = 200
|
||||
return jsonify(
|
||||
list_feed_alerts(
|
||||
date_from=request.args.get("date_from"),
|
||||
date_to=request.args.get("date_to"),
|
||||
severity=request.args.get("severity"),
|
||||
event_type=request.args.get("event_type"),
|
||||
dispenser_id=request.args.get("dispenser_id"),
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,514 @@
|
||||
import logging
|
||||
import secrets
|
||||
from html import escape
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, Response, current_app, jsonify, redirect, request, url_for
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.kiosk_qr import pair_url_to_qr_data_uri
|
||||
from app.models import KioskDevice, KioskPairToken
|
||||
from app.routes.auth_decorators import can_issue_kiosk_access_link
|
||||
|
||||
bp = Blueprint("kiosk", __name__, url_prefix="/api/kiosk")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _token_log_fragment(token: str) -> str:
|
||||
"""Фрагмент токена для логов (не логируем полностью)."""
|
||||
if not token:
|
||||
return "(пусто)"
|
||||
return (token[:12] + "…") if len(token) > 12 else token
|
||||
|
||||
|
||||
def _device_cookie_id() -> str:
|
||||
return (request.cookies.get("wesp_kiosk_device_id") or "").strip()
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
def _confirm_html_page(inner_html: str, status: int = 200) -> Response:
|
||||
body = (
|
||||
"<!DOCTYPE html><html lang=\"ru\"><head><meta charset=\"utf-8\">"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"></head>"
|
||||
f"<body style=\"font-family:system-ui,sans-serif;padding:24px;max-width:520px;margin:0 auto;\">{inner_html}</body></html>"
|
||||
)
|
||||
return Response(body, status=status, mimetype="text/html; charset=utf-8")
|
||||
|
||||
|
||||
def _confirm_pair_form_html(token: str) -> Response:
|
||||
safe = escape(token, quote=True)
|
||||
action = escape(url_for("kiosk.confirm_pair"), quote=True)
|
||||
inner = (
|
||||
"<h1 style=\"font-size:1.25rem;\">Подтвердите привязку терминала</h1>"
|
||||
"<p style=\"color:#444;line-height:1.45;\">Нажмите кнопку, чтобы завершить привязку. "
|
||||
"Страница без кнопки не расходует токен — так ссылка не «сгорает» из‑за предпросмотра в мессенджере или лишнего запроса.</p>"
|
||||
f'<form method="post" action="{action}">'
|
||||
f'<input type="hidden" name="token" value="{safe}">'
|
||||
'<button type="submit" style="margin-top:12px;padding:14px 22px;font-size:1.05rem;cursor:pointer;'
|
||||
'border-radius:8px;border:1px solid #333;background:#1a1a1a;color:#fff;">Подтвердить привязку</button>'
|
||||
"</form>"
|
||||
)
|
||||
return _confirm_html_page(inner, 200)
|
||||
|
||||
|
||||
def _confirm_pair_error_html(message: str, status: int) -> Response:
|
||||
inner = f"<p>{escape(message)}</p>"
|
||||
return _confirm_html_page(inner, status)
|
||||
|
||||
|
||||
def _scales_public_url() -> str:
|
||||
"""Тот же origin, что в QR (`_resolve_public_base_url`), путь /scales."""
|
||||
return _resolve_public_base_url().rstrip("/") + "/scales"
|
||||
|
||||
|
||||
def _confirm_pair_success_response():
|
||||
"""После привязки — редирект на /scales по LAN/public base; для JSON — поле redirect."""
|
||||
target = _scales_public_url()
|
||||
if request.is_json or (request.headers.get("Accept") or "").find("application/json") >= 0:
|
||||
return jsonify({"success": True, "paired": True, "redirect": target}), 200
|
||||
return redirect(target, code=302)
|
||||
|
||||
|
||||
def _confirm_pair_error_response(message: str, status_code: int):
|
||||
"""JSON для API, HTML для отправки формы из браузера."""
|
||||
if request.is_json or (request.headers.get("Accept") or "").find("application/json") >= 0:
|
||||
return _error(message, status_code)
|
||||
return _confirm_pair_error_html(message, status_code)
|
||||
|
||||
|
||||
def _is_local_host(host: str) -> bool:
|
||||
return host.lower() in {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
from app.services.lan_network import detect_lan_ip
|
||||
|
||||
|
||||
def _resolve_public_base_url() -> str:
|
||||
override = str(current_app.config.get("KIOSK_PUBLIC_BASE_URL", "") or "").strip().rstrip("/")
|
||||
if override:
|
||||
return override
|
||||
|
||||
host = (request.host or "").strip()
|
||||
host_name = host.split(":")[0] if host else ""
|
||||
if not _is_local_host(host_name):
|
||||
return request.host_url.rstrip("/")
|
||||
|
||||
lan_ip = detect_lan_ip()
|
||||
if not lan_ip:
|
||||
return request.host_url.rstrip("/")
|
||||
|
||||
port = str(request.environ.get("SERVER_PORT") or "").strip()
|
||||
# На ферме Flask обычно слушает plain HTTP (80). HTTPS в Start URL без reverse-proxy
|
||||
# приводит к «Bad request version» в логах (TLS-handshake на HTTP-порт).
|
||||
scheme = "https" if port == "443" else "http"
|
||||
if port and port not in {"80", "443"}:
|
||||
return f"{scheme}://{lan_ip}:{port}"
|
||||
return f"{scheme}://{lan_ip}"
|
||||
|
||||
|
||||
def _ensure_access_slug(device: KioskDevice, *, refresh: bool = False) -> str:
|
||||
if device.access_slug and not refresh:
|
||||
return device.access_slug
|
||||
device.access_slug = secrets.token_urlsafe(32)
|
||||
db.session.commit()
|
||||
return device.access_slug
|
||||
|
||||
|
||||
def _start_url_for_slug(slug: str) -> str:
|
||||
return f"{_resolve_public_base_url().rstrip('/')}/kiosk/start/{slug}"
|
||||
|
||||
|
||||
def _access_link_refresh_requested() -> bool:
|
||||
if request.args.get("refresh") == "1":
|
||||
return True
|
||||
payload = request.get_json(silent=True) or {}
|
||||
return bool(payload.get("refresh"))
|
||||
|
||||
|
||||
def _activate_by_token(token: str):
|
||||
row = db.session.execute(select(KioskPairToken).where(KioskPairToken.token == token)).scalar_one_or_none()
|
||||
if not row:
|
||||
logger.warning(
|
||||
"[KIOSK] токен не найден token=%s remote=%s",
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
)
|
||||
return None, _error("Токен не найден", 404)
|
||||
if row.used_at is not None:
|
||||
logger.warning(
|
||||
"[KIOSK] токен уже использован token=%s device_id_в_токене=%s remote=%s",
|
||||
_token_log_fragment(token),
|
||||
row.device_id,
|
||||
request.remote_addr,
|
||||
)
|
||||
return None, _error("Токен уже использован", 409)
|
||||
if row.expires_at < datetime.utcnow():
|
||||
logger.warning(
|
||||
"[KIOSK] токен истёк token=%s remote=%s",
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
)
|
||||
return None, _error("Срок действия токена истёк", 410)
|
||||
return row, None
|
||||
|
||||
|
||||
def _pair_current_device_by_token(token: str):
|
||||
target_device_id = _device_cookie_id()
|
||||
if not target_device_id:
|
||||
logger.warning(
|
||||
"[KIOSK] claim/scan: нет cookie wesp_kiosk_device_id token=%s remote=%s host=%s",
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
request.host,
|
||||
)
|
||||
return None, _error("Не найден cookie терминала. Откройте kiosk-страницу заново.", 400)
|
||||
target_device = db.session.get(KioskDevice, target_device_id)
|
||||
if not target_device:
|
||||
logger.warning(
|
||||
"[KIOSK] claim/scan: нет строки KioskDevice для cookie device_id=%s",
|
||||
target_device_id,
|
||||
)
|
||||
return None, _error("Терминал не зарегистрирован на сервере.", 404)
|
||||
|
||||
row, error = _activate_by_token(token)
|
||||
if error:
|
||||
return None, error
|
||||
|
||||
row.used_at = datetime.utcnow()
|
||||
target_device.status = "active"
|
||||
target_device.last_seen_at = datetime.utcnow()
|
||||
target_device.last_ip = request.remote_addr
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[KIOSK] claim/scan OK: активирован device_id=%s token=%s remote=%s",
|
||||
target_device.id,
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
)
|
||||
return target_device, None
|
||||
|
||||
|
||||
@bp.get("/status")
|
||||
def kiosk_status():
|
||||
enforce_paired_only = bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True))
|
||||
device_id = _device_cookie_id()
|
||||
if not device_id:
|
||||
logger.debug("[KIOSK] /status без cookie remote=%s host=%s", request.remote_addr, request.host)
|
||||
return jsonify(
|
||||
{
|
||||
"paired": False,
|
||||
"device_id": None,
|
||||
"status": "unregistered",
|
||||
"enforce_paired_only": enforce_paired_only,
|
||||
}
|
||||
)
|
||||
device = db.session.get(KioskDevice, device_id)
|
||||
if not device:
|
||||
logger.debug(
|
||||
"[KIOSK] /status неизвестный device_id=%s remote=%s",
|
||||
device_id,
|
||||
request.remote_addr,
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"paired": False,
|
||||
"device_id": device_id,
|
||||
"status": "unregistered",
|
||||
"enforce_paired_only": enforce_paired_only,
|
||||
}
|
||||
)
|
||||
paired = device.status == "active"
|
||||
if paired:
|
||||
device.last_seen_at = datetime.utcnow()
|
||||
device.last_ip = request.remote_addr
|
||||
db.session.commit()
|
||||
logger.debug(
|
||||
"[KIOSK] /status device_id=%s paired=%s status=%s remote=%s",
|
||||
device_id,
|
||||
paired,
|
||||
device.status,
|
||||
request.remote_addr,
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"paired": paired,
|
||||
"device_id": device.id,
|
||||
"status": device.status,
|
||||
"display_name": device.display_name,
|
||||
"enforce_paired_only": enforce_paired_only,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/setup-available")
|
||||
def kiosk_setup_available():
|
||||
"""Можно ли показывать UI выдачи Start URL (localhost или admin-сессия)."""
|
||||
return jsonify({"can_issue_access_link": can_issue_kiosk_access_link()})
|
||||
|
||||
|
||||
@bp.route("/access-link", methods=["GET", "POST"])
|
||||
def kiosk_access_link():
|
||||
"""Постоянная Start URL для Fully Kiosk + QR."""
|
||||
if not can_issue_kiosk_access_link():
|
||||
logger.warning(
|
||||
"[KIOSK] access-link: отказ (не localhost и нет admin-сессии) remote=%s host=%s",
|
||||
request.remote_addr,
|
||||
request.host,
|
||||
)
|
||||
return _error("Выдача Start URL доступна только администратору или с localhost.", 403)
|
||||
|
||||
device_id = _device_cookie_id()
|
||||
if not device_id:
|
||||
logger.warning(
|
||||
"[KIOSK] access-link: нет cookie remote=%s host=%s",
|
||||
request.remote_addr,
|
||||
request.host,
|
||||
)
|
||||
return _error("Не найден cookie терминала. Откройте kiosk-страницу заново.", 400)
|
||||
device = db.session.get(KioskDevice, device_id)
|
||||
if not device:
|
||||
logger.warning("[KIOSK] access-link: нет KioskDevice для id=%s", device_id)
|
||||
return _error("Терминал не зарегистрирован на сервере.", 404)
|
||||
|
||||
refresh = _access_link_refresh_requested()
|
||||
slug = _ensure_access_slug(device, refresh=refresh)
|
||||
start_url = _start_url_for_slug(slug)
|
||||
try:
|
||||
qr_img_url = pair_url_to_qr_data_uri(start_url)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[KIOSK] access-link: ошибка генерации QR device_id=%s",
|
||||
device.id,
|
||||
)
|
||||
return _error("Не удалось сгенерировать QR-код", 500)
|
||||
|
||||
logger.info(
|
||||
"[KIOSK] access-link: device_id=%s refresh=%s public_base=%s remote=%s",
|
||||
device.id,
|
||||
refresh,
|
||||
_resolve_public_base_url(),
|
||||
request.remote_addr,
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"start_url": start_url,
|
||||
"pair_url": start_url,
|
||||
"qr_image_url": qr_img_url,
|
||||
"device_id": device.id,
|
||||
"permanent": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/pair-token")
|
||||
def kiosk_pair_token():
|
||||
if not can_issue_kiosk_access_link():
|
||||
return _error("Выдача pair-token доступна только администратору или с localhost.", 403)
|
||||
|
||||
device_id = _device_cookie_id()
|
||||
if not device_id:
|
||||
logger.warning(
|
||||
"[KIOSK] pair-token: нет cookie remote=%s host=%s",
|
||||
request.remote_addr,
|
||||
request.host,
|
||||
)
|
||||
return _error("Не найден cookie терминала. Откройте kiosk-страницу заново.", 400)
|
||||
device = db.session.get(KioskDevice, device_id)
|
||||
if not device:
|
||||
logger.warning("[KIOSK] pair-token: нет KioskDevice для id=%s", device_id)
|
||||
return _error("Терминал не зарегистрирован на сервере.", 404)
|
||||
|
||||
ttl = max(30, int(current_app.config.get("KIOSK_PAIR_TOKEN_TTL_SECONDS", 300)))
|
||||
token = secrets.token_urlsafe(24)
|
||||
obj = KioskPairToken(
|
||||
token=token,
|
||||
device_id=device.id,
|
||||
expires_at=datetime.utcnow() + timedelta(seconds=ttl),
|
||||
issued_for="kiosk_qr_pair",
|
||||
)
|
||||
db.session.add(obj)
|
||||
db.session.commit()
|
||||
|
||||
base = _resolve_public_base_url()
|
||||
# QR и pair_url ведут на confirm: привязка киоска по device_id из токена, без cookie на телефоне.
|
||||
# pair/claim остаётся для сценария «второй терминал уже открыл сайт и имеет свой cookie».
|
||||
confirm_url = f"{base}/api/kiosk/pair/confirm?token={token}"
|
||||
pair_url = confirm_url
|
||||
try:
|
||||
qr_img_url = pair_url_to_qr_data_uri(pair_url)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[KIOSK] pair-token: ошибка локальной генерации QR device_id=%s",
|
||||
device.id,
|
||||
)
|
||||
return _error("Не удалось сгенерировать QR-код", 500)
|
||||
logger.info(
|
||||
"[KIOSK] pair-token выдан: device_id=%s token=%s public_base=%s remote=%s host=%s",
|
||||
device.id,
|
||||
_token_log_fragment(token),
|
||||
base,
|
||||
request.remote_addr,
|
||||
request.host,
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"token": token,
|
||||
"pair_url": pair_url,
|
||||
"confirm_url": confirm_url,
|
||||
"qr_image_url": qr_img_url,
|
||||
"expires_in": ttl,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/pair/confirm", methods=["GET", "POST"])
|
||||
def confirm_pair():
|
||||
"""GET — страница с кнопкой (токен не расходуется). POST — фактическая привязка."""
|
||||
if request.method == "GET":
|
||||
token = (request.args.get("token") or "").strip()
|
||||
if not token:
|
||||
logger.warning("[KIOSK] pair/confirm GET без token remote=%s", request.remote_addr)
|
||||
return _confirm_pair_error_html("token обязателен", 400)
|
||||
|
||||
row = db.session.execute(
|
||||
select(KioskPairToken).where(KioskPairToken.token == token)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
logger.warning(
|
||||
"[KIOSK] pair/confirm GET токен не найден token=%s remote=%s",
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
)
|
||||
return _confirm_pair_error_html("Токен не найден", 404)
|
||||
if row.used_at is not None:
|
||||
inner = (
|
||||
"<h2>Привязка уже выполнена</h2>"
|
||||
"<p>Этот одноразовый код уже использован. Если киоск не показывает доступ, "
|
||||
"обновите страницу весов на терминале.</p>"
|
||||
)
|
||||
return _confirm_html_page(inner, 200)
|
||||
if row.expires_at < datetime.utcnow():
|
||||
logger.warning(
|
||||
"[KIOSK] pair/confirm GET токен просрочен token=%s remote=%s",
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
)
|
||||
return _confirm_pair_error_html("Срок действия токена истёк", 410)
|
||||
logger.info(
|
||||
"[KIOSK] pair/confirm GET форма подтверждения token=%s device_id_в_токене=%s remote=%s",
|
||||
_token_log_fragment(token),
|
||||
row.device_id,
|
||||
request.remote_addr,
|
||||
)
|
||||
return _confirm_pair_form_html(token)
|
||||
|
||||
token = (request.form.get("token") or "").strip()
|
||||
if not token:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
token = str(payload.get("token") or "").strip()
|
||||
if not token:
|
||||
return _confirm_pair_error_response("token обязателен", 400)
|
||||
|
||||
row, error = _activate_by_token(token)
|
||||
if error:
|
||||
resp, status_code = error
|
||||
data = resp.get_json(silent=True) or {}
|
||||
msg = data.get("message", "Ошибка")
|
||||
logger.warning(
|
||||
"[KIOSK] pair/confirm POST отказ: %s token=%s remote=%s status=%s",
|
||||
msg,
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
status_code,
|
||||
)
|
||||
return _confirm_pair_error_response(msg, status_code)
|
||||
|
||||
device = db.session.get(KioskDevice, row.device_id)
|
||||
if not device:
|
||||
logger.error(
|
||||
"[KIOSK] pair/confirm POST: нет KioskDevice для device_id=%s из токена",
|
||||
row.device_id,
|
||||
)
|
||||
return _confirm_pair_error_response("Терминал не найден", 404)
|
||||
|
||||
row.used_at = datetime.utcnow()
|
||||
device.status = "active"
|
||||
device.last_seen_at = datetime.utcnow()
|
||||
device.last_ip = request.remote_addr
|
||||
|
||||
# Телефон с другим хостом, чем главный киоск (localhost vs LAN), имеет свой cookie и
|
||||
# отдельный KioskDevice. Редирект на /scales иначе попадает в guard с pending — активируем
|
||||
# и браузер, который отправил POST, если это другой pending-терминал.
|
||||
helper_id = _device_cookie_id()
|
||||
if helper_id and helper_id != device.id:
|
||||
helper = db.session.get(KioskDevice, helper_id)
|
||||
if helper and helper.status == "pending":
|
||||
helper.status = "active"
|
||||
helper.last_seen_at = datetime.utcnow()
|
||||
helper.last_ip = request.remote_addr
|
||||
logger.info(
|
||||
"[KIOSK] pair/confirm POST: также активирован браузер подтверждения device_id=%s "
|
||||
"(терминал из токена=%s)",
|
||||
helper_id,
|
||||
device.id,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
redirect_to = _scales_public_url()
|
||||
logger.info(
|
||||
"[KIOSK] pair/confirm POST OK: активирован device_id=%s token=%s redirect=%s remote=%s",
|
||||
device.id,
|
||||
_token_log_fragment(token),
|
||||
redirect_to,
|
||||
request.remote_addr,
|
||||
)
|
||||
return _confirm_pair_success_response()
|
||||
|
||||
|
||||
@bp.post("/pair/scan-confirm")
|
||||
def confirm_pair_from_scan():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
token = str(payload.get("token") or "").strip()
|
||||
if not token:
|
||||
return _error("token обязателен", 400)
|
||||
|
||||
target_device, error = _pair_current_device_by_token(token)
|
||||
if error:
|
||||
return error
|
||||
|
||||
logger.info(
|
||||
"[KIOSK] pair/scan-confirm OK device_id=%s token=%s",
|
||||
target_device.id,
|
||||
_token_log_fragment(token),
|
||||
)
|
||||
return jsonify({"success": True, "paired": True, "device_id": target_device.id})
|
||||
|
||||
|
||||
@bp.get("/pair/claim")
|
||||
def claim_pair_from_link():
|
||||
token = (request.args.get("token") or "").strip()
|
||||
if not token:
|
||||
return _error("token обязателен", 400)
|
||||
|
||||
logger.info(
|
||||
"[KIOSK] pair/claim GET token=%s remote=%s",
|
||||
_token_log_fragment(token),
|
||||
request.remote_addr,
|
||||
)
|
||||
target_device, error = _pair_current_device_by_token(token)
|
||||
if error:
|
||||
return error
|
||||
|
||||
return (
|
||||
"<html><head><meta charset='utf-8'></head><body>"
|
||||
"<h2>Терминал привязан</h2>"
|
||||
"<p>Переход на экран весов через 3 секунды...</p>"
|
||||
"<script>setTimeout(function(){ window.location.href = '/scales'; }, 3000);</script>"
|
||||
"</body></html>",
|
||||
200,
|
||||
)
|
||||
@@ -0,0 +1,632 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.lab.calc.diff import compare_master_execution
|
||||
from app.lab.calc.gfe_norms import preview_dynamic_norms
|
||||
from app.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
|
||||
from app.lab.calc.nutrients import parse_num
|
||||
from app.lab.calc.feed_groups import list_feed_groups_api, parse_group_selections
|
||||
from app.lab.calc.formulate import FormulateRequest, formulate, list_formulate_components
|
||||
from app.lab.calc.formulate_validate import validate_components
|
||||
from app.lab.commands import (
|
||||
apply_from_master,
|
||||
delete_animal_profile,
|
||||
ensure_empty_master,
|
||||
recalculate_ration,
|
||||
seed_from_execution,
|
||||
sync_from_execution,
|
||||
upsert_animal_profile,
|
||||
upsert_ration,
|
||||
)
|
||||
from app.lab.loaders.execution_loader import load_execution
|
||||
from app.lab.loaders.profile_loader import list_animal_profiles, load_animal_profile
|
||||
from app.lab.norm_catalog import list_norm_indicators
|
||||
from app.lab.services.seed_norms_catalog import (
|
||||
ReferenceNormsCatalogEmptyError,
|
||||
get_seed_norm_entry,
|
||||
list_seed_norm_catalog,
|
||||
)
|
||||
from app.lab.etl.lab_import_router import parse_lab_import
|
||||
from app.lab.etl.agrostar_xml_import import (
|
||||
apply_agrostar_import,
|
||||
enrich_parse_with_matches,
|
||||
parse_agrostar_xml,
|
||||
)
|
||||
from app.lab.loaders.ration_loader import load_ration, ration_to_calc_lines
|
||||
from app.lab.serde.api import diff_to_api, ration_to_api
|
||||
from app.models import Recipe
|
||||
from app.routes.auth_decorators import can_access_lab, require_auth
|
||||
|
||||
_LAB_TEMPLATES = os.path.join(os.path.dirname(__file__), "..", "lab", "templates")
|
||||
bp = Blueprint("lab", __name__, url_prefix="/api/lab", template_folder=_LAB_TEMPLATES)
|
||||
|
||||
|
||||
@bp.before_request
|
||||
def _lab_module_access_guard():
|
||||
if request.endpoint == "lab.health":
|
||||
return None
|
||||
if not session.get("authenticated", False):
|
||||
return None
|
||||
if not can_access_lab():
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"status": "error",
|
||||
"message": "Модуль Lab недоступен для этого пользователя",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _user() -> str:
|
||||
return str(session.get("user_login") or "system")
|
||||
|
||||
|
||||
@bp.get("/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "module": "lab"})
|
||||
|
||||
|
||||
@bp.get("/recipes")
|
||||
@require_auth
|
||||
def list_lab_recipes():
|
||||
"""Список партий для UI lab — только контур зоотехника (сессия)."""
|
||||
rows = db.session.execute(
|
||||
select(Recipe.id, Recipe.name)
|
||||
.where(Recipe.is_deleted.is_(False))
|
||||
.order_by(Recipe.name)
|
||||
.limit(500)
|
||||
).all()
|
||||
return jsonify({"recipes": [{"id": rid, "name": name} for rid, name in rows]})
|
||||
|
||||
|
||||
def _read_agrostar_xml_from_request() -> tuple[str | None, tuple | None]:
|
||||
"""Вернуть (xml_text, error_response) — error_response = (json, status) или None."""
|
||||
upload = request.files.get("file")
|
||||
if upload is not None:
|
||||
raw = upload.read()
|
||||
for encoding in ("utf-8", "utf-8-sig", "cp1251"):
|
||||
try:
|
||||
return raw.decode(encoding), None
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return None, (jsonify({"error": True, "message": "Не удалось прочитать кодировку XML"}), 400)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
xml_text = (data.get("xml") or data.get("content") or "").strip()
|
||||
if xml_text:
|
||||
return xml_text, None
|
||||
return None, (jsonify({"error": True, "message": "Передайте file (multipart) или json.xml"}), 400)
|
||||
|
||||
|
||||
@bp.post("/import/parse")
|
||||
@require_auth
|
||||
def post_lab_import_parse():
|
||||
"""Единый комбайн: AgroStar xml/pdf/xlsx, ПЛИНОР pdf."""
|
||||
upload = request.files.get("file")
|
||||
if upload is None:
|
||||
return jsonify({"error": True, "message": "Передайте file (multipart)"}), 400
|
||||
data = upload.read()
|
||||
body = parse_lab_import(upload.filename or "upload.bin", data)
|
||||
if body.get("error"):
|
||||
return jsonify(body), 400
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
@bp.post("/import/agrostar-xml")
|
||||
@require_auth
|
||||
def post_agrostar_parse():
|
||||
"""Разбор AgroStar Standard_XML_Data + подсказки сопоставления с component."""
|
||||
xml_text, err = _read_agrostar_xml_from_request()
|
||||
if err:
|
||||
return err
|
||||
parsed = parse_agrostar_xml(xml_text or "")
|
||||
if parsed.errors and not parsed.samples:
|
||||
return jsonify({"error": True, "message": "; ".join(parsed.errors), **parsed.to_api_dict()}), 400
|
||||
body = enrich_parse_with_matches(parsed)
|
||||
if parsed.errors:
|
||||
body["warnings"] = parsed.errors
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
@bp.post("/import/agrostar-xml/apply")
|
||||
@require_auth
|
||||
def post_agrostar_apply():
|
||||
"""Запись nutrients и dry_matter в component по сопоставленным пробам."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
assignments = data.get("assignments") or []
|
||||
if not assignments:
|
||||
return jsonify({"error": True, "message": "assignments required"}), 400
|
||||
dry_run = bool(data.get("dryRun") or data.get("dry_run"))
|
||||
result = apply_agrostar_import(assignments, user_id=_user(), dry_run=dry_run)
|
||||
return jsonify(result.to_api_dict())
|
||||
|
||||
|
||||
@bp.get("/rations/diff")
|
||||
@require_auth
|
||||
def get_diff_batch():
|
||||
raw = (request.args.get("recipe_ids") or "").strip()
|
||||
if not raw:
|
||||
return jsonify({"error": True, "message": "recipe_ids required"}), 400
|
||||
ids = [x.strip() for x in raw.split(",") if x.strip()]
|
||||
results = []
|
||||
for recipe_id in ids:
|
||||
try:
|
||||
master = load_ration(recipe_id)
|
||||
execution = load_execution(recipe_id)
|
||||
except LookupError:
|
||||
results.append({"recipeId": recipe_id, "error": "not_found"})
|
||||
continue
|
||||
master_lines = [
|
||||
{
|
||||
"component_id": l.component_id,
|
||||
"daily_kg": l.daily_kg,
|
||||
"in_ration": l.in_ration,
|
||||
}
|
||||
for l in master.lines
|
||||
]
|
||||
exec_lines = [
|
||||
{
|
||||
"component_id": l.component_id,
|
||||
"daily_kg_total": l.daily_kg_total,
|
||||
}
|
||||
for l in execution.lines
|
||||
]
|
||||
results.append(
|
||||
{
|
||||
"recipeId": recipe_id,
|
||||
**diff_to_api(compare_master_execution(master_lines, exec_lines)),
|
||||
}
|
||||
)
|
||||
return jsonify({"results": results})
|
||||
|
||||
|
||||
@bp.get("/rations/<recipe_id>")
|
||||
@require_auth
|
||||
def get_ration(recipe_id: str):
|
||||
try:
|
||||
snapshot = load_ration(recipe_id)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(ration_to_api(snapshot))
|
||||
|
||||
|
||||
@bp.put("/rations/<recipe_id>")
|
||||
@require_auth
|
||||
def put_ration(recipe_id: str):
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
upsert_ration(recipe_id, data, _user())
|
||||
snapshot = load_ration(recipe_id)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
except Exception as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
return jsonify(ration_to_api(snapshot))
|
||||
|
||||
|
||||
@bp.post("/rations/<recipe_id>/recalculate")
|
||||
@require_auth
|
||||
def post_recalculate(recipe_id: str):
|
||||
try:
|
||||
result = recalculate_ration(recipe_id, _user())
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.get("/rations/<recipe_id>/diff")
|
||||
@require_auth
|
||||
def get_diff(recipe_id: str):
|
||||
try:
|
||||
master = load_ration(recipe_id)
|
||||
execution = load_execution(recipe_id)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
master_lines = [
|
||||
{
|
||||
"component_id": l.component_id,
|
||||
"daily_kg": l.daily_kg,
|
||||
"in_ration": l.in_ration,
|
||||
}
|
||||
for l in master.lines
|
||||
]
|
||||
exec_lines = [
|
||||
{
|
||||
"component_id": l.component_id,
|
||||
"daily_kg_total": l.daily_kg_total,
|
||||
}
|
||||
for l in execution.lines
|
||||
]
|
||||
return jsonify(diff_to_api(compare_master_execution(master_lines, exec_lines)))
|
||||
|
||||
|
||||
@bp.post("/rations/<recipe_id>/seed-from-execution")
|
||||
@require_auth
|
||||
def post_seed(recipe_id: str):
|
||||
try:
|
||||
result = seed_from_execution(recipe_id, _user())
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.post("/rations/<recipe_id>/ensure-empty")
|
||||
@require_auth
|
||||
def post_ensure_empty(recipe_id: str):
|
||||
try:
|
||||
result = ensure_empty_master(recipe_id, _user())
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.post("/rations/<recipe_id>/sync-from-execution")
|
||||
@require_auth
|
||||
def post_sync_from_execution(recipe_id: str):
|
||||
try:
|
||||
result = sync_from_execution(recipe_id, _user())
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.post("/rations/<recipe_id>/apply-from-master")
|
||||
@require_auth
|
||||
def post_apply(recipe_id: str):
|
||||
try:
|
||||
result = apply_from_master(recipe_id, _user())
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.get("/rations/<recipe_id>/compound")
|
||||
@require_auth
|
||||
def get_compound(recipe_id: str):
|
||||
try:
|
||||
snapshot = load_ration(recipe_id)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(snapshot.compound_results or {})
|
||||
|
||||
|
||||
@bp.get("/norm-indicators")
|
||||
@require_auth
|
||||
def get_norm_indicators():
|
||||
return jsonify({"indicators": list_norm_indicators()})
|
||||
|
||||
|
||||
@bp.get("/norms-preview")
|
||||
@require_auth
|
||||
def get_norms_preview():
|
||||
"""Preview суточных норм по выбранной методике."""
|
||||
from app.lab.models import LabAnimalProfile
|
||||
from app.lab.services.norms_params import load_norms_params
|
||||
|
||||
method = normalize_norms_method(request.args.get("method"))
|
||||
mass = parse_num(request.args.get("mass_kg"))
|
||||
milk = parse_num(request.args.get("milk_yield_kg"))
|
||||
profile_id = (request.args.get("profile_id") or "").strip()
|
||||
stored: dict = {}
|
||||
params = NormsParams()
|
||||
if profile_id:
|
||||
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
|
||||
if profile is not None:
|
||||
from app.lab.services.profile_norms import load_norms_dict
|
||||
|
||||
stored = load_norms_dict(profile.id)
|
||||
params = load_norms_params(profile)
|
||||
if mass is None:
|
||||
mass = profile.mass_kg
|
||||
if milk is None:
|
||||
milk = profile.milk_yield_kg
|
||||
if not request.args.get("method"):
|
||||
method = normalize_norms_method(profile.norms_method)
|
||||
override = NormsParams.from_dict(
|
||||
{
|
||||
"milkFatPct": parse_num(request.args.get("milk_fat_pct")),
|
||||
"lactationNo": request.args.get("lactation_no"),
|
||||
"bodyCondition": request.args.get("body_condition"),
|
||||
"housingSystem": request.args.get("housing_system"),
|
||||
"koncOeSv": parse_num(request.args.get("konc_oe_sv")),
|
||||
}
|
||||
)
|
||||
params = NormsParams(
|
||||
milk_fat_pct=override.milk_fat_pct if override.milk_fat_pct is not None else params.milk_fat_pct,
|
||||
lactation_no=override.lactation_no if override.lactation_no is not None else params.lactation_no,
|
||||
lactation_stage=override.lactation_stage if override.lactation_stage is not None else params.lactation_stage,
|
||||
body_condition=override.body_condition if override.body_condition is not None else params.body_condition,
|
||||
housing_system=override.housing_system if override.housing_system is not None else params.housing_system,
|
||||
konc_oe_sv=override.konc_oe_sv if override.konc_oe_sv is not None else params.konc_oe_sv,
|
||||
)
|
||||
try:
|
||||
resolved, meta = resolve_norms(
|
||||
NormsResolveRequest(
|
||||
method=method,
|
||||
stored=stored,
|
||||
mass_kg=mass,
|
||||
milk_yield_kg=milk,
|
||||
force_dynamic=method == "wesp",
|
||||
params=params,
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
|
||||
if method == "wesp" and not dynamic:
|
||||
dynamic = preview_dynamic_norms(mass, milk)
|
||||
body: dict = {
|
||||
"normsMethod": method,
|
||||
"resolvedIndicators": resolved,
|
||||
"dynamicNorms": dynamic,
|
||||
"normsMeta": meta.get("meta"),
|
||||
}
|
||||
if meta.get("coverage"):
|
||||
body["coverage"] = meta["coverage"]
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
@bp.get("/gfe-norms-preview")
|
||||
@require_auth
|
||||
def get_gfe_norms_preview():
|
||||
"""Расчётные min по GfE 2001 (alias method=wesp)."""
|
||||
mass = parse_num(request.args.get("mass_kg"))
|
||||
milk = parse_num(request.args.get("milk_yield_kg"))
|
||||
return jsonify({"dynamicNorms": preview_dynamic_norms(mass, milk), "normsMethod": "wesp"})
|
||||
|
||||
|
||||
@bp.get("/seed-norms-catalog")
|
||||
@require_auth
|
||||
def get_seed_norms_catalog():
|
||||
"""Строки справочника zootech (lab_animal_profile norm_*)."""
|
||||
ration_type = (request.args.get("ration_type") or "DAIRY").strip().upper()
|
||||
try:
|
||||
entries = list_seed_norm_catalog(ration_type)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
except ReferenceNormsCatalogEmptyError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify({"rationType": ration_type, "entries": entries})
|
||||
|
||||
|
||||
@bp.get("/seed-norms-catalog/<int:external_no>")
|
||||
@require_auth
|
||||
def get_seed_norms_catalog_row(external_no: int):
|
||||
ration_type = (request.args.get("ration_type") or "DAIRY").strip().upper()
|
||||
try:
|
||||
entry = get_seed_norm_entry(ration_type, external_no)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
if entry is None:
|
||||
return jsonify({"error": True, "message": "Строка справочника не найдена"}), 404
|
||||
return jsonify(entry)
|
||||
|
||||
|
||||
@bp.get("/formulate/groups")
|
||||
@require_auth
|
||||
def get_formulate_groups():
|
||||
return jsonify({"groups": list_feed_groups_api()})
|
||||
|
||||
|
||||
@bp.get("/formulate/components")
|
||||
@require_auth
|
||||
def get_formulate_components():
|
||||
return jsonify({"components": list_formulate_components(), "groups": list_feed_groups_api()})
|
||||
|
||||
|
||||
@bp.get("/formulate/validate")
|
||||
@require_auth
|
||||
def get_formulate_validate():
|
||||
raw = (request.args.get("ids") or "").strip()
|
||||
if not raw:
|
||||
return jsonify({"error": True, "message": "ids required"}), 400
|
||||
ids = [x.strip() for x in raw.split(",") if x.strip()]
|
||||
return jsonify({"components": validate_components(ids)})
|
||||
|
||||
|
||||
@bp.post("/formulate")
|
||||
@require_auth
|
||||
def post_formulate():
|
||||
data = request.get_json(silent=True) or {}
|
||||
group_selections = parse_group_selections(data.get("groupSelections"))
|
||||
main_feed_ids = data.get("mainFeedIds") or []
|
||||
additional_ids = data.get("additionalIds") or []
|
||||
candidate_ids = data.get("candidateIds") or data.get("componentIds") or []
|
||||
if group_selections:
|
||||
candidate_ids = []
|
||||
elif main_feed_ids or additional_ids:
|
||||
if not isinstance(main_feed_ids, list) or not isinstance(additional_ids, list):
|
||||
return jsonify({"error": True, "message": "mainFeedIds/additionalIds must be lists"}), 400
|
||||
group_selections = {
|
||||
"rough": [str(x) for x in main_feed_ids],
|
||||
"succulent": [],
|
||||
"concentrate": [str(x) for x in additional_ids if str(x) not in main_feed_ids],
|
||||
"other": [],
|
||||
}
|
||||
for x in additional_ids:
|
||||
sx = str(x)
|
||||
if sx not in group_selections["rough"] and sx not in group_selections["concentrate"]:
|
||||
group_selections["concentrate"].append(sx)
|
||||
candidate_ids = []
|
||||
elif not isinstance(candidate_ids, list):
|
||||
return jsonify({"error": True, "message": "candidateIds must be a list"}), 400
|
||||
profile_id = (data.get("profileId") or "").strip()
|
||||
if not profile_id:
|
||||
return jsonify({"error": True, "message": "profileId required"}), 400
|
||||
|
||||
try:
|
||||
result = formulate(
|
||||
FormulateRequest(
|
||||
profile_id=profile_id,
|
||||
candidate_ids=[str(x) for x in candidate_ids],
|
||||
group_selections=group_selections,
|
||||
main_feed_ids=[str(x) for x in main_feed_ids],
|
||||
mass_kg=parse_formulate_num(data.get("massKg")),
|
||||
milk_yield_kg=parse_formulate_num(data.get("milkYieldKg")),
|
||||
heads_per_trip=_parse_int(data.get("headsPerTrip")),
|
||||
total_kg_per_head=float(data.get("totalKgPerHead") or 7.3),
|
||||
optimize_keys=list(data.get("optimizeKeys") or []),
|
||||
objective=str(data.get("objective") or "min_cost"),
|
||||
cost_weight=float(data.get("costWeight") or 100),
|
||||
grid_step=float(data.get("gridStep") or 0.1),
|
||||
prefilter_k=int(data.get("prefilterK") or 18),
|
||||
min_share=float(data.get("minShare") or 0.05),
|
||||
norms_method=str(data.get("normsMethod") or "wesp"),
|
||||
norms_params=dict(data.get("normsParams") or {}),
|
||||
)
|
||||
)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
except ValueError as exc:
|
||||
args = exc.args
|
||||
if args and args[0] == "ineligible_components":
|
||||
return jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Некоторые компоненты не подходят для авторациона",
|
||||
"components": args[1],
|
||||
}
|
||||
), 400
|
||||
if args and args[0] == "candidate_ids_min_3":
|
||||
return jsonify({"error": True, "message": "Нужно минимум 3 кандидата"}), 400
|
||||
if args and args[0] == "candidate_ids_duplicate":
|
||||
return jsonify({"error": True, "message": "Дубликаты в candidateIds"}), 400
|
||||
if args and args[0] == "no_feasible_solution":
|
||||
return jsonify({"error": True, "message": "Не удалось подобрать рацион"}), 400
|
||||
if args and args[0] == "group_selection_invalid":
|
||||
return jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Некорректный выбор по группам кормов",
|
||||
"errors": args[1],
|
||||
}
|
||||
), 400
|
||||
if args and args[0] == "no_feasible_solution_groups":
|
||||
return jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": "Не удалось подобрать рацион с учётом обязательных групп кормов",
|
||||
}
|
||||
), 400
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def parse_formulate_num(value) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int(value) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@bp.get("/animal-profiles")
|
||||
@require_auth
|
||||
def get_profiles():
|
||||
ration_type = (request.args.get("ration_type") or "").strip() or None
|
||||
return jsonify({"profiles": list_animal_profiles(ration_type)})
|
||||
|
||||
|
||||
@bp.post("/animal-profiles")
|
||||
@require_auth
|
||||
def post_profile():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = upsert_animal_profile(None, data, _user())
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@bp.get("/animal-profiles/<profile_id>")
|
||||
@require_auth
|
||||
def get_profile(profile_id: str):
|
||||
try:
|
||||
return jsonify(load_animal_profile(profile_id))
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
|
||||
|
||||
@bp.put("/animal-profiles/<profile_id>")
|
||||
@require_auth
|
||||
def put_profile(profile_id: str):
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = upsert_animal_profile(profile_id, data, _user())
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.delete("/animal-profiles/<profile_id>")
|
||||
@require_auth
|
||||
def delete_profile(profile_id: str):
|
||||
try:
|
||||
result = delete_animal_profile(profile_id, _user())
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.get("/rations/<recipe_id>/print")
|
||||
@require_auth
|
||||
def get_print(recipe_id: str):
|
||||
mode = (request.args.get("mode") or "ration").strip().lower()
|
||||
if mode not in ("ration", "compound"):
|
||||
return jsonify({"error": True, "message": "mode must be ration or compound"}), 400
|
||||
try:
|
||||
snapshot = load_ration(recipe_id)
|
||||
except LookupError as exc:
|
||||
return jsonify({"error": True, "message": str(exc)}), 404
|
||||
if not snapshot.exists:
|
||||
return jsonify({"error": True, "message": "Мастер рациона не найден"}), 404
|
||||
|
||||
lines = []
|
||||
for line in snapshot.lines:
|
||||
included = line.in_ration if mode == "ration" else line.in_compound
|
||||
if not included:
|
||||
continue
|
||||
lines.append(
|
||||
{
|
||||
"name": line.ingredient_name or line.component_id or "—",
|
||||
"daily_kg": line.daily_kg,
|
||||
"included": True,
|
||||
}
|
||||
)
|
||||
results = snapshot.ration_results if mode == "ration" else snapshot.compound_results
|
||||
indicators = (results or {}).get("indicators") or []
|
||||
title = "Рацион" if mode == "ration" else "Комбикорм"
|
||||
return render_template(
|
||||
"print_ration.html",
|
||||
title=title,
|
||||
recipe_name=snapshot.recipe_name,
|
||||
ration_type=snapshot.ration_type,
|
||||
heads=snapshot.heads_per_trip,
|
||||
mode=mode,
|
||||
lines=lines,
|
||||
indicators=indicators[:12],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
import time
|
||||
import uuid
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request, send_from_directory, session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
Component,
|
||||
ComponentLoadingTime,
|
||||
FeedDispenser,
|
||||
FeedMixer,
|
||||
FeedingLocation,
|
||||
FeedingPeriod,
|
||||
FeedingPoint,
|
||||
Ingredient,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
PeriodRecipe,
|
||||
Recipe,
|
||||
Trip,
|
||||
UnloadingGroup,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal, require_paired_terminal
|
||||
from app.services.daily_plan.loading_recipe import plan_ingredients_for_loading
|
||||
from app.services.update_api_service import (
|
||||
build_updates_status_payload,
|
||||
force_check_updates,
|
||||
install_pending_update,
|
||||
)
|
||||
from app.services.hardware import GPIOController
|
||||
from config import write_sync_client_state
|
||||
|
||||
bp = Blueprint("legacy_misc", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
class _LoadingRuntime:
|
||||
def __init__(self) -> None:
|
||||
self._lock = Lock()
|
||||
self.current_recipe_id: Optional[str] = None
|
||||
self.current_component_index = 0
|
||||
self.weight_at_current_component_start = 0.0
|
||||
self.is_mixing_mode = False
|
||||
self.mixing_timer_active = False
|
||||
self.navigation_commands_queue: List[Dict[str, Any]] = []
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"current_recipe_id": self.current_recipe_id,
|
||||
"current_component_index": self.current_component_index,
|
||||
"weight_at_current_component_start": self.weight_at_current_component_start,
|
||||
"is_mixing_mode": self.is_mixing_mode,
|
||||
"mixing_timer_active": self.mixing_timer_active,
|
||||
}
|
||||
|
||||
|
||||
_runtime = _LoadingRuntime()
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
def _get_active_recipe(recipe_id: Optional[str]) -> Optional[Recipe]:
|
||||
if not recipe_id:
|
||||
return None
|
||||
return db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _get_active_ingredients(recipe: Recipe) -> List[Ingredient]:
|
||||
rows = [i for i in (recipe.ingredients or []) if not getattr(i, "is_deleted", False)]
|
||||
return sorted(rows, key=lambda x: (getattr(x, "order", 0), x.created_at))
|
||||
|
||||
|
||||
def _loading_ingredient_rows(recipe: Recipe) -> List[Dict[str, Any]]:
|
||||
"""Веса и состав для экрана оператора — overlay «План на день», как на киоске."""
|
||||
return plan_ingredients_for_loading(recipe)
|
||||
|
||||
|
||||
def _current_weight() -> float:
|
||||
"""Текущий вес с тех же весов, что и GET /current_weight (ScalesReader)."""
|
||||
try:
|
||||
from app.routes import scales as scales_module
|
||||
|
||||
reader = scales_module._get_reader()
|
||||
return float(reader.get_current_weight())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
@bp.post("/set_mixing_mode")
|
||||
@require_auth
|
||||
def set_mixing_mode():
|
||||
data = request.get_json(silent=True) or {}
|
||||
with _runtime._lock:
|
||||
_runtime.is_mixing_mode = bool(data.get("is_mixing", False))
|
||||
return jsonify({"status": "success"})
|
||||
|
||||
|
||||
@bp.post("/led/<string:state>")
|
||||
@require_auth
|
||||
def control_led(state: str):
|
||||
try:
|
||||
controller = GPIOController.get_instance(
|
||||
pin=int(current_app.config.get("GPIO_LED_PIN", 18)),
|
||||
simulation_mode=bool(current_app.config.get("SIMULATION_MODE", False)),
|
||||
)
|
||||
if state == "on":
|
||||
controller.on()
|
||||
return jsonify({"status": "success", "message": "Светодиод включен"})
|
||||
if state == "off":
|
||||
controller.off()
|
||||
return jsonify({"status": "success", "message": "Светодиод выключен"})
|
||||
if state == "blink":
|
||||
payload = request.get_json(silent=True) or {}
|
||||
times = int(payload.get("times", 1) or 1)
|
||||
delay = float(payload.get("delay", 0.5) or 0.5)
|
||||
controller.blink(times=max(1, times), delay=max(0.05, delay))
|
||||
return jsonify({"status": "success", "message": f"Мигание {times} раз"})
|
||||
return jsonify({"status": "error", "message": "Неверная команда"}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
|
||||
@bp.post("/set_mixing_timer")
|
||||
@require_auth_or_paired_terminal
|
||||
def set_mixing_timer():
|
||||
data = request.get_json(silent=True) or {}
|
||||
with _runtime._lock:
|
||||
_runtime.mixing_timer_active = bool(data.get("active", False))
|
||||
return jsonify({"status": "success"})
|
||||
|
||||
|
||||
@bp.get("/get_mixing_timer")
|
||||
@require_auth_or_paired_terminal
|
||||
def get_mixing_timer():
|
||||
with _runtime._lock:
|
||||
active = _runtime.mixing_timer_active
|
||||
return jsonify({"active": active})
|
||||
|
||||
|
||||
def _kg_int(value: float) -> int:
|
||||
"""Целые килограммы для API и UI."""
|
||||
return int(round(float(value)))
|
||||
|
||||
|
||||
@bp.get("/weight_display_data")
|
||||
@require_paired_terminal
|
||||
def weight_display_data():
|
||||
snap = _runtime.snapshot()
|
||||
recipe = _get_active_recipe(snap["current_recipe_id"])
|
||||
current_weight = _current_weight()
|
||||
if recipe is None:
|
||||
return jsonify(
|
||||
{
|
||||
"status": "no_recipe",
|
||||
"component_name": "Текущий вес",
|
||||
"remaining_weight": 0,
|
||||
"current_loaded": _kg_int(current_weight),
|
||||
"total_component": 0,
|
||||
"total_mixture": _kg_int(current_weight),
|
||||
"recipe_name": "",
|
||||
"current_index": 0,
|
||||
"total_components": 0,
|
||||
"show_reset_button": False,
|
||||
"show_nav_buttons": False,
|
||||
"is_mixing_mode": snap["is_mixing_mode"],
|
||||
"mixing_timer_active": snap["mixing_timer_active"],
|
||||
"next_component_name": None,
|
||||
}
|
||||
)
|
||||
|
||||
ingredients = _loading_ingredient_rows(recipe)
|
||||
total_components = len(ingredients)
|
||||
if total_components == 0:
|
||||
return jsonify({"status": "error", "message": "Нет активных компонентов"}), 400
|
||||
|
||||
idx = min(snap["current_component_index"], total_components - 1)
|
||||
current_ingredient = ingredients[idx]
|
||||
current_loaded = max(0.0, current_weight - snap["weight_at_current_component_start"])
|
||||
total_component_weight = float(current_ingredient.get("amount") or 0)
|
||||
total_mixture_weight = sum(float(i.get("amount") or 0) for i in ingredients)
|
||||
remaining = max(0.0, total_component_weight - current_loaded)
|
||||
next_name = ingredients[idx + 1]["name"] if idx < total_components - 1 else None
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "active",
|
||||
"component_name": current_ingredient["name"],
|
||||
"remaining_weight": _kg_int(remaining),
|
||||
"current_loaded": _kg_int(current_loaded),
|
||||
"total_component": _kg_int(total_component_weight),
|
||||
"total_mixture": _kg_int(total_mixture_weight),
|
||||
"recipe_name": recipe.name,
|
||||
"current_index": idx,
|
||||
"total_components": total_components,
|
||||
"show_reset_button": current_loaded > 0,
|
||||
"show_nav_buttons": total_components > 1,
|
||||
"is_mixing_mode": snap["is_mixing_mode"],
|
||||
"mixing_timer_active": snap["mixing_timer_active"],
|
||||
"next_component_name": next_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/get_current_recipe")
|
||||
@require_auth
|
||||
def get_current_recipe():
|
||||
snap = _runtime.snapshot()
|
||||
recipe = _get_active_recipe(snap["current_recipe_id"])
|
||||
if recipe is None:
|
||||
return jsonify({"status": "inactive", "message": "Рецепт не выбран"})
|
||||
ingredients = _loading_ingredient_rows(recipe)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "active",
|
||||
"recipe_id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"ingredients": [
|
||||
{"name": ingredient["name"], "amount": float(ingredient.get("amount") or 0)}
|
||||
for ingredient in ingredients
|
||||
],
|
||||
"total_weight": sum(float(i.get("amount") or 0) for i in ingredients),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/current_loading_state")
|
||||
@require_auth
|
||||
def current_loading_state():
|
||||
snap = _runtime.snapshot()
|
||||
recipe = _get_active_recipe(snap["current_recipe_id"])
|
||||
if recipe is None:
|
||||
return jsonify({"status": "inactive", "message": "Рецепт не выбран"})
|
||||
|
||||
ingredients = _loading_ingredient_rows(recipe)
|
||||
idx = min(snap["current_component_index"], max(len(ingredients) - 1, 0))
|
||||
current_ingredient = ingredients[idx] if ingredients else None
|
||||
current_weight = _current_weight()
|
||||
loaded = max(0.0, current_weight - snap["weight_at_current_component_start"])
|
||||
target = float(current_ingredient.get("amount") or 0) if current_ingredient else 0.0
|
||||
remaining = max(0.0, target - loaded)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "active",
|
||||
"recipe_id": recipe.id,
|
||||
"recipe_name": recipe.name,
|
||||
"current_index": idx,
|
||||
"current_component": {
|
||||
"name": current_ingredient["name"] if current_ingredient else "Не выбран",
|
||||
"target_weight": target,
|
||||
"current_weight": current_weight,
|
||||
"current_loaded": loaded,
|
||||
"remaining_weight": remaining,
|
||||
},
|
||||
"total_mixture_weight": sum(float(i.get("amount") or 0) for i in ingredients),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/set_current_recipe")
|
||||
@require_auth_or_paired_terminal
|
||||
def set_current_recipe():
|
||||
data = request.get_json(silent=True) or {}
|
||||
recipe_id = data.get("recipe_id")
|
||||
if not recipe_id:
|
||||
with _runtime._lock:
|
||||
_runtime.current_recipe_id = None
|
||||
_runtime.current_component_index = 0
|
||||
_runtime.weight_at_current_component_start = 0.0
|
||||
return jsonify({"status": "cleared"})
|
||||
|
||||
recipe = _get_active_recipe(str(recipe_id))
|
||||
if recipe is None:
|
||||
return jsonify({"status": "error", "message": "Рецепт не найден или удален"}), 404
|
||||
|
||||
with _runtime._lock:
|
||||
_runtime.current_recipe_id = recipe.id
|
||||
_runtime.current_component_index = 0
|
||||
_runtime.weight_at_current_component_start = _current_weight()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@bp.post("/sync_loading_state")
|
||||
@require_auth_or_paired_terminal
|
||||
def sync_loading_state():
|
||||
data = request.get_json(silent=True) or {}
|
||||
with _runtime._lock:
|
||||
if data.get("recipe_id"):
|
||||
_runtime.current_recipe_id = str(data["recipe_id"])
|
||||
_runtime.current_component_index = int(data.get("component_index", 0) or 0)
|
||||
_runtime.weight_at_current_component_start = float(
|
||||
data.get("component_start_weight", 0) or 0
|
||||
)
|
||||
return jsonify({"status": "success"})
|
||||
|
||||
|
||||
@bp.post("/update_loading_state")
|
||||
@require_auth_or_paired_terminal
|
||||
def update_loading_state():
|
||||
return jsonify({"status": "success"})
|
||||
|
||||
|
||||
@bp.post("/navigate_component")
|
||||
@require_auth_or_paired_terminal
|
||||
def navigate_component():
|
||||
data = request.get_json(silent=True) or {}
|
||||
direction = data.get("direction")
|
||||
with _runtime._lock:
|
||||
recipe = _get_active_recipe(_runtime.current_recipe_id)
|
||||
if recipe is None:
|
||||
return jsonify({"status": "error", "message": "Рецепт не выбран"}), 400
|
||||
total = len(_loading_ingredient_rows(recipe))
|
||||
if total == 0:
|
||||
return jsonify({"status": "error", "message": "Нет активных компонентов"}), 400
|
||||
if direction == "next" and _runtime.current_component_index < total - 1:
|
||||
_runtime.current_component_index += 1
|
||||
_runtime.weight_at_current_component_start = _current_weight()
|
||||
elif direction == "prev" and _runtime.current_component_index > 0:
|
||||
_runtime.current_component_index -= 1
|
||||
_runtime.weight_at_current_component_start = _current_weight()
|
||||
else:
|
||||
return jsonify({"status": "error", "message": "Невозможно переключить"}), 400
|
||||
return jsonify({"status": "ok", "index": _runtime.current_component_index})
|
||||
|
||||
|
||||
@bp.post("/reset_component")
|
||||
@require_auth_or_paired_terminal
|
||||
def reset_component():
|
||||
with _runtime._lock:
|
||||
if _runtime.current_recipe_id is None:
|
||||
return jsonify({"status": "error", "message": "Рецепт не выбран"}), 400
|
||||
_runtime.weight_at_current_component_start = _current_weight()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@bp.post("/send_navigation_command")
|
||||
@require_paired_terminal
|
||||
def send_navigation_command():
|
||||
data = request.get_json(silent=True) or {}
|
||||
command = data.get("command")
|
||||
if command not in {"prev", "next"}:
|
||||
return jsonify({"status": "error", "message": "Неверная команда"}), 400
|
||||
with _runtime._lock:
|
||||
_runtime.navigation_commands_queue.append({"command": command, "timestamp": time.time()})
|
||||
if len(_runtime.navigation_commands_queue) > 10:
|
||||
_runtime.navigation_commands_queue = _runtime.navigation_commands_queue[-10:]
|
||||
return jsonify({"status": "success", "message": f"Команда {command} отправлена"})
|
||||
|
||||
|
||||
@bp.get("/get_navigation_commands")
|
||||
@require_auth_or_paired_terminal
|
||||
def get_navigation_commands():
|
||||
with _runtime._lock:
|
||||
commands = list(_runtime.navigation_commands_queue)
|
||||
_runtime.navigation_commands_queue.clear()
|
||||
return jsonify({"status": "success", "commands": commands})
|
||||
|
||||
|
||||
@bp.post("/set_role")
|
||||
@require_auth
|
||||
def set_role():
|
||||
data = request.get_json(silent=True) or {}
|
||||
role = data.get("role")
|
||||
dispenser_name = (data.get("dispenser_name") or "").strip()
|
||||
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})
|
||||
|
||||
|
||||
@bp.get("/mac_info")
|
||||
@require_auth
|
||||
def mac_info():
|
||||
raw = f"{uuid.getnode():012x}"
|
||||
mac = ":".join(raw[i : i + 2] for i in range(0, 12, 2))
|
||||
lock_enabled = bool(current_app.config.get("MAC_LOCK_ENABLED", False))
|
||||
allowed = current_app.config.get("ALLOWED_MAC_ADDRESSES", []) or []
|
||||
return jsonify(
|
||||
{
|
||||
"current_mac": mac,
|
||||
"is_authorized": (not lock_enabled) or (mac in allowed),
|
||||
"mac_lock_enabled": lock_enabled,
|
||||
"allowed_mac_addresses": allowed,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _model_by_entity_type(entity_type: str):
|
||||
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 _resolve_entity(model, entity_id: str):
|
||||
if model is PeriodRecipe:
|
||||
parts = entity_id.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
return db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == parts[0],
|
||||
PeriodRecipe.recipe_id == parts[1],
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return db.session.get(model, entity_id)
|
||||
|
||||
|
||||
@bp.delete("/<string:entity_type>/<string:entity_id>/soft_delete")
|
||||
@require_auth
|
||||
def soft_delete_entity(entity_type: str, entity_id: str):
|
||||
model = _model_by_entity_type(entity_type)
|
||||
if model is None:
|
||||
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
|
||||
entity = _resolve_entity(model, entity_id)
|
||||
if entity is None:
|
||||
return _error("Объект не найден", 404)
|
||||
if not hasattr(entity, "is_deleted"):
|
||||
return _error("Мягкое удаление не поддерживается", 400)
|
||||
if entity.is_deleted:
|
||||
return _error("Объект уже удален", 400)
|
||||
|
||||
deleted_by = (request.get_json(silent=True) or {}).get("deleted_by") or "system"
|
||||
entity.soft_delete(deleted_by_user=deleted_by)
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"{entity_type} успешно удален",
|
||||
"deleted_at": entity.deleted_at.isoformat() if entity.deleted_at else None,
|
||||
"deleted_by": entity.deleted_by,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<string:entity_type>/<string:entity_id>/restore")
|
||||
@require_auth
|
||||
def restore_entity(entity_type: str, entity_id: str):
|
||||
model = _model_by_entity_type(entity_type)
|
||||
if model is None:
|
||||
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
|
||||
entity = _resolve_entity(model, entity_id)
|
||||
if entity is None:
|
||||
return _error("Объект не найден", 404)
|
||||
if not hasattr(entity, "is_deleted"):
|
||||
return _error("Восстановление не поддерживается", 400)
|
||||
if not entity.is_deleted:
|
||||
return _error("Объект не удален", 400)
|
||||
entity.is_deleted = False
|
||||
entity.deleted_at = None
|
||||
entity.deleted_by = None
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "message": f"{entity_type} успешно восстановлен"})
|
||||
|
||||
|
||||
@bp.get("/<string:entity_type>/deleted")
|
||||
@require_auth
|
||||
def deleted_entities(entity_type: str):
|
||||
model = _model_by_entity_type(entity_type)
|
||||
if model is None:
|
||||
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
|
||||
if not hasattr(model, "is_deleted"):
|
||||
return jsonify([])
|
||||
rows = db.session.execute(select(model).where(model.is_deleted.is_(True))).scalars().all()
|
||||
payload = []
|
||||
for r in rows:
|
||||
payload.append(
|
||||
{
|
||||
"id": getattr(r, "id", None),
|
||||
"name": getattr(r, "name", "N/A"),
|
||||
"is_deleted": getattr(r, "is_deleted", False),
|
||||
"deleted_at": r.deleted_at.isoformat() if getattr(r, "deleted_at", None) else None,
|
||||
"deleted_by": getattr(r, "deleted_by", None),
|
||||
}
|
||||
)
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.get("/updates/check")
|
||||
@require_auth_or_paired_terminal
|
||||
def updates_check():
|
||||
return jsonify(force_check_updates())
|
||||
|
||||
|
||||
@bp.post("/updates/install")
|
||||
@require_auth_or_paired_terminal
|
||||
def updates_install():
|
||||
body, code = install_pending_update()
|
||||
return jsonify(body), code
|
||||
|
||||
|
||||
@bp.get("/updates/status")
|
||||
@require_auth_or_paired_terminal
|
||||
def updates_status():
|
||||
return jsonify(build_updates_status_payload())
|
||||
|
||||
|
||||
@bp.get("/health")
|
||||
def health_check():
|
||||
from app.services.update_health import build_health_payload
|
||||
|
||||
payload = build_health_payload(current_app)
|
||||
code = 200 if payload.get("ok") else 503
|
||||
return jsonify(payload), code
|
||||
|
||||
|
||||
@bp.get("/server/info")
|
||||
def server_info():
|
||||
ver = current_app.config.get("SYNC_CLIENT_VERSION", "1.0.0")
|
||||
is_master = bool(current_app.config.get("SYNC_CLIENT_IS_MASTER", True))
|
||||
return jsonify(
|
||||
{
|
||||
"system": "wesp",
|
||||
"version": ver,
|
||||
"role": "server",
|
||||
"status": "active",
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"api_version": "1.0",
|
||||
"is_master": is_master,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/config/update_server_url")
|
||||
@require_auth
|
||||
def update_server_url():
|
||||
new_url = (request.args.get("url") or "").strip()
|
||||
if not new_url:
|
||||
return jsonify({"status": "error", "message": "Не указан параметр url"}), 400
|
||||
if not (new_url.startswith("http://") or new_url.startswith("https://")):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "url должен начинаться с http:// или https://",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
normalized = new_url.rstrip("/") + "/"
|
||||
write_sync_client_state(
|
||||
{
|
||||
"server_url": normalized,
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "URL сервера обновлён",
|
||||
"server_url": normalized,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from flask import Blueprint, jsonify, request, session
|
||||
|
||||
from app.routes.auth_decorators import require_auth
|
||||
from app.services.notification_center_service import (
|
||||
create_notification,
|
||||
get_notification,
|
||||
list_notifications,
|
||||
mark_all_read,
|
||||
mark_read,
|
||||
unread_count,
|
||||
)
|
||||
|
||||
bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
def notifications_list():
|
||||
if request.args.get("summary") in ("1", "true", "yes"):
|
||||
return jsonify(unread_count()), 200
|
||||
sort = request.args.get("sort", "newest")
|
||||
category = request.args.get("category") or None
|
||||
unread_only = request.args.get("unread") in ("1", "true", "yes")
|
||||
day = request.args.get("date") or None
|
||||
return jsonify(
|
||||
list_notifications(
|
||||
date=day,
|
||||
category=category,
|
||||
unread_only=unread_only,
|
||||
sort=sort,
|
||||
)
|
||||
), 200
|
||||
|
||||
|
||||
@bp.get("/<string:notification_id>")
|
||||
@require_auth
|
||||
def notifications_get(notification_id: str):
|
||||
row = get_notification(notification_id)
|
||||
if row is None:
|
||||
return jsonify({"error": True, "message": "Уведомление не найдено"}), 404
|
||||
from app.services.notification_center_service import _serialize
|
||||
|
||||
return jsonify(_serialize(row)), 200
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
def notifications_create():
|
||||
data = request.get_json(silent=True) or {}
|
||||
title = data.get("title") or ""
|
||||
detail = data.get("detail") or title
|
||||
if not str(title).strip() and not str(detail).strip():
|
||||
return jsonify({"error": True, "message": "Пустое уведомление"}), 400
|
||||
row = create_notification(
|
||||
title=str(title),
|
||||
detail=str(detail),
|
||||
kind=data.get("kind") or data.get("type") or "info",
|
||||
category=data.get("category") or "general",
|
||||
page=data.get("page"),
|
||||
link_kind=data.get("linkKind") or data.get("link_kind"),
|
||||
link_id=data.get("linkId") or data.get("link_id"),
|
||||
user_login=session.get("login"),
|
||||
)
|
||||
from app.services.notification_center_service import _serialize
|
||||
|
||||
return jsonify(_serialize(row)), 201
|
||||
|
||||
|
||||
@bp.patch("/read-all")
|
||||
@require_auth
|
||||
def notifications_read_all():
|
||||
count = mark_all_read()
|
||||
return jsonify({"success": True, "marked": count}), 200
|
||||
|
||||
|
||||
@bp.patch("/<string:notification_id>/read")
|
||||
@require_auth
|
||||
def notifications_mark_read(notification_id: str):
|
||||
row = mark_read(notification_id)
|
||||
if row is None:
|
||||
return jsonify({"error": True, "message": "Уведомление не найдено"}), 404
|
||||
from app.services.notification_center_service import _serialize
|
||||
|
||||
return jsonify(_serialize(row)), 200
|
||||
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
HTML-страницы приложения (без REST под /api).
|
||||
|
||||
Откуда брать соответствие с монолитом: legacy/proga_monolith.py (@app.route для путей без префикса).
|
||||
|
||||
Куда смотреть:
|
||||
- Статика: каталог static/ (раздаётся Flask как /static/... и send_from_directory для целых страниц).
|
||||
- Шаблоны Jinja: каталог templates/ (задаётся в create_app как template_folder; звук выгрузки там же — отдаётся как /sounds/unloading.mp3).
|
||||
- Весы: app.routes.scales — общий ScalesReader через _get_reader() (тот же, что /current_weight, /stream_weight).
|
||||
- Текущий рецепт при загрузке: app.routes.legacy_misc — _runtime и _get_active_recipe() (те же, что /api/get_current_recipe).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
current_app,
|
||||
make_response,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.kiosk_device_cookie import set_kiosk_device_cookie
|
||||
from app.models import KioskDevice
|
||||
from app.routes.auth_decorators import _paired_terminal_valid, can_access_lab, is_superuser_session
|
||||
from app.routes import legacy_misc
|
||||
from app.routes.scales import _get_reader
|
||||
from app.services.hardware_settings_service import default_app_landing_path
|
||||
from app.services.setup_state import is_setup_completed
|
||||
|
||||
bp = Blueprint("pages", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SETUP_GUARD_EXACT = {
|
||||
"/setup",
|
||||
"/favicon.ico",
|
||||
"/calibration",
|
||||
"/calibrate",
|
||||
"/calibrate/continue",
|
||||
"/tare",
|
||||
"/current_weight",
|
||||
"/current_raw_data",
|
||||
"/stream_weight",
|
||||
}
|
||||
_SETUP_GUARD_PREFIXES = (
|
||||
"/static/",
|
||||
"/api/setup",
|
||||
"/api/health",
|
||||
"/api/startup/",
|
||||
"/api/kiosk/",
|
||||
"/starting",
|
||||
)
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def _setup_wizard_guard():
|
||||
if is_setup_completed(current_app):
|
||||
return None
|
||||
if current_app.config.get("TESTING") and current_app.config.get(
|
||||
"WESP_TESTING_BYPASS_SETUP_GUARD"
|
||||
):
|
||||
return None
|
||||
path = request.path or ""
|
||||
if path in _SETUP_GUARD_EXACT:
|
||||
return None
|
||||
for prefix in _SETUP_GUARD_PREFIXES:
|
||||
if path.startswith(prefix):
|
||||
return None
|
||||
return redirect(url_for("pages.setup_page"))
|
||||
|
||||
|
||||
def _static_dir() -> str:
|
||||
if current_app.static_folder:
|
||||
return current_app.static_folder
|
||||
return str(Path(current_app.root_path).parent / "static")
|
||||
|
||||
|
||||
def _templates_dir() -> str:
|
||||
"""Каталог templates/ в корне проекта (Jinja + вспомогательные файлы вроде звука)."""
|
||||
return str(Path(current_app.root_path).parent / "templates")
|
||||
|
||||
|
||||
def _no_cache(response):
|
||||
"""Заголовки против кеширования (как в монолите для feed_dispensers)."""
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
|
||||
def _send_static_html(filename: str):
|
||||
"""Отдать HTML из static/ без direct_passthrough (для favicon и прочих вставок в <head>)."""
|
||||
path = Path(_static_dir()) / filename
|
||||
html = path.read_text(encoding="utf-8")
|
||||
return make_response(html)
|
||||
|
||||
|
||||
# Киоск/legacy иногда открывают HTML напрямую из /static/ — отдаём через inject, не send_file.
|
||||
_STATIC_HTML_ALIASES = {
|
||||
"/static/login.html": "login.html",
|
||||
"/static/unauthorized.html": "unauthorized.html",
|
||||
"/static/recipes_selection.html": "recipes_selection.html",
|
||||
"/static/unloading.html": "unloading.html",
|
||||
"/static/startup-loading.html": "startup-loading.html",
|
||||
}
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def _serve_allowlisted_static_html():
|
||||
filename = _STATIC_HTML_ALIASES.get(request.path or "")
|
||||
if not filename:
|
||||
return None
|
||||
return _send_static_html(filename)
|
||||
|
||||
|
||||
def _safe_next_path(raw: str | None) -> str | None:
|
||||
"""Разрешить только относительный путь на этом же origin (без open redirect)."""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return None
|
||||
s = raw.strip()
|
||||
if not s.startswith("/"):
|
||||
return None
|
||||
if s.startswith("//"):
|
||||
return None
|
||||
if "://" in s:
|
||||
return None
|
||||
if "\x00" in s or "\r" in s or "\n" in s:
|
||||
return None
|
||||
path_only = s.split("?", 1)[0]
|
||||
if "@" in path_only:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _current_path_for_next() -> str:
|
||||
"""Путь + query для параметра next при редиректе на логин."""
|
||||
qs = request.query_string.decode("utf-8", errors="replace")
|
||||
if qs:
|
||||
return request.path + "?" + qs
|
||||
return request.path
|
||||
|
||||
|
||||
def _require_zootech_session():
|
||||
"""Если нет сессии администратора — редирект на /login с безопасным next."""
|
||||
if session.get("authenticated"):
|
||||
return None
|
||||
safe = _safe_next_path(_current_path_for_next())
|
||||
if safe:
|
||||
return redirect(url_for("pages.login_page", next=safe))
|
||||
return redirect(url_for("pages.login_page"))
|
||||
|
||||
|
||||
def _require_superuser_for_admin_page():
|
||||
if is_superuser_session():
|
||||
return None
|
||||
return redirect(url_for("pages.recipes_page"))
|
||||
|
||||
|
||||
def _require_lab_access_page():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
if not can_access_lab():
|
||||
return redirect(url_for("pages.recipes_page"))
|
||||
return None
|
||||
|
||||
|
||||
KIOSK_PAGE_PATHS = {
|
||||
"/scales",
|
||||
"/calibration",
|
||||
"/db_check",
|
||||
"/component_loader",
|
||||
"/duplicate",
|
||||
"/unloading",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_kiosk_cookie(response):
|
||||
"""Assign persistent kiosk device cookie and upsert terminal row."""
|
||||
if request.path not in KIOSK_PAGE_PATHS:
|
||||
return response
|
||||
|
||||
device_id = (request.cookies.get("wesp_kiosk_device_id") or "").strip()
|
||||
new_cookie = False
|
||||
if not device_id:
|
||||
device_id = str(uuid.uuid4())
|
||||
new_cookie = True
|
||||
|
||||
row = db.session.execute(select(KioskDevice).where(KioskDevice.id == device_id)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = KioskDevice(
|
||||
id=device_id,
|
||||
display_name=f"terminal-{device_id[:8]}",
|
||||
status="pending",
|
||||
last_ip=request.remote_addr,
|
||||
last_seen_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.last_ip = request.remote_addr
|
||||
row.last_seen_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
if new_cookie:
|
||||
logger.info(
|
||||
"[KIOSK-COOKIE] новый терминал: device_id=%s path=%s host=%s remote=%s",
|
||||
device_id,
|
||||
request.path,
|
||||
request.host,
|
||||
request.remote_addr,
|
||||
)
|
||||
set_kiosk_device_cookie(response, device_id)
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/kiosk/start/<slug>")
|
||||
def kiosk_start(slug: str):
|
||||
"""Start URL для Fully Kiosk Browser: cookie + active + редирект на весы."""
|
||||
slug = (slug or "").strip()
|
||||
if not slug:
|
||||
# На Raspberry Pi (localhost kiosk boot) иногда всплывает пустой/старый start URL.
|
||||
# Чтобы киоск не «залипал» на 404, мягко уводим на /starting.
|
||||
if _is_localhost_request():
|
||||
logger.warning(
|
||||
"[KIOSK] start URL: пустой slug (localhost) → redirect /starting remote=%s ua=%s",
|
||||
request.remote_addr,
|
||||
(request.headers.get("User-Agent") or "")[:120],
|
||||
)
|
||||
return redirect(
|
||||
url_for("pages.starting_page") + f"?next={default_app_landing_path()}",
|
||||
code=302,
|
||||
)
|
||||
logger.warning(
|
||||
"[KIOSK] start URL: пустой slug remote=%s ua=%s",
|
||||
request.remote_addr,
|
||||
(request.headers.get("User-Agent") or "")[:120],
|
||||
)
|
||||
return "Ссылка не найдена", 404
|
||||
|
||||
device = db.session.execute(
|
||||
select(KioskDevice).where(KioskDevice.access_slug == slug)
|
||||
).scalar_one_or_none()
|
||||
if not device or device.status == "revoked":
|
||||
logger.warning(
|
||||
"[KIOSK] start URL: неизвестный или отозванный slug=%s remote=%s",
|
||||
slug[:12] + "…" if len(slug) > 12 else slug,
|
||||
request.remote_addr,
|
||||
)
|
||||
if _is_localhost_request():
|
||||
logger.warning(
|
||||
"[KIOSK] start URL invalid (localhost) → redirect /starting slug=%s remote=%s",
|
||||
slug[:12] + "…" if len(slug) > 12 else slug,
|
||||
request.remote_addr,
|
||||
)
|
||||
return redirect(
|
||||
url_for("pages.starting_page") + f"?next={default_app_landing_path()}",
|
||||
code=302,
|
||||
)
|
||||
return "Ссылка не найдена", 404
|
||||
|
||||
device.status = "active"
|
||||
device.last_seen_at = datetime.utcnow()
|
||||
device.last_ip = request.remote_addr
|
||||
db.session.commit()
|
||||
|
||||
response = make_response(redirect(url_for("pages.scales")))
|
||||
set_kiosk_device_cookie(response, device.id)
|
||||
logger.info(
|
||||
"[KIOSK] start URL OK: device_id=%s remote=%s",
|
||||
device.id,
|
||||
request.remote_addr,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _is_localhost_request() -> bool:
|
||||
host = (request.host.split(":")[0] if request.host else "").lower()
|
||||
remote = (request.remote_addr or "").lower()
|
||||
localhost_hosts = {"localhost", "127.0.0.1", "::1"}
|
||||
return host in localhost_hosts or remote in localhost_hosts
|
||||
|
||||
|
||||
def _apply_kiosk_guard(response):
|
||||
"""Set kiosk cookie/device and redirect unpaired non-localhost terminals."""
|
||||
response = _ensure_kiosk_cookie(response)
|
||||
if request.path == "/calibration" and not is_setup_completed(current_app):
|
||||
return response
|
||||
if request.path not in KIOSK_PAGE_PATHS:
|
||||
return response
|
||||
if _is_localhost_request():
|
||||
return response
|
||||
if not bool(current_app.config.get("KIOSK_ENFORCE_PAIRED_ONLY", True)):
|
||||
return response
|
||||
if _paired_terminal_valid():
|
||||
return response
|
||||
|
||||
did = (request.cookies.get("wesp_kiosk_device_id") or "").strip()
|
||||
logger.warning(
|
||||
"[KIOSK-GUARD] непривязанный терминал → redirect unauthorized: device_id=%s next=%s host=%s remote=%s",
|
||||
did or "(нет cookie)",
|
||||
request.path,
|
||||
request.host,
|
||||
request.remote_addr,
|
||||
)
|
||||
target = url_for("pages.unauthorized_device_page", next=request.path)
|
||||
guarded_response = make_response(redirect(target))
|
||||
return _ensure_kiosk_cookie(guarded_response)
|
||||
|
||||
|
||||
# --- URL как в монолите: файл templates/unloading.mp3 ---
|
||||
|
||||
|
||||
@bp.route("/sounds/unloading.mp3")
|
||||
def unloading_sound():
|
||||
return send_from_directory(
|
||||
_templates_dir(),
|
||||
"unloading.mp3",
|
||||
mimetype="audio/mpeg",
|
||||
)
|
||||
|
||||
|
||||
# --- Только файлы из static/ (одна HTML-страница = один файл) ---
|
||||
|
||||
|
||||
@bp.route("/favicon.ico")
|
||||
def favicon():
|
||||
return send_from_directory(
|
||||
_static_dir(),
|
||||
"favicon-32.png",
|
||||
mimetype="image/png",
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
return _send_static_html("login.html")
|
||||
|
||||
|
||||
@bp.route("/login")
|
||||
def login_page():
|
||||
return _send_static_html("login.html")
|
||||
|
||||
|
||||
@bp.route("/components")
|
||||
def components_page():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
return _send_static_html("components.html")
|
||||
|
||||
|
||||
@bp.route("/lab")
|
||||
def lab_sandbox_page():
|
||||
guard = _require_lab_access_page()
|
||||
if guard is not None:
|
||||
return guard
|
||||
return _send_static_html("lab.html")
|
||||
|
||||
|
||||
@bp.route("/lab/profiles")
|
||||
def lab_profiles_page():
|
||||
guard = _require_lab_access_page()
|
||||
if guard is not None:
|
||||
return guard
|
||||
return _send_static_html("lab-profiles.html")
|
||||
|
||||
|
||||
@bp.route("/api/recipes_wibor")
|
||||
@bp.route("/recipes_selection")
|
||||
def recipes_wibor_page():
|
||||
# /api/recipes_wibor — URL из старых клиентов; /recipes_selection — то же UI, короткий путь.
|
||||
# Не требует сессии зоотехника: открывается с киоска (/scales и т.д.), доступ к API — по paired terminal / legacy guards.
|
||||
return _send_static_html("recipes_selection.html")
|
||||
|
||||
|
||||
@bp.route("/recipes")
|
||||
def recipes_page():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
return _send_static_html("recipes.html")
|
||||
|
||||
|
||||
@bp.route("/recipes_content")
|
||||
def recipes_content():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
return _send_static_html("recipes.html")
|
||||
|
||||
|
||||
@bp.route("/unloading")
|
||||
def unloading_page():
|
||||
response = _send_static_html("unloading.html")
|
||||
return _apply_kiosk_guard(response)
|
||||
|
||||
|
||||
@bp.route("/reports")
|
||||
def reports_page():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
return _send_static_html("reports.html")
|
||||
|
||||
|
||||
@bp.route("/feed_consumption")
|
||||
def feed_consumption_page():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
return _send_static_html("consumption.html")
|
||||
|
||||
|
||||
@bp.route("/feed_dispensers")
|
||||
def feed_dispensers_page():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
response = _send_static_html("feed_dispensers.html")
|
||||
return _no_cache(response)
|
||||
|
||||
|
||||
@bp.route("/admin")
|
||||
def admin_page():
|
||||
guard = _require_zootech_session()
|
||||
if guard is not None:
|
||||
return guard
|
||||
super_guard = _require_superuser_for_admin_page()
|
||||
if super_guard is not None:
|
||||
return super_guard
|
||||
response = _send_static_html("admin.html")
|
||||
return _no_cache(response)
|
||||
|
||||
|
||||
@bp.route("/unauthorized-device")
|
||||
def unauthorized_device_page():
|
||||
# Редирект сюда при MAC-lock в монолите; отдельно ответы API — 403 JSON
|
||||
return _send_static_html("unauthorized.html"), 403
|
||||
|
||||
|
||||
@bp.route("/starting")
|
||||
def starting_page():
|
||||
"""Экран загрузки, пока в фоне идут миграции и инициализация."""
|
||||
return _send_static_html("startup-loading.html")
|
||||
|
||||
|
||||
@bp.route("/setup")
|
||||
def setup_page():
|
||||
return _send_static_html("setup.html")
|
||||
|
||||
|
||||
@bp.route("/logo-loader-demo")
|
||||
def logo_loader_demo_page():
|
||||
"""Превью пиксельной анимации логотипа (экран загрузки)."""
|
||||
return _send_static_html("logo-loader-demo.html")
|
||||
|
||||
|
||||
# --- Шаблоны templates/ + данные с бэкенда ---
|
||||
|
||||
|
||||
@bp.route("/scales")
|
||||
def scales():
|
||||
# Вес в интерфейсе тянет SSE с /stream_weight, обнуление — POST /tare (blueprint scales)
|
||||
response = make_response(render_template("desktop_index.html"))
|
||||
return _apply_kiosk_guard(response)
|
||||
|
||||
|
||||
@bp.route("/calibration")
|
||||
def calibration():
|
||||
# Тот же ScalesReader, что и маршруты в app.routes.scales
|
||||
embed = request.args.get("embed") == "1"
|
||||
setup_mode = not is_setup_completed(current_app)
|
||||
reader = _get_reader()
|
||||
current_weight = reader.get_current_weight()
|
||||
factor = reader.get_calibration_factor()
|
||||
raw = reader.calibration_debug_payload()
|
||||
stream_w = current_weight
|
||||
response = make_response(render_template(
|
||||
"calibration.html",
|
||||
embed=embed,
|
||||
setup_mode=setup_mode,
|
||||
dispenser_name="",
|
||||
current_weight=int(current_weight),
|
||||
calibration_factor=round(factor, 2),
|
||||
stream_weight=int(stream_w),
|
||||
raw_data=json.dumps(raw, indent=2),
|
||||
))
|
||||
return _apply_kiosk_guard(response)
|
||||
|
||||
|
||||
@bp.route("/db_check")
|
||||
def db_check_page():
|
||||
# Диагностика БД в UI; при необходимости дополняется вызовами /api/sync/check-db и т.д. из фронта
|
||||
embed = request.args.get("embed") == "1"
|
||||
response = make_response(render_template("db_check.html", embed=embed))
|
||||
return _apply_kiosk_guard(response)
|
||||
|
||||
|
||||
@bp.route("/component_loader")
|
||||
def component_loader():
|
||||
# Второй экран «только загрузка»; живые данные в монолите шли из глобального состояния — здесь стартовые заглушки.
|
||||
# Для обновления в реальном времени фронт может использовать /api/weight_display_data (legacy_misc).
|
||||
response = make_response(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"),
|
||||
))
|
||||
return _apply_kiosk_guard(response)
|
||||
|
||||
|
||||
@bp.route("/duplicate")
|
||||
def duplicate_selection_page():
|
||||
# Дублёр экрана выбора рецепта; основная логика на клиенте ходит в /api/* из legacy_misc
|
||||
try:
|
||||
snap = legacy_misc._runtime.snapshot()
|
||||
current_recipe = legacy_misc._get_active_recipe(snap["current_recipe_id"])
|
||||
response = make_response(render_template(
|
||||
"recipes_selection_duplicate.html",
|
||||
current_recipe=current_recipe,
|
||||
))
|
||||
return _apply_kiosk_guard(response)
|
||||
except Exception:
|
||||
response = make_response(render_template(
|
||||
"recipes_selection_duplicate.html",
|
||||
current_recipe=None,
|
||||
))
|
||||
return _apply_kiosk_guard(response)
|
||||
@@ -0,0 +1,220 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app import db
|
||||
from app.models import FeedingPeriod, Ingredient, PeriodRecipe, Recipe, UnloadingGroup
|
||||
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal
|
||||
from app.services.period_recipes_query import recipes_for_period_ordered
|
||||
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
|
||||
from app.services.daily_plan.replacements import get_replaced_recipe_ids
|
||||
from app.services.daily_plan.builder import recipe_total_weights_by_id
|
||||
from app.services.daily_plan.skips import (
|
||||
filter_recipes_for_list_view,
|
||||
get_skipped_ingredient_ids,
|
||||
get_skipped_unloading_group_ids,
|
||||
is_kiosk_recipe_list_request,
|
||||
is_zootech_recipe_list_view,
|
||||
)
|
||||
from app.routes.recipes import (
|
||||
_group_distribution_type,
|
||||
_ingredient_dry_matter_per_head,
|
||||
_ingredient_weight_per_head,
|
||||
_unloading_groups_from_payload,
|
||||
)
|
||||
|
||||
bp = Blueprint("periods", __name__, url_prefix="/api/periods")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
def _serialize_period_recipe(
|
||||
recipe: Recipe,
|
||||
*,
|
||||
total_weight: float | None = None,
|
||||
skipped_today: bool = False,
|
||||
skipped_ingredient_today: bool = False,
|
||||
skipped_group_today: bool = False,
|
||||
adjusted_ingredient_today: bool = False,
|
||||
replaced_ingredient_today: bool = False,
|
||||
):
|
||||
payload = {
|
||||
"id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"heads_count": recipe.heads_per_trip,
|
||||
"mixing_time": recipe.mixing_time,
|
||||
"trip_percent": recipe.trip_percent,
|
||||
}
|
||||
if total_weight is not None:
|
||||
payload["total_weight"] = total_weight
|
||||
if is_zootech_recipe_list_view():
|
||||
payload["skippedToday"] = skipped_today
|
||||
payload["skippedIngredientToday"] = skipped_ingredient_today
|
||||
payload["skippedGroupToday"] = skipped_group_today
|
||||
payload["adjustedIngredientToday"] = adjusted_ingredient_today
|
||||
payload["replacedIngredientToday"] = replaced_ingredient_today
|
||||
return payload
|
||||
|
||||
|
||||
@bp.get("/<string:period_id>/recipes")
|
||||
@require_auth_or_paired_terminal
|
||||
def period_recipes_list(period_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id, FeedingPeriod.is_deleted.is_(False)
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
|
||||
recipes = recipes_for_period_ordered(period_id)
|
||||
visible, skipped_today = filter_recipes_for_list_view(recipes)
|
||||
skip_ings = get_skipped_ingredient_ids()
|
||||
skip_grps = get_skipped_unloading_group_ids()
|
||||
adj_ids = get_adjusted_recipe_ids()
|
||||
repl_ids = get_replaced_recipe_ids()
|
||||
plan_weights: dict[str, float] = {}
|
||||
if is_kiosk_recipe_list_request():
|
||||
plan_weights = recipe_total_weights_by_id(dispenser_id=period.dispenser_id)
|
||||
|
||||
def _row(recipe: Recipe) -> dict:
|
||||
return _serialize_period_recipe(
|
||||
recipe,
|
||||
total_weight=plan_weights.get(recipe.id),
|
||||
skipped_today=recipe.id in skipped_today,
|
||||
skipped_ingredient_today=bool(skip_ings.get(recipe.id)),
|
||||
skipped_group_today=bool(skip_grps.get(recipe.id)),
|
||||
adjusted_ingredient_today=recipe.id in adj_ids,
|
||||
replaced_ingredient_today=recipe.id in repl_ids,
|
||||
)
|
||||
|
||||
return jsonify([_row(r) for r in visible])
|
||||
|
||||
|
||||
@bp.post("/<string:period_id>/recipes")
|
||||
@require_auth
|
||||
def period_recipes_add(period_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id, FeedingPeriod.is_deleted.is_(False)
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
recipe_id = data.get("recipe_id")
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] period_recipes_add period_id=%s dispenser_id=%s attach_recipe_id=%s "
|
||||
"path=%s remote=%s",
|
||||
period_id,
|
||||
period.dispenser_id,
|
||||
recipe_id or "(new)",
|
||||
request.path,
|
||||
request.remote_addr,
|
||||
)
|
||||
|
||||
if recipe_id:
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not recipe:
|
||||
return _error("Рецепт не найден", 404)
|
||||
else:
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return _error("Название рецепта обязательно", 400)
|
||||
|
||||
recipe = Recipe(
|
||||
name=name,
|
||||
heads_per_trip=int(data.get("heads_count", data.get("headsPerTrip", 1))),
|
||||
mixing_time=int(data.get("mixing_time", data.get("mixingTime", 0))),
|
||||
trip_percent=float(data.get("trip_percent", data.get("tripPercent", 100))),
|
||||
dry_matter_locked=bool(
|
||||
data.get("dry_matter_locked", data.get("dryMatterLocked", 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",
|
||||
)
|
||||
db.session.add(recipe)
|
||||
db.session.flush()
|
||||
|
||||
for idx, ing in enumerate(data.get("ingredients", []) or [], start=1):
|
||||
ingredient = Ingredient(
|
||||
name=ing.get("name", ""),
|
||||
weight_per_head=_ingredient_weight_per_head(ing),
|
||||
amount=float(ing.get("amount", 0) or 0),
|
||||
dry_matter=float(ing.get("dry_matter", 0) or 0),
|
||||
dry_matter_per_head=_ingredient_dry_matter_per_head(ing),
|
||||
component_id=ing.get("component_id"),
|
||||
order=idx,
|
||||
recipe_id=recipe.id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(ingredient)
|
||||
|
||||
for idx, group in enumerate(_unloading_groups_from_payload(data), start=1):
|
||||
unloading_group = UnloadingGroup(
|
||||
name=group.get("name", ""),
|
||||
distribution_type=_group_distribution_type(group),
|
||||
value=float(group.get("value", 0) or 0),
|
||||
weight=float(group["weight"]) if group.get("weight") not in (None, "") else None,
|
||||
order=int(group.get("order", idx)),
|
||||
recipe_id=recipe.id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(unloading_group)
|
||||
|
||||
existing = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == period.id,
|
||||
PeriodRecipe.recipe_id == recipe.id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] period_recipes_add period_id=%s recipe_id=%s already_linked",
|
||||
period.id,
|
||||
recipe.id,
|
||||
)
|
||||
return jsonify({"success": True, "id": recipe.id, "message": "Рецепт уже в периоде"})
|
||||
|
||||
max_order = db.session.scalar(
|
||||
select(func.max(PeriodRecipe.order)).where(
|
||||
PeriodRecipe.period_id == period.id, PeriodRecipe.is_deleted.is_(False)
|
||||
)
|
||||
) or 0
|
||||
link = PeriodRecipe(
|
||||
period_id=period.id,
|
||||
recipe_id=recipe.id,
|
||||
order=max_order + 1,
|
||||
created_at=datetime.now(),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] period_recipes_add period_id=%s dispenser_id=%s recipe_id=%s "
|
||||
"period_recipe_order=%s new_recipe=%s",
|
||||
period.id,
|
||||
period.dispenser_id,
|
||||
recipe.id,
|
||||
link.order,
|
||||
not bool(recipe_id),
|
||||
)
|
||||
return jsonify({"success": True, "id": recipe.id, "message": "Рецепт добавлен в период"}), 201
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
from sqlalchemy import exists, select, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
Component,
|
||||
FeedDispenser,
|
||||
FeedingPeriod,
|
||||
Ingredient,
|
||||
PeriodRecipe,
|
||||
Recipe,
|
||||
UnloadingGroup,
|
||||
)
|
||||
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal, require_paired_terminal
|
||||
from app.services.daily_plan.builder import trip_overlay_for_recipe
|
||||
from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights
|
||||
from app.services.daily_plan.adjustments import get_component_adjustment_map
|
||||
from app.services.daily_plan.replacements import get_ingredient_replacement_map
|
||||
from app.services.daily_plan.skips import (
|
||||
get_skipped_ingredient_ids,
|
||||
get_skipped_unloading_group_ids,
|
||||
is_kiosk_recipe_list_request,
|
||||
)
|
||||
from app.services.recipe_calculator import calculate_recipe
|
||||
from app.services.recipe_update_service import RecipeUpdateError, update_recipe_from_payload
|
||||
from app.services.sync_manager import _is_sqlite_lock_message, db_commit_with_retry, enqueue_sync_queue_task
|
||||
from app.services.sync_content_hash import compute_content_hash_hex, stable_payload_for_hash
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("recipes", __name__, url_prefix="/api/recipes")
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def recipes_ping():
|
||||
"""Health-check для модуля рецептов."""
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
def _add_no_cache_headers(response):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
def _ingredient_weight_per_head(ing: dict) -> float:
|
||||
"""Как в recipes.html: приходит weight_per_head или weightPerHead."""
|
||||
for key in ("weightPerHead", "weight_per_head"):
|
||||
if key in ing and ing[key] is not None and ing[key] != "":
|
||||
try:
|
||||
return float(ing[key])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
|
||||
def _ingredient_dry_matter_per_head(ing: dict):
|
||||
if not ing:
|
||||
return None
|
||||
for key in ("dryMatterPerHead", "dry_matter_per_head"):
|
||||
if key in ing and ing[key] is not None and ing[key] != "":
|
||||
try:
|
||||
return float(ing[key])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _group_distribution_type(group: dict) -> str:
|
||||
return group.get("distributionType") or group.get("distribution_type") or "percent"
|
||||
|
||||
|
||||
def _unloading_groups_from_payload(data: dict) -> list:
|
||||
raw = data.get("unloadingGroups") or data.get("unloading_groups") or []
|
||||
return [g for g in raw if isinstance(g, dict)]
|
||||
|
||||
|
||||
def _serialize_recipe(
|
||||
recipe: Recipe, *, plan_date: str | None = None, exclude_skipped: bool = False
|
||||
):
|
||||
from datetime import date as date_cls
|
||||
|
||||
apply_overlay = plan_date is not None
|
||||
resolved_date = None
|
||||
if apply_overlay:
|
||||
try:
|
||||
resolved_date = date_cls.fromisoformat(str(plan_date).strip()[:10]).isoformat()
|
||||
except ValueError:
|
||||
apply_overlay = False
|
||||
|
||||
skipped_ingredient_ids: set[str] = set()
|
||||
skipped_group_ids: set[str] = set()
|
||||
replacement_map: dict[str, str] = {}
|
||||
component_adjustment_map: dict[str, dict] = {}
|
||||
if apply_overlay and resolved_date:
|
||||
skipped_ing_by_recipe = get_skipped_ingredient_ids(resolved_date)
|
||||
skipped_grp_by_recipe = get_skipped_unloading_group_ids(resolved_date)
|
||||
skipped_ingredient_ids = skipped_ing_by_recipe.get(recipe.id, set())
|
||||
skipped_group_ids = skipped_grp_by_recipe.get(recipe.id, set())
|
||||
replacement_map = get_ingredient_replacement_map(resolved_date).get(recipe.id, {})
|
||||
component_adjustment_map = get_component_adjustment_map(resolved_date)
|
||||
|
||||
kiosk_view = is_kiosk_recipe_list_request()
|
||||
hide_skipped = kiosk_view or exclude_skipped
|
||||
trip_overlay = None
|
||||
if apply_overlay and resolved_date:
|
||||
trip_overlay = trip_overlay_for_recipe(recipe, resolved_date)
|
||||
trip_ing_by_id = {
|
||||
row["id"]: row for row in (trip_overlay or {}).get("ingredients") or []
|
||||
}
|
||||
trip_grp_by_id = {
|
||||
row["id"]: row for row in (trip_overlay or {}).get("unloadingGroups") or []
|
||||
}
|
||||
|
||||
ingredients = db.session.execute(
|
||||
select(Ingredient)
|
||||
.where(Ingredient.recipe_id == recipe.id, Ingredient.is_deleted.is_(False))
|
||||
.order_by(Ingredient.order.asc())
|
||||
).scalars().all()
|
||||
comp_ids = [i.component_id for i in ingredients if i.component_id]
|
||||
comp_ids.extend(replacement_map.values())
|
||||
components_by_id: dict[str, Component] = {}
|
||||
comp_name_by_id: dict[str, str] = {}
|
||||
if comp_ids:
|
||||
comps = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(set(comp_ids)),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
for c in comps:
|
||||
components_by_id[c.id] = c
|
||||
comp_name_by_id[c.id] = c.name
|
||||
|
||||
def _ingredient_row_name(ing: Ingredient) -> str:
|
||||
raw = (ing.name or "").strip()
|
||||
if raw:
|
||||
base = raw
|
||||
elif ing.component_id and ing.component_id in comp_name_by_id:
|
||||
base = comp_name_by_id[ing.component_id]
|
||||
else:
|
||||
base = "—"
|
||||
repl_id = replacement_map.get(ing.id)
|
||||
if repl_id and repl_id in comp_name_by_id:
|
||||
return f"{base} → {comp_name_by_id[repl_id]}"
|
||||
return base
|
||||
|
||||
def _ingredient_payload(i: Ingredient) -> dict:
|
||||
repl_id = replacement_map.get(i.id) if apply_overlay else None
|
||||
heads = int(recipe.heads_per_trip or 0)
|
||||
component_adj = (
|
||||
component_adjustment_map.get(str(i.component_id)) if apply_overlay and i.component_id else None
|
||||
)
|
||||
trip_row = trip_ing_by_id.get(i.id) if apply_overlay else None
|
||||
if apply_overlay:
|
||||
weights = resolve_plan_ingredient_weights(
|
||||
i,
|
||||
recipe,
|
||||
heads=heads,
|
||||
components_by_id=components_by_id,
|
||||
replacement_component_id=repl_id,
|
||||
component_adjustment=component_adj,
|
||||
)
|
||||
wph = weights["weightPerHead"]
|
||||
dm_pct = weights["dryMatterPct"]
|
||||
dm_ph = weights["dryMatterPerHead"]
|
||||
if trip_row is not None:
|
||||
amount = float(trip_row.get("totalKg") or 0)
|
||||
elif i.id in skipped_ingredient_ids:
|
||||
amount = 0.0
|
||||
else:
|
||||
amount = weights["totalKg"] if wph > 0 else i.amount
|
||||
else:
|
||||
wph = float(i.weight_per_head or 0)
|
||||
dm_pct = float(i.dry_matter or 0)
|
||||
if not dm_pct and i.component_id and i.component_id in components_by_id:
|
||||
dm_pct = float(components_by_id[i.component_id].dry_matter or 0)
|
||||
dm_ph = float(i.dry_matter_per_head or 0)
|
||||
if not dm_ph and wph > 0 and dm_pct > 0:
|
||||
dm_ph = wph * (dm_pct / 100.0)
|
||||
amount = i.amount
|
||||
weights = {}
|
||||
payload = {
|
||||
"id": i.id,
|
||||
"name": (
|
||||
trip_row.get("name")
|
||||
if trip_row and trip_row.get("name")
|
||||
else (_ingredient_row_name(i) if apply_overlay else (
|
||||
(i.name or "").strip()
|
||||
or (comp_name_by_id.get(i.component_id) if i.component_id else "")
|
||||
or "—"
|
||||
))
|
||||
),
|
||||
"weightPerHead": wph,
|
||||
"weight_per_head": wph,
|
||||
"amount": amount,
|
||||
"dry_matter": dm_pct,
|
||||
"dry_matter_per_head": dm_ph,
|
||||
"order": i.order,
|
||||
"component_id": (repl_id or i.component_id) if apply_overlay else i.component_id,
|
||||
"original_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,
|
||||
}
|
||||
if apply_overlay and not kiosk_view:
|
||||
payload["replacedToday"] = bool(repl_id)
|
||||
payload["adjustedToday"] = bool(weights.get("adjustedToday"))
|
||||
if repl_id:
|
||||
payload["recalculationMode"] = weights.get("recalculationMode")
|
||||
if repl_id or weights.get("adjustedToday"):
|
||||
payload["originalWeightPerHead"] = weights.get("originalWeightPerHead")
|
||||
payload["originalDryMatterPerHead"] = weights.get("originalDryMatterPerHead")
|
||||
payload["originalDryMatterPct"] = weights.get("originalDryMatterPct")
|
||||
if i.id in skipped_ingredient_ids:
|
||||
payload["skippedToday"] = True
|
||||
return payload
|
||||
|
||||
groups = db.session.execute(
|
||||
select(UnloadingGroup)
|
||||
.where(UnloadingGroup.recipe_id == recipe.id, UnloadingGroup.is_deleted.is_(False))
|
||||
.order_by(UnloadingGroup.order.asc())
|
||||
).scalars().all()
|
||||
|
||||
def _group_plan_weight(group: UnloadingGroup) -> float | None:
|
||||
if not apply_overlay:
|
||||
return None
|
||||
trip_group = trip_grp_by_id.get(group.id)
|
||||
if trip_group is None:
|
||||
return None
|
||||
return float(trip_group.get("weightKg") or 0)
|
||||
|
||||
return {
|
||||
"id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"headsPerTrip": recipe.heads_per_trip,
|
||||
"mixingTime": recipe.mixing_time,
|
||||
"tripPercent": recipe.trip_percent,
|
||||
# Синонимы в snake_case — форма /recipes и копирование рейса ожидают эти ключи
|
||||
"heads_count": recipe.heads_per_trip,
|
||||
"mixing_time": recipe.mixing_time,
|
||||
"trip_percent": recipe.trip_percent,
|
||||
"dryMatterLocked": recipe.dry_matter_locked,
|
||||
"dry_matter_locked": recipe.dry_matter_locked,
|
||||
"unloading_link_broken": recipe.unloading_link_broken,
|
||||
"unloadingLinkBroken": 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": [
|
||||
_ingredient_payload(i)
|
||||
for i in ingredients
|
||||
if not hide_skipped or i.id not in skipped_ingredient_ids
|
||||
],
|
||||
"unloadingGroups": [
|
||||
{
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"distributionType": g.distribution_type,
|
||||
"value": g.value,
|
||||
"weight": (_pw if (_pw := _group_plan_weight(g)) is not None else 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,
|
||||
**(
|
||||
{"skippedToday": True}
|
||||
if apply_overlay and not kiosk_view and g.id in skipped_group_ids
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for g in groups
|
||||
if not hide_skipped or g.id not in skipped_group_ids
|
||||
],
|
||||
# snake_case — copy/paste рейса и форма редактора
|
||||
"unloading_groups": [
|
||||
{
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"distribution_type": g.distribution_type,
|
||||
"distributionType": g.distribution_type,
|
||||
"value": g.value,
|
||||
"weight": (_pw if (_pw := _group_plan_weight(g)) is not None else g.weight),
|
||||
"order": g.order,
|
||||
**(
|
||||
{"skippedToday": True}
|
||||
if apply_overlay and not kiosk_view and g.id in skipped_group_ids
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for g in groups
|
||||
if not hide_skipped or g.id not in skipped_group_ids
|
||||
],
|
||||
**(
|
||||
{
|
||||
"total_weight": trip_overlay["totalWeightKg"],
|
||||
"totalWeightKg": trip_overlay["totalWeightKg"],
|
||||
"unloadingTotalKg": trip_overlay.get("unloadingTotalKg"),
|
||||
}
|
||||
if trip_overlay
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_paired_terminal
|
||||
def list_recipes():
|
||||
"""Список рецептов (совместим с легаси /api/recipes)."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 500))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return _error("Некорректные параметры пагинации", 400)
|
||||
|
||||
recipes = db.session.execute(
|
||||
select(Recipe)
|
||||
.where(Recipe.is_deleted.is_(False))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
response = jsonify([_serialize_recipe(r) for r in recipes])
|
||||
return _add_no_cache_headers(response)
|
||||
|
||||
|
||||
@bp.get("/<string:recipe_id>/open-context")
|
||||
@require_auth
|
||||
def recipe_open_context(recipe_id: str):
|
||||
"""Контекст для перехода к рейсу из центра уведомлений."""
|
||||
recipe = db.session.get(Recipe, recipe_id)
|
||||
if recipe is None or getattr(recipe, "is_deleted", False):
|
||||
return _error("Рецепт не найден", 404)
|
||||
|
||||
pr = db.session.execute(
|
||||
select(PeriodRecipe)
|
||||
.where(
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if pr is not None:
|
||||
period = db.session.get(FeedingPeriod, pr.period_id)
|
||||
if period is not None and not getattr(period, "is_deleted", False):
|
||||
return jsonify(
|
||||
{
|
||||
"recipeId": recipe_id,
|
||||
"dispenserId": period.dispenser_id,
|
||||
"periodId": pr.period_id,
|
||||
"isMill": False,
|
||||
}
|
||||
)
|
||||
|
||||
mill = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(
|
||||
FeedDispenser.type == "mill",
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if mill is not None:
|
||||
orphan = not db.session.execute(
|
||||
select(
|
||||
exists().where(
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
if orphan:
|
||||
return jsonify(
|
||||
{
|
||||
"recipeId": recipe_id,
|
||||
"dispenserId": mill.id,
|
||||
"periodId": None,
|
||||
"isMill": True,
|
||||
}
|
||||
)
|
||||
|
||||
return _error("Не удалось определить расположение рецепта", 404)
|
||||
|
||||
|
||||
@bp.get("/<string:recipe_id>")
|
||||
@require_auth_or_paired_terminal
|
||||
def get_recipe(recipe_id: str):
|
||||
"""Получение одного рецепта по ID."""
|
||||
recipe = db.session.get(Recipe, recipe_id)
|
||||
if recipe is None:
|
||||
return _error("Рецепт не найден", 404)
|
||||
if getattr(recipe, "is_deleted", False):
|
||||
return _error("Рецепт удалён", 404)
|
||||
plan_date = request.args.get("date")
|
||||
return jsonify(_serialize_recipe(recipe, plan_date=plan_date))
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
def create_recipe():
|
||||
"""Создание рецепта без привязки к периоду (для кормоцеха)."""
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return _error("Название рецепта обязательно", 400)
|
||||
|
||||
recipe = Recipe(
|
||||
name=name,
|
||||
heads_per_trip=int(data.get("headsPerTrip", data.get("heads_count", 1))),
|
||||
mixing_time=int(data.get("mixingTime", data.get("mixing_time", 0))),
|
||||
trip_percent=float(data.get("tripPercent", data.get("trip_percent", 100))),
|
||||
dry_matter_locked=bool(data.get("dry_matter_locked", data.get("dryMatterLocked", 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",
|
||||
)
|
||||
db.session.add(recipe)
|
||||
db.session.flush()
|
||||
|
||||
# Ингредиенты
|
||||
for idx, ing in enumerate(data.get("ingredients", []) or [], start=1):
|
||||
component = None
|
||||
comp_id = ing.get("component_id")
|
||||
if comp_id:
|
||||
component = db.session.get(Component, comp_id)
|
||||
if not component and ing.get("name"):
|
||||
component = db.session.execute(
|
||||
select(Component).where(Component.name == ing["name"])
|
||||
).scalar_one_or_none()
|
||||
if not component:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"message": (
|
||||
f'Компонент не найден (id="{comp_id or ""}", '
|
||||
f'name="{ing.get("name", "")}")'
|
||||
),
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
dmph = _ingredient_dry_matter_per_head(ing)
|
||||
ingredient = Ingredient(
|
||||
name=component.name,
|
||||
weight_per_head=_ingredient_weight_per_head(ing),
|
||||
amount=float(ing.get("amount", 0) or 0),
|
||||
dry_matter=float(ing.get("dry_matter", component.dry_matter) or 0),
|
||||
dry_matter_per_head=dmph,
|
||||
component_id=component.id,
|
||||
order=idx,
|
||||
recipe_id=recipe.id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(ingredient)
|
||||
db.session.flush()
|
||||
|
||||
for idx, group in enumerate(_unloading_groups_from_payload(data), start=1):
|
||||
unloading_group = UnloadingGroup(
|
||||
name=group.get("name", ""),
|
||||
distribution_type=_group_distribution_type(group),
|
||||
value=float(group.get("value", 0) or 0),
|
||||
weight=float(group["weight"]) if group.get("weight") not in (None, "") else None,
|
||||
order=int(group.get("order", idx)),
|
||||
recipe_id=recipe.id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(unloading_group)
|
||||
db.session.flush()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] create_recipe committed id=%s name=%s path=%s remote=%s",
|
||||
recipe.id,
|
||||
recipe.name,
|
||||
request.path,
|
||||
request.remote_addr,
|
||||
)
|
||||
|
||||
return jsonify({"message": "Рецепт добавлен", "id": recipe.id}), 201
|
||||
|
||||
|
||||
@bp.put("/<string:recipe_id>")
|
||||
@require_auth
|
||||
def update_recipe(recipe_id: str):
|
||||
"""Обновление рецепта (логика как legacy/proga_monolith.update_recipe)."""
|
||||
data = request.get_json()
|
||||
try:
|
||||
result = update_recipe_from_payload(recipe_id, data)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] update_recipe committed id=%s path=%s remote=%s",
|
||||
recipe_id,
|
||||
request.path,
|
||||
request.remote_addr,
|
||||
)
|
||||
except RecipeUpdateError as e:
|
||||
db.session.rollback()
|
||||
return _error(e.message, e.status_code)
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.exception("Ошибка при обновлении рецепта %s", recipe_id)
|
||||
return _error(str(e), 500)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": result["message"],
|
||||
"id": result["id"],
|
||||
"stats": result["stats"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.delete("/<string:recipe_id>")
|
||||
@require_auth
|
||||
def delete_recipe(recipe_id: str):
|
||||
"""Мягкое удаление рецепта (soft-delete, каскад делает событие)."""
|
||||
recipe = db.session.get(Recipe, recipe_id)
|
||||
if recipe is None:
|
||||
return _error("Рецепт не найден", 404)
|
||||
if getattr(recipe, "is_deleted", False):
|
||||
return jsonify({"success": True, "message": "Рецепт уже удалён"})
|
||||
|
||||
rid = recipe.id
|
||||
if hasattr(recipe, "soft_delete"):
|
||||
recipe.soft_delete(deleted_by_user="api")
|
||||
else:
|
||||
db.session.delete(recipe)
|
||||
|
||||
enqueue_sync_queue_task("recipe", rid, "delete", priority=1)
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] delete_recipe committed id=%s path=%s remote=%s",
|
||||
rid,
|
||||
request.path,
|
||||
request.remote_addr,
|
||||
)
|
||||
return jsonify({"success": True, "message": "Рецепт удалён"})
|
||||
|
||||
|
||||
@bp.post("/calculate")
|
||||
@require_auth
|
||||
def calculate_recipe_endpoint():
|
||||
"""Как legacy/proga_monolith.calculate_recipe_endpoint: те же ключи тела и нормализация."""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return _error("Данные не предоставлены", 400)
|
||||
|
||||
try:
|
||||
heads_count = int(
|
||||
data.get("headsCount")
|
||||
or data.get("heads_count")
|
||||
or data.get("headsPerTrip")
|
||||
or 0
|
||||
)
|
||||
trip_percent = float(
|
||||
data.get("tripPercent") or data.get("trip_percent") or 100
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return _error("Некорректные числовые параметры", 400)
|
||||
|
||||
ingredients = data.get("ingredients", []) or []
|
||||
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)
|
||||
)
|
||||
|
||||
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")
|
||||
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")
|
||||
if "weightPerHead" not in ing and "weight_per_head" in ing:
|
||||
ing["weightPerHead"] = ing.get("weight_per_head")
|
||||
normalized_ingredients.append(ing)
|
||||
ingredients = normalized_ingredients
|
||||
|
||||
normalized_groups = []
|
||||
for g in unloading_groups:
|
||||
if not isinstance(g, dict):
|
||||
continue
|
||||
g = dict(g)
|
||||
if "distributionType" not in g and "distribution_type" in g:
|
||||
g["distributionType"] = g.get("distribution_type")
|
||||
normalized_groups.append(g)
|
||||
unloading_groups = normalized_groups
|
||||
|
||||
component_ids = [i.get("component_id") for i in ingredients if i.get("component_id")]
|
||||
component_dry_matter_map = {}
|
||||
if component_ids:
|
||||
comps = db.session.execute(
|
||||
select(Component).where(Component.id.in_(component_ids))
|
||||
).scalars().all()
|
||||
component_dry_matter_map = {c.id: c.dry_matter for c in comps}
|
||||
|
||||
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 or None,
|
||||
calculate_from_dry_matter=calculate_from_dry_matter,
|
||||
)
|
||||
|
||||
def to_float2(x):
|
||||
try:
|
||||
return round(float(x), 2)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def truncate2(x):
|
||||
try:
|
||||
v = float(x)
|
||||
return float(int(v * 100)) / 100.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
if result.get("ingredients"):
|
||||
for ing in result["ingredients"]:
|
||||
for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"):
|
||||
if key in ing and ing[key] is not None:
|
||||
raw_val = ing[key]
|
||||
v = truncate2(raw_val) if key == "weightPerHead" else to_float2(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])
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _period_recipe_order_content_hash(period_id: str, recipe_id: str, order: int) -> str:
|
||||
return compute_content_hash_hex(
|
||||
stable_payload_for_hash(
|
||||
{
|
||||
"period_id": period_id,
|
||||
"recipe_id": recipe_id,
|
||||
"order": order,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_period_recipe_move(rows, recipe_id: str, req_from_index: int, req_to_index: int):
|
||||
"""rows: list of (recipe_id, order). Returns (from_index, to_index) or (None, None) if not found."""
|
||||
if not rows:
|
||||
return None, None
|
||||
|
||||
from_index = req_from_index
|
||||
if from_index < 0 or from_index >= len(rows):
|
||||
from_index = next((i for i, row in enumerate(rows) if row[0] == recipe_id), -1)
|
||||
elif rows[from_index][0] != recipe_id:
|
||||
from_index = next((i for i, row in enumerate(rows) if row[0] == recipe_id), -1)
|
||||
if from_index < 0:
|
||||
return None, None
|
||||
|
||||
to_index = max(0, min(int(req_to_index), len(rows) - 1))
|
||||
return from_index, to_index
|
||||
|
||||
|
||||
def _period_recipe_order_updates(rows, from_index: int, to_index: int):
|
||||
"""Return [(recipe_id, new_order), ...] only for rows whose order changes."""
|
||||
recipe_ids = [row[0] for row in rows]
|
||||
current_order = {row[0]: row[1] for row in rows}
|
||||
moved = recipe_ids.pop(from_index)
|
||||
recipe_ids.insert(to_index, moved)
|
||||
|
||||
updates = []
|
||||
for idx, rid in enumerate(recipe_ids, start=1):
|
||||
if current_order.get(rid) != idx:
|
||||
updates.append((rid, idx))
|
||||
return updates
|
||||
|
||||
|
||||
def _load_period_recipe_order_rows(period_id: str):
|
||||
return db.session.execute(
|
||||
select(PeriodRecipe.recipe_id, PeriodRecipe.order)
|
||||
.where(PeriodRecipe.period_id == period_id, PeriodRecipe.is_deleted.is_(False))
|
||||
.order_by(PeriodRecipe.order.asc(), PeriodRecipe.created_at.asc())
|
||||
).all()
|
||||
|
||||
|
||||
def _commit_period_recipe_reorder(period_id: str, order_updates):
|
||||
"""Отдельное короткое соединение: не конкурирует с сессией sync pull."""
|
||||
now = datetime.now()
|
||||
attempts = int(getattr(Config, "RECIPE_REORDER_COMMIT_ATTEMPTS", 12))
|
||||
base_delay = float(getattr(Config, "RECIPE_REORDER_COMMIT_BASE_DELAY", 0.12))
|
||||
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
with db.engine.begin() as conn:
|
||||
for recipe_id, new_order in order_updates:
|
||||
conn.execute(
|
||||
update(PeriodRecipe)
|
||||
.where(
|
||||
PeriodRecipe.period_id == period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
)
|
||||
.values(
|
||||
order=new_order,
|
||||
content_hash=_period_recipe_order_content_hash(period_id, recipe_id, new_order),
|
||||
updated_by="system",
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
db.session.rollback()
|
||||
return
|
||||
except OperationalError as exc:
|
||||
db.session.rollback()
|
||||
if _is_sqlite_lock_message(exc) and attempt < attempts - 1:
|
||||
logger.warning(
|
||||
"move_recipe_in_period: SQLite locked, retry %s/%s period=%s",
|
||||
attempt + 1,
|
||||
attempts,
|
||||
period_id,
|
||||
)
|
||||
time.sleep(base_delay * (2 ** min(attempt, 5)))
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _enqueue_period_recipe_reorder_sync(period_id: str, order_updates) -> None:
|
||||
db.session.rollback()
|
||||
for recipe_id, _ in order_updates:
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes",
|
||||
f"{period_id}:{recipe_id}",
|
||||
"update",
|
||||
priority=4,
|
||||
)
|
||||
db_commit_with_retry()
|
||||
|
||||
|
||||
def _defer_period_recipe_reorder_sync(app, period_id: str, order_updates) -> None:
|
||||
"""Sync queue — после ответа клиенту, чтобы не держать lock вместе с pull."""
|
||||
|
||||
def _worker() -> None:
|
||||
with app.app_context():
|
||||
try:
|
||||
_enqueue_period_recipe_reorder_sync(period_id, order_updates)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"move_recipe_in_period: deferred sync enqueue failed period=%s",
|
||||
period_id,
|
||||
)
|
||||
|
||||
threading.Thread(
|
||||
target=_worker,
|
||||
name=f"recipe-reorder-sync-{period_id[:8]}",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
@bp.put("/<string:recipe_id>/move")
|
||||
@require_auth
|
||||
def move_recipe_in_period(recipe_id: str):
|
||||
"""Legacy-compatible reorder endpoint for recipes list in a period."""
|
||||
data = request.get_json() or {}
|
||||
period_id = data.get("period_id")
|
||||
if not period_id:
|
||||
return jsonify({"error": True, "message": "period_id обязателен"}), 400
|
||||
|
||||
try:
|
||||
req_from_index = int(data.get("from_index"))
|
||||
req_to_index = int(data.get("to_index"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": True, "message": "from_index/to_index должны быть числами"}), 400
|
||||
|
||||
try:
|
||||
db.session.rollback()
|
||||
rows = _load_period_recipe_order_rows(period_id)
|
||||
if not rows:
|
||||
return jsonify({"error": True, "message": "Рецепты периода не найдены"}), 404
|
||||
|
||||
from_index, to_index = _resolve_period_recipe_move(rows, recipe_id, req_from_index, req_to_index)
|
||||
if from_index is None:
|
||||
return jsonify({"error": True, "message": "Рецепт не найден в периоде"}), 404
|
||||
if from_index == to_index:
|
||||
return jsonify({"success": True, "message": "Порядок рейсов без изменений"}), 200
|
||||
|
||||
order_updates = _period_recipe_order_updates(rows, from_index, to_index)
|
||||
if not order_updates:
|
||||
return jsonify({"success": True, "message": "Порядок рейсов без изменений"}), 200
|
||||
|
||||
_commit_period_recipe_reorder(period_id, order_updates)
|
||||
except OperationalError as exc:
|
||||
db.session.rollback()
|
||||
if _is_sqlite_lock_message(exc):
|
||||
logger.warning(
|
||||
"move_recipe_in_period: SQLite locked period=%s recipe=%s",
|
||||
period_id,
|
||||
recipe_id,
|
||||
)
|
||||
return jsonify({"error": True, "message": "База занята, повторите через секунду"}), 503
|
||||
raise
|
||||
|
||||
_defer_period_recipe_reorder_sync(current_app._get_current_object(), period_id, order_updates)
|
||||
return jsonify({"success": True, "message": "Порядок рейсов обновлён"})
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
|
||||
from app.models import (
|
||||
ComponentLoadingTime,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.routes.auth_decorators import require_auth
|
||||
|
||||
bp = Blueprint("reports", __name__, url_prefix="/api/reports")
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
def _parse_date_range():
|
||||
date_from = (request.args.get("date_from") or "").strip()
|
||||
date_to = (request.args.get("date_to") or "").strip()
|
||||
if not date_from or not date_to:
|
||||
# Legacy fallback: last 24h when explicit range is missing.
|
||||
end = datetime.now()
|
||||
return end - timedelta(hours=24), end + timedelta(seconds=1), None
|
||||
try:
|
||||
start = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
end = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
|
||||
return start, end, None
|
||||
except ValueError:
|
||||
return None, None, _error("Некорректный формат date_from/date_to (YYYY-MM-DD)", 400)
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def reports_ping():
|
||||
"""Временный health-check эндпоинт для модуля отчетов."""
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@bp.get("/loading")
|
||||
@require_auth
|
||||
def list_loading_reports():
|
||||
"""Список отчётов загрузки с пагинацией."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 100))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return _error("Некорректные параметры пагинации", 400)
|
||||
|
||||
reports = db.session.execute(
|
||||
select(LoadingReport)
|
||||
.where(LoadingReport.is_deleted.is_(False))
|
||||
.order_by(LoadingReport.start_time.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": r.id,
|
||||
"recipe_id": r.recipe_id,
|
||||
"recipe_name": r.recipe_name,
|
||||
"start_time": r.start_time.isoformat() if r.start_time else None,
|
||||
"end_time": r.end_time.isoformat() if r.end_time else None,
|
||||
"total_weight": r.total_weight,
|
||||
"version": r.version,
|
||||
}
|
||||
for r in reports
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
def list_reports_legacy():
|
||||
"""Legacy-compatible aggregated reports endpoint used by reports UI."""
|
||||
start, end, range_error = _parse_date_range()
|
||||
if range_error:
|
||||
return range_error
|
||||
|
||||
try:
|
||||
limit = int(request.args.get("limit", 500))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return _error("Некорректные параметры пагинации", 400)
|
||||
|
||||
reports = db.session.execute(
|
||||
select(LoadingReport)
|
||||
.where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= start,
|
||||
LoadingReport.start_time < end,
|
||||
)
|
||||
.order_by(LoadingReport.start_time.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
if not reports:
|
||||
return jsonify([])
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent)
|
||||
.where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(LoadingReportComponent.loading_order.asc())
|
||||
).scalars().all()
|
||||
loading_times = db.session.execute(
|
||||
select(ComponentLoadingTime)
|
||||
.where(
|
||||
ComponentLoadingTime.report_id.in_(report_ids),
|
||||
ComponentLoadingTime.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ComponentLoadingTime.loading_order.asc())
|
||||
).scalars().all()
|
||||
unloading_rows = db.session.execute(
|
||||
select(UnloadingReport)
|
||||
.where(
|
||||
UnloadingReport.loading_report_id.in_(report_ids),
|
||||
UnloadingReport.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(UnloadingReport.start_time.desc())
|
||||
).scalars().all()
|
||||
unloading_by_loading_id = {u.loading_report_id: u for u in unloading_rows}
|
||||
unloading_ids = [u.id for u in unloading_rows]
|
||||
unloading_groups = (
|
||||
db.session.execute(
|
||||
select(UnloadingReportGroup)
|
||||
.where(
|
||||
UnloadingReportGroup.report_id.in_(unloading_ids),
|
||||
UnloadingReportGroup.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(UnloadingReportGroup.order.asc())
|
||||
).scalars().all()
|
||||
if unloading_ids
|
||||
else []
|
||||
)
|
||||
|
||||
components_map = {}
|
||||
for c in components:
|
||||
components_map.setdefault(c.report_id, []).append(
|
||||
{
|
||||
"id": c.id,
|
||||
"component_id": c.component_id,
|
||||
"component_name": c.component_name,
|
||||
"target_weight": c.target_weight,
|
||||
"actual_weight": c.actual_weight,
|
||||
"overload": c.overload,
|
||||
"loading_order": c.loading_order,
|
||||
}
|
||||
)
|
||||
|
||||
loading_times_map = {}
|
||||
for t in loading_times:
|
||||
loading_times_map.setdefault(t.report_id, []).append(
|
||||
{
|
||||
"id": t.id,
|
||||
"component_name": t.component_name,
|
||||
"start_time": t.start_time.isoformat() if t.start_time else None,
|
||||
"end_time": t.end_time.isoformat() if t.end_time else None,
|
||||
"loading_duration": t.loading_duration,
|
||||
"loading_order": t.loading_order,
|
||||
}
|
||||
)
|
||||
|
||||
unloading_groups_map = {}
|
||||
for g in unloading_groups:
|
||||
unloading_groups_map.setdefault(g.report_id, []).append(
|
||||
{
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"target_weight": g.target_weight,
|
||||
"unloaded_weight": g.unloaded_weight,
|
||||
"remaining_weight": g.remaining_weight,
|
||||
"distribution_type": g.distribution_type,
|
||||
"distribution_value": g.distribution_value,
|
||||
"order": g.order,
|
||||
}
|
||||
)
|
||||
|
||||
payload = []
|
||||
for r in reports:
|
||||
unloading = unloading_by_loading_id.get(r.id)
|
||||
unloading_payload = None
|
||||
if unloading:
|
||||
unloading_payload = {
|
||||
"id": unloading.id,
|
||||
"start_time": unloading.start_time.isoformat() if unloading.start_time else None,
|
||||
"end_time": unloading.end_time.isoformat() if unloading.end_time else None,
|
||||
"total_weight": unloading.total_weight,
|
||||
"total_unloaded_weight": unloading.total_unloaded_weight,
|
||||
"remaining_weight": unloading.remaining_weight,
|
||||
"unloading_groups": unloading_groups_map.get(unloading.id, []),
|
||||
}
|
||||
|
||||
payload.append(
|
||||
{
|
||||
"id": r.id,
|
||||
"recipe_id": r.recipe_id,
|
||||
"recipe_name": r.recipe_name,
|
||||
"start_time": r.start_time.isoformat() if r.start_time else None,
|
||||
"end_time": r.end_time.isoformat() if r.end_time else None,
|
||||
"target_mixing_time": r.target_mixing_time,
|
||||
"actual_mixing_time": r.actual_mixing_time,
|
||||
"total_weight": r.total_weight,
|
||||
"dispenser_type": r.dispenser_type,
|
||||
"components": components_map.get(r.id, []),
|
||||
"component_loading_times": loading_times_map.get(r.id, []),
|
||||
"unloading_data": unloading_payload,
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.get("/unloading")
|
||||
@require_auth
|
||||
def list_unloading_reports():
|
||||
"""Список отчётов выгрузки с пагинацией."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 100))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return _error("Некорректные параметры пагинации", 400)
|
||||
|
||||
reports = db.session.execute(
|
||||
select(UnloadingReport)
|
||||
.where(UnloadingReport.is_deleted.is_(False))
|
||||
.order_by(UnloadingReport.start_time.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": r.id,
|
||||
"recipe_id": r.recipe_id,
|
||||
"recipe_name": r.recipe_name,
|
||||
"start_time": r.start_time.isoformat() if r.start_time else None,
|
||||
"end_time": r.end_time.isoformat() if r.end_time else None,
|
||||
"total_weight": r.total_weight,
|
||||
"total_unloaded_weight": r.total_unloaded_weight,
|
||||
"remaining_weight": r.remaining_weight,
|
||||
"version": r.version,
|
||||
}
|
||||
for r in reports
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
ComponentLoadingTime,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Recipe,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.routes.auth_decorators import require_auth, require_paired_terminal
|
||||
|
||||
bp = Blueprint("reports_legacy", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
@bp.post("/save_report")
|
||||
@require_paired_terminal
|
||||
def save_report_legacy():
|
||||
data = request.get_json() or {}
|
||||
recipe_id = data.get("recipe_id")
|
||||
if not recipe_id:
|
||||
return _error("Не указан ID рецепта", 400)
|
||||
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not recipe:
|
||||
return _error("Рецепт не найден", 404)
|
||||
|
||||
try:
|
||||
total_weight = float(data.get("total_weight", 0))
|
||||
target_mixing_time = int(data.get("target_mixing_time", 0))
|
||||
actual_mixing_time = int(data.get("actual_mixing_time", 0))
|
||||
except (TypeError, ValueError):
|
||||
return _error("Некорректные числовые поля отчёта", 400)
|
||||
|
||||
components = data.get("components", []) or []
|
||||
if not components:
|
||||
return _error("Нет данных о компонентах", 400)
|
||||
|
||||
report = LoadingReport(
|
||||
recipe_id=recipe_id,
|
||||
recipe_name=recipe.name,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
target_mixing_time=target_mixing_time,
|
||||
actual_mixing_time=actual_mixing_time,
|
||||
total_weight=total_weight,
|
||||
dispenser_type=(data.get("dispenser_type") or "dispenser"),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.flush()
|
||||
|
||||
for idx, comp in enumerate(components, start=1):
|
||||
db.session.add(
|
||||
LoadingReportComponent(
|
||||
report_id=report.id,
|
||||
component_id=comp.get("component_id") or comp.get("componentId"),
|
||||
component_name=str(comp.get("name") or comp.get("component_name") or "—"),
|
||||
target_weight=float(comp.get("target_weight", 0) or 0),
|
||||
actual_weight=float(comp.get("actual_weight", 0) or 0),
|
||||
overload=float(comp.get("overload", 0) or 0),
|
||||
loading_order=idx,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
for item in data.get("component_loading_times", []) or []:
|
||||
try:
|
||||
start_time = datetime.fromisoformat(item["start_time"])
|
||||
end_time = datetime.fromisoformat(item["end_time"])
|
||||
except Exception:
|
||||
continue
|
||||
db.session.add(
|
||||
ComponentLoadingTime(
|
||||
report_id=report.id,
|
||||
component_name=str(item.get("component_name") or "—"),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
loading_duration=float(item.get("loading_duration", 0) or 0),
|
||||
loading_order=int(item.get("loading_order", 0) or 0),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
try:
|
||||
from app.services.feed_quality.evaluator import evaluate_loading_report
|
||||
|
||||
evaluate_loading_report(report.id, send_notifications=True)
|
||||
except Exception:
|
||||
logger.exception("[FEED-QUALITY] save_report evaluate failed report_id=%s", report.id)
|
||||
return jsonify({"status": "success", "message": "Отчёт успешно сохранён", "report_id": report.id})
|
||||
|
||||
|
||||
@bp.get("/consumption_by_component")
|
||||
@require_auth
|
||||
def consumption_by_component():
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
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)
|
||||
.where(LoadingReportComponent.is_deleted.is_(False))
|
||||
.where(LoadingReport.is_deleted.is_(False))
|
||||
.group_by(
|
||||
LoadingReportComponent.component_id,
|
||||
LoadingReportComponent.component_name,
|
||||
)
|
||||
).all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"component_id": r.component_id,
|
||||
"component_name": r.component_name or "—",
|
||||
"total_actual_weight": round(float(r.total_actual_weight or 0), 2),
|
||||
"report_count": int(r.report_count or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/reports/<string:report_id>/loading_times")
|
||||
@require_auth
|
||||
def report_loading_times(report_id: str):
|
||||
report = db.session.execute(
|
||||
select(LoadingReport).where(LoadingReport.id == report_id, LoadingReport.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not report:
|
||||
return _error("Отчёт не найден", 404)
|
||||
rows = db.session.execute(
|
||||
select(ComponentLoadingTime)
|
||||
.where(
|
||||
ComponentLoadingTime.report_id == report_id,
|
||||
ComponentLoadingTime.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ComponentLoadingTime.loading_order.asc())
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"loading_times": [
|
||||
{
|
||||
"component_name": lt.component_name,
|
||||
"start_time": lt.start_time.isoformat() if lt.start_time else None,
|
||||
"end_time": lt.end_time.isoformat() if lt.end_time else None,
|
||||
"loading_duration": float(lt.loading_duration or 0),
|
||||
"loading_order": lt.loading_order,
|
||||
}
|
||||
for lt in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/save_unloading_report")
|
||||
@require_paired_terminal
|
||||
def save_unloading_report_legacy():
|
||||
data = request.get_json() or {}
|
||||
loading_report_id = data.get("loading_report_id")
|
||||
recipe_id = data.get("recipe_id")
|
||||
if not loading_report_id:
|
||||
return _error("Не указан ID отчёта о загрузке", 400)
|
||||
if not recipe_id:
|
||||
return _error("Не указан ID рецепта", 400)
|
||||
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not recipe:
|
||||
return _error("Рецепт не найден", 404)
|
||||
|
||||
report = UnloadingReport(
|
||||
recipe_id=recipe_id,
|
||||
recipe_name=recipe.name,
|
||||
loading_report_id=loading_report_id,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
total_weight=float(data.get("total_weight", 0) or 0),
|
||||
total_unloaded_weight=float(data.get("total_unloaded_weight", 0) or 0),
|
||||
remaining_weight=float(data.get("remaining_weight", 0) or 0),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.flush()
|
||||
|
||||
for group in data.get("unloading_groups", []) or []:
|
||||
db.session.add(
|
||||
UnloadingReportGroup(
|
||||
report_id=report.id,
|
||||
name=str(group.get("name") or "—"),
|
||||
target_weight=float(group.get("target_weight", 0) or 0),
|
||||
unloaded_weight=float(group.get("unloaded_weight", 0) or 0),
|
||||
remaining_weight=float(group.get("remaining_weight", 0) or 0),
|
||||
distribution_type=str(group.get("distribution_type") or "percent"),
|
||||
distribution_value=float(group.get("distribution_value", 0) or 0),
|
||||
order=int(group.get("order", 0) or 0),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
try:
|
||||
from app.services.feed_quality.evaluator import evaluate_unloading_report
|
||||
|
||||
evaluate_unloading_report(report.id, send_notifications=True)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[FEED-QUALITY] save_unloading_report evaluate failed unloading_id=%s", report.id
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Отчёт о выгрузке сохранён",
|
||||
"unloading_report_id": report.id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/unloading_reports")
|
||||
@require_auth
|
||||
def unloading_reports_legacy():
|
||||
rows = db.session.execute(
|
||||
select(UnloadingReport)
|
||||
.where(UnloadingReport.is_deleted.is_(False))
|
||||
.order_by(UnloadingReport.start_time.desc())
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": r.id,
|
||||
"recipe_id": r.recipe_id,
|
||||
"recipe_name": r.recipe_name,
|
||||
"loading_report_id": r.loading_report_id,
|
||||
"start_time": r.start_time.isoformat() if r.start_time else None,
|
||||
"end_time": r.end_time.isoformat() if r.end_time else None,
|
||||
"total_weight": r.total_weight,
|
||||
"total_unloaded_weight": r.total_unloaded_weight,
|
||||
"remaining_weight": r.remaining_weight,
|
||||
"unloading_groups": [
|
||||
{
|
||||
"name": g.name,
|
||||
"target_weight": g.target_weight,
|
||||
"unloaded_weight": g.unloaded_weight,
|
||||
"remaining_weight": g.remaining_weight,
|
||||
"distribution_type": g.distribution_type,
|
||||
"distribution_value": g.distribution_value,
|
||||
"order": g.order,
|
||||
}
|
||||
for g in sorted(r.groups, key=lambda x: x.order)
|
||||
if not g.is_deleted
|
||||
],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/reports/<string:report_id>/unloading_groups")
|
||||
@require_auth
|
||||
def report_unloading_groups(report_id: str):
|
||||
report = db.session.execute(
|
||||
select(UnloadingReport).where(
|
||||
UnloadingReport.id == report_id, UnloadingReport.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not report:
|
||||
return _error("Отчёт выгрузки не найден", 404)
|
||||
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": g.name,
|
||||
"target_weight": g.target_weight,
|
||||
"unloaded_weight": g.unloaded_weight,
|
||||
"remaining_weight": g.remaining_weight,
|
||||
"distribution_type": g.distribution_type,
|
||||
"distribution_value": g.distribution_value,
|
||||
"order": g.order,
|
||||
}
|
||||
for g in sorted(report.groups, key=lambda x: x.order)
|
||||
if not g.is_deleted
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
|
||||
from flask import Blueprint, Response, current_app, jsonify, request
|
||||
|
||||
from app.routes.auth_decorators import require_paired_terminal
|
||||
from app.services.hardware import ScalesReader
|
||||
|
||||
bp = Blueprint("scales", __name__)
|
||||
|
||||
_scales_reader = None
|
||||
_calibration_state = {"target_weight": None, "initial_raw": None}
|
||||
_calibration_lock = threading.Lock()
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "status": "error", "message": message}), status_code
|
||||
|
||||
|
||||
def _get_reader() -> ScalesReader:
|
||||
global _scales_reader
|
||||
if _scales_reader is None:
|
||||
_scales_reader = ScalesReader.from_config(current_app.config)
|
||||
_scales_reader.bind_flask_app(current_app._get_current_object())
|
||||
_scales_reader.start()
|
||||
return _scales_reader
|
||||
|
||||
|
||||
@bp.get("/current_weight")
|
||||
@require_paired_terminal
|
||||
def current_weight():
|
||||
reader = _get_reader()
|
||||
payload = {"weight": reader.get_current_weight(), **reader.get_scale_health()}
|
||||
return jsonify(payload), 200
|
||||
|
||||
|
||||
@bp.post("/tare")
|
||||
@require_paired_terminal
|
||||
def tare_weight():
|
||||
reader = _get_reader()
|
||||
try:
|
||||
tare_value = reader.tare()
|
||||
except ValueError as e:
|
||||
return jsonify({"status": "error", "message": str(e)}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Новый ноль сохранён",
|
||||
"new_zero": round(float(tare_value), 4),
|
||||
"success": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/calibrate")
|
||||
@require_paired_terminal
|
||||
def calibrate_weight():
|
||||
data = request.get_json(silent=True) or {}
|
||||
reader = _get_reader()
|
||||
|
||||
if data.get("target_weight") is not None:
|
||||
try:
|
||||
target_weight = float(data.get("target_weight"))
|
||||
except (TypeError, ValueError):
|
||||
return _error("target_weight должен быть числом", 400)
|
||||
if target_weight <= 0:
|
||||
return _error("target_weight должен быть больше 0", 400)
|
||||
try:
|
||||
initial_raw = reader.average_recent_raw(
|
||||
int(current_app.config.get("TARE_RAW_WINDOW", 2))
|
||||
)
|
||||
except ValueError as e:
|
||||
return _error(str(e), 400)
|
||||
|
||||
with _calibration_lock:
|
||||
_calibration_state["target_weight"] = target_weight
|
||||
_calibration_state["initial_raw"] = initial_raw
|
||||
return jsonify(
|
||||
{
|
||||
"status": "confirm",
|
||||
"message": f"Поместите груз {target_weight}кг и подтвердите",
|
||||
}
|
||||
)
|
||||
|
||||
raw_factor = data.get("calibration_factor")
|
||||
if raw_factor is None:
|
||||
return _error("Нужно указать calibration_factor или target_weight", 400)
|
||||
try:
|
||||
factor = float(raw_factor)
|
||||
except (TypeError, ValueError):
|
||||
return _error("calibration_factor должен быть числом", 400)
|
||||
|
||||
try:
|
||||
applied = reader.calibrate(factor)
|
||||
except ValueError as e:
|
||||
return _error(str(e), 400)
|
||||
return jsonify({"status": "success", "success": True, "calibration_factor": applied})
|
||||
|
||||
|
||||
@bp.post("/calibrate/continue")
|
||||
@require_paired_terminal
|
||||
def calibrate_continue():
|
||||
reader = _get_reader()
|
||||
with _calibration_lock:
|
||||
target_weight = _calibration_state.get("target_weight")
|
||||
initial_raw = _calibration_state.get("initial_raw")
|
||||
if not target_weight or initial_raw is None:
|
||||
return _error("Калибровка не начата", 400)
|
||||
|
||||
try:
|
||||
final_raw = reader.average_recent_raw(int(current_app.config.get("TARE_RAW_WINDOW", 2)))
|
||||
counts_per_kg = (final_raw - float(initial_raw)) / float(target_weight)
|
||||
applied = reader.set_counts_per_kg(float(counts_per_kg))
|
||||
except ValueError as e:
|
||||
return _error(str(e), 400)
|
||||
|
||||
with _calibration_lock:
|
||||
_calibration_state["target_weight"] = None
|
||||
_calibration_state["initial_raw"] = None
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Калибровка завершена!",
|
||||
"calibration_factor": round(float(applied), 6),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/current_raw_data")
|
||||
@require_paired_terminal
|
||||
def current_raw_data():
|
||||
reader = _get_reader()
|
||||
return jsonify(reader.calibration_debug_payload())
|
||||
|
||||
|
||||
@bp.get("/stream_weight")
|
||||
@require_paired_terminal
|
||||
def stream_weight():
|
||||
reader = _get_reader()
|
||||
# Интервал нужно взять здесь: внутри генератора контекст приложения уже снят.
|
||||
read_interval = float(current_app.config.get("READ_INTERVAL", 0.05))
|
||||
|
||||
def _events():
|
||||
while True:
|
||||
payload = {"weight": reader.get_current_weight(), **reader.get_scale_health()}
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
time.sleep(read_interval)
|
||||
|
||||
return Response(_events(), mimetype="text/event-stream")
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
"""Setup wizard API (/api/setup/*)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, make_response, request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app import db
|
||||
from app.kiosk_device_cookie import set_kiosk_device_cookie
|
||||
from app.models import KioskDevice, WebUser
|
||||
from app.routes.auth import _ensure_default_superuser, load_credentials
|
||||
from app.routes.admin import (
|
||||
_WEB_USER_PASSWORD_MIN_LEN,
|
||||
_effective_sync_connection,
|
||||
_reject_if_weak_login_or_password,
|
||||
_valid_login,
|
||||
)
|
||||
from app.routes.auth_decorators import _is_localhost_request, is_superuser_session, require_superuser
|
||||
from app.services.admin_dashboard_service import get_install_and_warranty
|
||||
from app.services.admin_peripheral_monitor import build_hardware_status
|
||||
from app.services.hardware_settings_service import (
|
||||
is_scale_hardware_platform,
|
||||
setup_complete_redirect,
|
||||
)
|
||||
from app.services.lan_network import parse_wesp_listen
|
||||
from app.services.network_settings_service import build_public_url_from_hostname, network_settings_snapshot
|
||||
from app.services.setup_state import (
|
||||
is_setup_completed,
|
||||
mark_setup_complete,
|
||||
mark_step_completed,
|
||||
read_install_state,
|
||||
reset_setup,
|
||||
setup_snapshot,
|
||||
write_install_state,
|
||||
)
|
||||
from app.services.mdns_service import reload_mdns
|
||||
from config import (
|
||||
DEFAULT_NETWORK_LOCAL_HOSTNAME,
|
||||
apply_network_settings_to_app,
|
||||
coerce_security_bool,
|
||||
normalize_local_hostname,
|
||||
read_sync_client_state,
|
||||
validate_local_hostname,
|
||||
write_network_settings,
|
||||
write_sync_client_state,
|
||||
)
|
||||
|
||||
bp = Blueprint("setup", __name__, url_prefix="/api/setup")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_USER_CREATE_ATTEMPTS: Dict[str, int] = {}
|
||||
_USER_CREATE_LIMIT = 20
|
||||
|
||||
|
||||
def _setup_connection_snapshot() -> dict:
|
||||
conn = _effective_sync_connection(current_app)
|
||||
state = read_sync_client_state()
|
||||
return {
|
||||
**conn,
|
||||
"client_id": state.get("client_id"),
|
||||
"client_name": state.get("client_name"),
|
||||
}
|
||||
|
||||
|
||||
def _default_setup_client_name() -> str:
|
||||
snap = network_settings_snapshot(current_app)
|
||||
host = normalize_local_hostname(str(snap.get("local_hostname") or ""))
|
||||
default_host = normalize_local_hostname(DEFAULT_NETWORK_LOCAL_HOSTNAME)
|
||||
if host and host != default_host:
|
||||
return host.replace(".local", "")[:200]
|
||||
os_h = str((snap.get("detected") or {}).get("os_hostname") or "").strip().lower()
|
||||
os_h = os_h.replace(" ", "_")[:200]
|
||||
return os_h or "vesy_1"
|
||||
|
||||
|
||||
def _setup_blocked_response():
|
||||
return jsonify({"status": "error", "message": "Первоначальная настройка уже завершена."}), 403
|
||||
|
||||
|
||||
def _localhost_required_response():
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Настройка доступна только с localhost или для суперпользователя.",
|
||||
}
|
||||
), 403
|
||||
|
||||
|
||||
def _is_setup_local_request() -> bool:
|
||||
if bool(current_app.config.get("TESTING")):
|
||||
return True
|
||||
return _is_localhost_request()
|
||||
|
||||
|
||||
def _require_setup_access(*, allow_completed: bool = False):
|
||||
if not allow_completed and is_setup_completed(current_app):
|
||||
return _setup_blocked_response()
|
||||
if is_superuser_session():
|
||||
return None
|
||||
if not is_setup_completed(current_app):
|
||||
# Wizard is open to the LAN until first-time setup finishes (see pages guard).
|
||||
return None
|
||||
if not _is_setup_local_request():
|
||||
return _localhost_required_response()
|
||||
return None
|
||||
|
||||
|
||||
def _require_setup_mutation():
|
||||
return _require_setup_access(allow_completed=False)
|
||||
|
||||
|
||||
def _rate_limit_user_create() -> tuple[dict | None, int]:
|
||||
key = request.remote_addr or "unknown"
|
||||
count = _USER_CREATE_ATTEMPTS.get(key, 0) + 1
|
||||
_USER_CREATE_ATTEMPTS[key] = count
|
||||
if count > _USER_CREATE_LIMIT:
|
||||
return {"status": "error", "message": "Слишком много попыток создания пользователей."}, 429
|
||||
return None, 0
|
||||
|
||||
|
||||
def _validate_user_fields(login: str, password: str, confirm: str, label: str) -> tuple[dict | None, int]:
|
||||
login = (login or "").strip()
|
||||
password = password or ""
|
||||
confirm = confirm or ""
|
||||
if not login:
|
||||
return {"status": "error", "message": f"{label}: укажите логин."}, 400
|
||||
if not _valid_login(login):
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"{label}: логин не длиннее 64 символов.",
|
||||
}, 400
|
||||
if not password:
|
||||
return {"status": "error", "message": f"{label}: укажите пароль."}, 400
|
||||
if password != confirm:
|
||||
return {"status": "error", "message": f"{label}: пароли не совпадают."}, 400
|
||||
if len(password) < _WEB_USER_PASSWORD_MIN_LEN:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"{label}: пароль не короче {_WEB_USER_PASSWORD_MIN_LEN} символов.",
|
||||
}, 400
|
||||
err_body, err_code = _reject_if_weak_login_or_password(login, password)
|
||||
if err_body is not None:
|
||||
err_body["message"] = f"{label}: {err_body.get('message', 'слабый пароль')}"
|
||||
return err_body, err_code
|
||||
return None, 0
|
||||
|
||||
|
||||
def _hardware_platform_snapshot() -> dict:
|
||||
machine = platform.machine() or "unknown"
|
||||
system = platform.system() or "unknown"
|
||||
arm = is_scale_hardware_platform()
|
||||
return {
|
||||
"scale_hardware_platform": arm,
|
||||
"machine": machine,
|
||||
"system": system,
|
||||
"label": f"{machine} ({system})",
|
||||
"message": (
|
||||
"Платформа ARM — можно подключить весы HX711 к Raspberry Pi."
|
||||
if arm
|
||||
else "Весы не найдены"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _ping_sync_server_once(server_url: str, *, timeout: float = 5.0) -> tuple[bool, str]:
|
||||
url = str(server_url or "").strip().rstrip("/")
|
||||
if not url:
|
||||
return False, "Укажите адрес сервера."
|
||||
ping_url = f"{url}/api/sync/ping"
|
||||
try:
|
||||
req = Request(ping_url, method="GET", headers={"Accept": "application/json"})
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
resp.read()
|
||||
return True, ""
|
||||
except HTTPError as exc:
|
||||
if exc.code in (401, 403, 404, 405):
|
||||
return True, ""
|
||||
return False, f"Сервер ответил с ошибкой HTTP {exc.code}."
|
||||
except URLError as exc:
|
||||
reason = str(exc.reason or exc)
|
||||
low = reason.lower()
|
||||
if "timed out" in low or "timeout" in low:
|
||||
return False, "Сервер не отвечает. Проверьте адрес и что главный сервер включён в сети."
|
||||
if "name or service not known" in low or "nodename nor servname" in low:
|
||||
return False, "Сервер не найден. Проверьте имя (например komton_srv_1.local) или IP."
|
||||
return False, f"Не удалось подключиться: {reason}."
|
||||
except Exception as exc:
|
||||
return False, f"Не удалось подключиться: {exc}"
|
||||
|
||||
|
||||
def _ping_sync_server(server_url: str, *, timeout: float = 5.0) -> tuple[bool, str]:
|
||||
from app.services.sync_url_resolve import first_reachable_sync_url
|
||||
|
||||
ok, err, _effective = first_reachable_sync_url(
|
||||
server_url,
|
||||
ping_fn=lambda u: _ping_sync_server_once(u, timeout=timeout),
|
||||
)
|
||||
return ok, err
|
||||
|
||||
|
||||
def _ping_sync_server_with_effective(
|
||||
server_url: str, *, timeout: float = 5.0
|
||||
) -> tuple[bool, str, str]:
|
||||
from app.services.sync_url_resolve import first_reachable_sync_url
|
||||
|
||||
return first_reachable_sync_url(
|
||||
server_url,
|
||||
ping_fn=lambda u: _ping_sync_server_once(u, timeout=timeout),
|
||||
)
|
||||
|
||||
|
||||
def _restart_local_sync_if_needed(*, async_start: bool = False) -> bool:
|
||||
if current_app.config.get("TESTING"):
|
||||
return False
|
||||
try:
|
||||
from sync_client import apply_sync_client_runtime
|
||||
|
||||
app = current_app._get_current_object()
|
||||
if async_start:
|
||||
def _run() -> None:
|
||||
with app.app_context():
|
||||
try:
|
||||
apply_sync_client_runtime(app)
|
||||
except Exception:
|
||||
logger.exception("setup: фоновый перезапуск sync_client")
|
||||
|
||||
threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name="wesp-setup-sync-restart",
|
||||
).start()
|
||||
return True
|
||||
return bool(apply_sync_client_runtime(app))
|
||||
except Exception:
|
||||
logger.exception("setup: не удалось перезапустить sync_client")
|
||||
return False
|
||||
|
||||
|
||||
def _sync_progress_payload() -> dict:
|
||||
state = read_sync_client_state()
|
||||
progress: dict = {"active": False}
|
||||
try:
|
||||
from sync_client import get_initial_sync_progress_snapshot
|
||||
|
||||
progress = get_initial_sync_progress_snapshot()
|
||||
except Exception:
|
||||
logger.debug("setup: initial sync progress unavailable", exc_info=True)
|
||||
return {
|
||||
"first_bootstrap_done": bool(state.get("first_bootstrap_done")),
|
||||
"initial_sync_progress": progress,
|
||||
"connection": _setup_connection_snapshot(),
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/status")
|
||||
def setup_status():
|
||||
err = _require_setup_access(allow_completed=True)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
install_w = get_install_and_warranty(current_app)
|
||||
snap = setup_snapshot(current_app)
|
||||
snap["first_launch_at"] = install_w.get("first_launch_at")
|
||||
user_count = db.session.scalar(select(func.count()).select_from(WebUser)) or 0
|
||||
|
||||
try:
|
||||
from app.services.update_health import build_health_payload
|
||||
|
||||
health = build_health_payload(current_app)
|
||||
except Exception:
|
||||
health = {"ok": False}
|
||||
|
||||
zootech_logins = list(
|
||||
db.session.scalars(
|
||||
select(WebUser.login).where(WebUser.is_superuser.is_(False)).order_by(WebUser.login)
|
||||
).all()
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"setup": snap,
|
||||
"health": health,
|
||||
"network": network_settings_snapshot(current_app),
|
||||
"sync": _sync_progress_payload(),
|
||||
"connection": _setup_connection_snapshot(),
|
||||
"users_exist": int(user_count) > 0,
|
||||
"zootech_users": zootech_logins,
|
||||
"user_rules": {
|
||||
"password_min_len": _WEB_USER_PASSWORD_MIN_LEN,
|
||||
},
|
||||
"hardware": _hardware_platform_snapshot(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/device-role")
|
||||
def setup_device_role():
|
||||
err = _require_setup_mutation()
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
role = str(payload.get("device_role") or payload.get("role") or "").strip().lower()
|
||||
if role not in ("server", "client"):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "device_role: server или client"},
|
||||
), 400
|
||||
|
||||
conn = _effective_sync_connection(current_app)
|
||||
if conn.get("role_locked_by_env") and role in ("server", "client"):
|
||||
env_role = str(conn.get("env_role") or conn.get("role") or "").strip().lower()
|
||||
if env_role and env_role != role:
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Роль задана в WESP_SYNC_ROLE — измените окружение или выберите совпадающую роль.",
|
||||
}
|
||||
), 400
|
||||
|
||||
updates: Dict[str, Any] = {}
|
||||
if role == "server":
|
||||
updates["role"] = "server"
|
||||
updates["server_url"] = ""
|
||||
elif role == "client":
|
||||
updates["role"] = "client"
|
||||
if updates:
|
||||
try:
|
||||
write_sync_client_state(updates)
|
||||
except ValueError as exc:
|
||||
return jsonify({"status": "error", "message": str(exc)}), 400
|
||||
if role == "server" and not current_app.config.get("TESTING"):
|
||||
try:
|
||||
from sync_client import stop_sync_client
|
||||
|
||||
stop_sync_client()
|
||||
except Exception:
|
||||
pass
|
||||
# Sync перезапускается на шаге «Подключение к серверу» (async). Здесь не трогаем:
|
||||
# при role=client и сохранённом server_url register() блокирует HTTP-ответ на таймауты.
|
||||
|
||||
write_install_state(current_app, {"device_role": role})
|
||||
mark_step_completed(current_app, "device")
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"device_role": role,
|
||||
"connection": _setup_connection_snapshot(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/network")
|
||||
def setup_network():
|
||||
err = _require_setup_mutation()
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
snap = network_settings_snapshot(current_app)
|
||||
updates: dict = {}
|
||||
|
||||
if "local_hostname" in payload:
|
||||
if snap.get("local_hostname_locked_by_env"):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "local_hostname задан в окружении."},
|
||||
), 400
|
||||
ok, host_or_err = validate_local_hostname(payload.get("local_hostname"))
|
||||
if not ok:
|
||||
return jsonify({"status": "error", "message": host_or_err}), 400
|
||||
updates["local_hostname"] = host_or_err
|
||||
if not snap.get("public_base_url_locked_by_env"):
|
||||
_, listen_port = parse_wesp_listen(str(current_app.config.get("WESP_LISTEN") or ""))
|
||||
updates["public_base_url"] = build_public_url_from_hostname(
|
||||
updates["local_hostname"],
|
||||
listen_port,
|
||||
)
|
||||
if not snap.get("mdns_enabled_locked_by_env"):
|
||||
updates["mdns_enabled"] = True
|
||||
|
||||
elif "public_base_url" in payload:
|
||||
if snap.get("public_base_url_locked_by_env"):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "public_base_url задан в окружении."},
|
||||
), 400
|
||||
url = str(payload.get("public_base_url") or "").strip().rstrip("/")
|
||||
if url and not (url.startswith("http://") or url.startswith("https://")):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Публичный URL: пусто или http(s)://…"},
|
||||
), 400
|
||||
updates["public_base_url"] = url
|
||||
|
||||
if "mdns_enabled" in payload:
|
||||
if snap.get("mdns_enabled_locked_by_env"):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "mdns_enabled задан в окружении."},
|
||||
), 400
|
||||
updates["mdns_enabled"] = coerce_security_bool(payload.get("mdns_enabled"))
|
||||
|
||||
if not updates:
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Передайте local_hostname, public_base_url и/или mdns_enabled."},
|
||||
), 400
|
||||
|
||||
write_network_settings(updates)
|
||||
apply_network_settings_to_app(current_app)
|
||||
if not current_app.config.get("TESTING"):
|
||||
try:
|
||||
if any(k in updates for k in ("mdns_enabled", "local_hostname")):
|
||||
reload_mdns(current_app)
|
||||
except Exception as exc:
|
||||
logger.warning("setup network: mDNS reload failed: %s", exc)
|
||||
|
||||
mark_step_completed(current_app, "network")
|
||||
out = network_settings_snapshot(current_app)
|
||||
out["settings_path"] = str(current_app.config.get("DATA_DIR", ""))
|
||||
return jsonify({"status": "success", "network": out})
|
||||
|
||||
|
||||
@bp.post("/sync")
|
||||
def setup_sync():
|
||||
err = _require_setup_mutation()
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
conn = _effective_sync_connection(current_app)
|
||||
updates: dict = {"role": "client"}
|
||||
offline = bool(payload.get("offline"))
|
||||
|
||||
if conn.get("server_url_locked_by_env"):
|
||||
if "server_url" in payload and str(payload.get("server_url") or "").strip():
|
||||
return jsonify(
|
||||
{"status": "error", "message": "server_url задан в WESP_SYNC_SERVER_URL."},
|
||||
), 400
|
||||
elif "server_url" in payload:
|
||||
raw = str(payload.get("server_url") or "").strip().rstrip("/")
|
||||
if raw and not (raw.startswith("http://") or raw.startswith("https://")):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Адрес сервера должен начинаться с http:// или https://"},
|
||||
), 400
|
||||
updates["server_url"] = raw
|
||||
|
||||
server_url_check = str(updates.get("server_url") or conn.get("server_url") or "").strip().rstrip("/")
|
||||
if not offline and server_url_check and not current_app.config.get("TESTING"):
|
||||
reachable, ping_error, effective_url = _ping_sync_server_with_effective(
|
||||
server_url_check, timeout=5.0
|
||||
)
|
||||
if not reachable:
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"reachable": False,
|
||||
"message": ping_error,
|
||||
"server_url": server_url_check,
|
||||
}
|
||||
), 502
|
||||
if effective_url and effective_url != server_url_check:
|
||||
updates["server_url"] = effective_url
|
||||
logger.info(
|
||||
"setup: имя %s не резолвится — сохранён адрес по IP %s",
|
||||
server_url_check,
|
||||
effective_url,
|
||||
)
|
||||
|
||||
if "client_id" in payload:
|
||||
cid = str(payload.get("client_id") or "").strip()
|
||||
if cid:
|
||||
try:
|
||||
uuid.UUID(cid)
|
||||
except ValueError:
|
||||
return jsonify(
|
||||
{"status": "error", "message": "client_id: ожидается UUID."},
|
||||
), 400
|
||||
else:
|
||||
cid = str(uuid.uuid4())
|
||||
updates["client_id"] = cid
|
||||
elif not read_sync_client_state().get("client_id"):
|
||||
updates["client_id"] = str(uuid.uuid4())
|
||||
|
||||
if "client_name" in payload:
|
||||
name = str(payload.get("client_name") or "").strip()
|
||||
if len(name) > 200:
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Название терминала: не длиннее 200 символов."},
|
||||
), 400
|
||||
if name:
|
||||
updates["client_name"] = name
|
||||
if "client_name" not in updates and not read_sync_client_state().get("client_name"):
|
||||
updates["client_name"] = _default_setup_client_name()
|
||||
|
||||
write_sync_client_state(updates)
|
||||
reloaded = _restart_local_sync_if_needed(async_start=True)
|
||||
mark_step_completed(current_app, "sync")
|
||||
write_install_state(
|
||||
current_app,
|
||||
{"sync_server_connected": (not offline and bool(server_url_check))},
|
||||
)
|
||||
|
||||
state = read_sync_client_state()
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"sync_reloaded": reloaded,
|
||||
"offline": offline,
|
||||
"reachable": not offline and bool(server_url_check),
|
||||
"client_id": state.get("client_id"),
|
||||
"connection": _setup_connection_snapshot(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/sync/test")
|
||||
def setup_sync_test():
|
||||
err = _require_setup_access(allow_completed=True)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
server_url = str(payload.get("server_url") or "").strip().rstrip("/")
|
||||
if not server_url:
|
||||
conn = _effective_sync_connection(current_app)
|
||||
server_url = str(conn.get("server_url") or "").strip().rstrip("/")
|
||||
if not server_url:
|
||||
return jsonify({"status": "error", "message": "Укажите адрес сервера."}), 400
|
||||
|
||||
reachable, ping_error, effective_url = _ping_sync_server_with_effective(
|
||||
server_url, timeout=5.0
|
||||
)
|
||||
ping_url = f"{(effective_url or server_url)}/api/sync/ping"
|
||||
if not reachable:
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"reachable": False,
|
||||
"message": ping_error,
|
||||
"ping_url": ping_url,
|
||||
}
|
||||
), 502
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"reachable": True,
|
||||
"ping_url": ping_url,
|
||||
"effective_server_url": effective_url if effective_url != server_url else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/sync/progress")
|
||||
def setup_sync_progress():
|
||||
err = _require_setup_access(allow_completed=True)
|
||||
if err is not None:
|
||||
return err
|
||||
return jsonify({"status": "success", **_sync_progress_payload()})
|
||||
|
||||
|
||||
@bp.post("/users")
|
||||
def setup_users():
|
||||
err = _require_setup_mutation()
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
rate_err, rate_code = _rate_limit_user_create()
|
||||
if rate_err is not None:
|
||||
return jsonify(rate_err), rate_code
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
zootech = payload.get("zootech") if isinstance(payload.get("zootech"), dict) else payload
|
||||
|
||||
err_body, err_code = _validate_user_fields(
|
||||
zootech.get("login"),
|
||||
zootech.get("password"),
|
||||
zootech.get("confirm") or zootech.get("password_confirm"),
|
||||
"Зоотехник",
|
||||
)
|
||||
if err_body is not None:
|
||||
return jsonify(err_body), err_code
|
||||
|
||||
zootech_login = zootech.get("login", "").strip()
|
||||
env_login, _ = load_credentials()
|
||||
if zootech_login.lower() == env_login.strip().lower():
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Логин зоотехника не должен совпадать с учётной записью администратора.",
|
||||
},
|
||||
), 400
|
||||
|
||||
_ensure_default_superuser()
|
||||
|
||||
existing = db.session.execute(
|
||||
select(WebUser).where(WebUser.login == zootech_login)
|
||||
).scalar_one_or_none()
|
||||
|
||||
try:
|
||||
if existing is not None:
|
||||
if existing.is_superuser:
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Этот логин занят учётной записью администратора.",
|
||||
},
|
||||
), 400
|
||||
existing.password_hash = generate_password_hash(zootech["password"])
|
||||
db.session.commit()
|
||||
action = "updated"
|
||||
message = f"Пароль зоотехника «{zootech_login}» обновлён."
|
||||
else:
|
||||
user = WebUser(
|
||||
login=zootech_login,
|
||||
password_hash=generate_password_hash(zootech["password"]),
|
||||
is_superuser=False,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
action = "created"
|
||||
message = "Создана учётная запись зоотехника."
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Не удалось сохранить пользователя (дубликат логина?)."},
|
||||
), 409
|
||||
|
||||
mark_step_completed(current_app, "users")
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"action": action,
|
||||
"login": zootech_login,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/hardware/check")
|
||||
def setup_hardware_check():
|
||||
err = _require_setup_access(allow_completed=True)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
payload = build_hardware_status(current_app)
|
||||
scales = payload.get("scales") or {}
|
||||
platform_info = _hardware_platform_snapshot()
|
||||
ok = bool(scales.get("available")) and not scales.get("error")
|
||||
weight = scales.get("weight_kg")
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"ok": ok,
|
||||
"platform": platform_info,
|
||||
"hardware": {
|
||||
"simulation_mode": scales.get("simulation_mode"),
|
||||
"current_weight_kg": weight,
|
||||
"available": scales.get("available"),
|
||||
"error": scales.get("error"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/kiosk/prepare")
|
||||
def setup_kiosk_prepare():
|
||||
err = _require_setup_mutation()
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
device_id = (request.cookies.get("wesp_kiosk_device_id") or "").strip()
|
||||
if not device_id:
|
||||
device_id = str(uuid.uuid4())
|
||||
|
||||
row = db.session.execute(
|
||||
select(KioskDevice).where(KioskDevice.id == device_id)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = KioskDevice(
|
||||
id=device_id,
|
||||
display_name=f"terminal-{device_id[:8]}",
|
||||
status="pending",
|
||||
last_ip=request.remote_addr,
|
||||
last_seen_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.last_ip = request.remote_addr
|
||||
row.last_seen_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
response = make_response(
|
||||
jsonify({"status": "success", "device_id": device_id, "paired": row.status == "active"})
|
||||
)
|
||||
set_kiosk_device_cookie(response, device_id)
|
||||
return response
|
||||
|
||||
|
||||
@bp.post("/complete")
|
||||
def setup_complete():
|
||||
err = _require_setup_mutation()
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
state = read_install_state(current_app)
|
||||
device_role = state.get("device_role")
|
||||
if not device_role:
|
||||
return jsonify({"status": "error", "message": "Сначала выберите тип устройства."}), 400
|
||||
|
||||
if device_role == "server":
|
||||
has_users = db.session.scalar(select(WebUser.id).limit(1)) is not None
|
||||
if not has_users:
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Создайте учётную запись зоотехника."},
|
||||
), 400
|
||||
write_sync_client_state({"role": "server", "server_url": ""})
|
||||
if not current_app.config.get("TESTING"):
|
||||
try:
|
||||
from sync_client import stop_sync_client
|
||||
|
||||
stop_sync_client()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mark_step_completed(current_app, "done")
|
||||
final = mark_setup_complete(current_app)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"setup": setup_snapshot(current_app),
|
||||
"redirect": setup_complete_redirect(device_role),
|
||||
"message": "Первоначальная настройка завершена.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/reset")
|
||||
@require_superuser
|
||||
def setup_reset():
|
||||
"""Только флаг мастера (БД не трогает). Для полного сброса — POST /api/setup/factory-reset."""
|
||||
reset_setup(current_app)
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Флаг setup_completed сброшен. Откройте /setup для повторной настройки.",
|
||||
"setup": setup_snapshot(current_app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/factory-reset")
|
||||
@require_superuser
|
||||
def setup_factory_reset():
|
||||
"""Удаляет БД, JSON в data/, логи; sync по умолчанию role=server. Нужен перезапуск процесса."""
|
||||
from app.services.factory_reset import (
|
||||
factory_reset_app,
|
||||
validate_factory_reset_request,
|
||||
)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
ok, err, remove_logs, safety_backup = validate_factory_reset_request(payload)
|
||||
if not ok:
|
||||
return jsonify({"status": "error", "message": err}), 400
|
||||
|
||||
report = factory_reset_app(
|
||||
current_app,
|
||||
remove_logs=remove_logs,
|
||||
safety_sqlite_backup=safety_backup,
|
||||
)
|
||||
code = 200 if report.get("ok") else 500
|
||||
return jsonify({"status": "success" if report.get("ok") else "error", **report}), code
|
||||
@@ -0,0 +1,57 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import ComponentStock
|
||||
from app.routes.auth_decorators import require_auth
|
||||
|
||||
bp = Blueprint("sklad", __name__, url_prefix="/api/sklad")
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def sklad_ping():
|
||||
"""Временный health-check эндпоинт для модуля склада."""
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@bp.get("/components")
|
||||
@require_auth
|
||||
def list_component_stock():
|
||||
"""Список складских остатков с пагинацией."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 100))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return _error("Некорректные параметры пагинации", 400)
|
||||
|
||||
rows = db.session.execute(
|
||||
select(ComponentStock)
|
||||
.where(ComponentStock.is_deleted.is_(False))
|
||||
.order_by(
|
||||
ComponentStock.sort_order.asc(),
|
||||
ComponentStock.updated_at.desc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": s.id,
|
||||
"component_id": s.component_id,
|
||||
"component_name": s.component_name,
|
||||
"total_kg": s.total_kg,
|
||||
"inflow_kg": s.inflow_kg,
|
||||
"baseline_consumed_kg": s.baseline_consumed_kg,
|
||||
"stocktake_at": s.stocktake_at.isoformat() if s.stocktake_at else None,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
"updated_by": s.updated_by,
|
||||
}
|
||||
for s in rows
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Статус фоновой инициализации (доступен до готовности остальных API)."""
|
||||
|
||||
from flask import Blueprint, current_app, jsonify
|
||||
|
||||
from app.services.hardware_settings_service import default_app_landing_path
|
||||
from app.services.startup_background import is_startup_ready_implicit
|
||||
from app.services.startup_state import startup_status_payload
|
||||
|
||||
bp = Blueprint("startup", __name__, url_prefix="/api/startup")
|
||||
|
||||
|
||||
@bp.get("/status")
|
||||
def startup_status():
|
||||
payload = startup_status_payload()
|
||||
payload["default_next"] = default_app_landing_path()
|
||||
if is_startup_ready_implicit(current_app):
|
||||
payload = {**payload, "ready": True, "phase": "ready"}
|
||||
code = 200 if payload.get("ready") else 503
|
||||
return jsonify({"status": "success" if payload.get("ready") else "starting", **payload}), code
|
||||
@@ -0,0 +1,960 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
import gzip
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
from sqlalchemy import and_, func, select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
SyncClient,
|
||||
SyncClientDisplayName,
|
||||
SyncConflict,
|
||||
SyncDelivery,
|
||||
SyncMetadata,
|
||||
SyncQueue,
|
||||
)
|
||||
from app.routes.auth_decorators import require_auth, require_paired_terminal
|
||||
from app.services.sync_manager import (
|
||||
REPORT_TABLES,
|
||||
SERVER_MASTER_TABLES,
|
||||
SyncManager,
|
||||
apply_sync_change,
|
||||
build_consistency_snapshot,
|
||||
bootstrap_universal_sync,
|
||||
format_sync_pull_out_line,
|
||||
)
|
||||
from app.services.client_log_upload import store_client_log
|
||||
from app.services.sync_error_display import sync_error_text_for_display
|
||||
from app.services.sync_runtime import sync_runtime
|
||||
from app.routes.sync_request_parser import (
|
||||
parse_confirm_payload,
|
||||
parse_json_request,
|
||||
parse_pull_payload,
|
||||
parse_push_payload,
|
||||
)
|
||||
from config import Config
|
||||
|
||||
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sync_payload_message(payload: Any) -> str:
|
||||
if isinstance(payload, dict):
|
||||
m = payload.get("message")
|
||||
if m is not None and str(m).strip():
|
||||
return str(m).strip()[:2000]
|
||||
err = payload.get("error")
|
||||
if err is not None and err is not True:
|
||||
return str(err)[:2000]
|
||||
return str(payload)[:2000] if payload is not None else ""
|
||||
|
||||
|
||||
def _log_sync_manager_result(operation: str, client_id: str, result: dict) -> None:
|
||||
"""process_* часто возвращает 4xx/5xx без исключения — без этой строки в логах пусто."""
|
||||
code = int(result.get("status_code") or 200)
|
||||
if code < 400:
|
||||
return
|
||||
pl = result.get("payload")
|
||||
msg = _sync_payload_message(pl)
|
||||
remote = (getattr(request, "remote_addr", None) or "")[:48]
|
||||
log_fn = logger.error if code >= 500 else logger.warning
|
||||
log_fn(
|
||||
"[%s-FAIL] HTTP %s client_id=%s remote=%s %s",
|
||||
operation,
|
||||
code,
|
||||
(client_id or "")[:36],
|
||||
remote,
|
||||
msg or "—",
|
||||
)
|
||||
|
||||
|
||||
def error_response(message: str, status_code: int = 400, **extra: Any):
|
||||
payload: Dict[str, Any] = {"error": True, "message": message}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jsonify(payload), status_code
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def sync_ping():
|
||||
from app.services.lan_network import detect_lan_ip
|
||||
|
||||
return jsonify({"status": "ok", "lan_ip": detect_lan_ip() or None}), 200
|
||||
|
||||
|
||||
@bp.get("/clients")
|
||||
@require_auth
|
||||
def sync_clients():
|
||||
clients = db.session.execute(
|
||||
select(SyncClient)
|
||||
.where(SyncClient.is_deleted.is_(False))
|
||||
.order_by(SyncClient.last_seen.desc().nullslast(), SyncClient.created_at.desc())
|
||||
).scalars().all()
|
||||
names = {
|
||||
n.node_id: n.display_name
|
||||
for n in db.session.execute(select(SyncClientDisplayName)).scalars().all()
|
||||
}
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"node_id": c.node_id,
|
||||
"client_name": c.client_name,
|
||||
"display_name": names.get(c.node_id),
|
||||
"ip_address": c.ip_address,
|
||||
"status": c.status,
|
||||
"last_seen": c.last_seen.isoformat() if c.last_seen else None,
|
||||
"is_enabled": c.is_enabled,
|
||||
"total_syncs": c.total_syncs,
|
||||
"sync_error": sync_error_text_for_display(c.last_error),
|
||||
}
|
||||
for c in clients
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/clients/<string:node_id>/deliveries")
|
||||
@require_auth
|
||||
def sync_client_deliveries(node_id: str):
|
||||
try:
|
||||
hours = int(request.args.get("hours", 24))
|
||||
except ValueError:
|
||||
return error_response("hours должен быть числом", 400)
|
||||
if hours <= 0:
|
||||
hours = 24
|
||||
|
||||
client = db.session.execute(
|
||||
select(SyncClient).where(
|
||||
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not client:
|
||||
return error_response("Клиент не найден", 404)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
cutoff = datetime.now() - timedelta(hours=hours)
|
||||
rows = db.session.execute(
|
||||
select(SyncDelivery, SyncQueue)
|
||||
.join(SyncQueue, SyncDelivery.task_id == SyncQueue.id)
|
||||
.where(SyncDelivery.client_id == client.id)
|
||||
.where(SyncDelivery.delivered_at >= cutoff)
|
||||
.order_by(SyncDelivery.delivered_at.desc())
|
||||
.limit(500)
|
||||
).all()
|
||||
|
||||
deliveries = []
|
||||
for d, task in rows:
|
||||
deliveries.append(
|
||||
{
|
||||
"task_id": d.task_id,
|
||||
"table_name": task.table_name,
|
||||
"action": task.action,
|
||||
"record_id": task.record_id,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"processed_at": task.processed_at.isoformat() if task.processed_at else None,
|
||||
"delivered_at": d.delivered_at.isoformat() if d.delivered_at else None,
|
||||
"name": task.record_id,
|
||||
"context": task.table_name,
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({"deliveries": deliveries})
|
||||
|
||||
|
||||
@bp.put("/clients/<string:node_id>/display_name")
|
||||
@require_auth
|
||||
def sync_client_set_display_name(node_id: str):
|
||||
data = request.get_json() or {}
|
||||
display_name = (data.get("display_name") or "").strip()
|
||||
|
||||
client = db.session.execute(
|
||||
select(SyncClient).where(
|
||||
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not client:
|
||||
return error_response("Клиент не найден", 404)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
row = db.session.execute(
|
||||
select(SyncClientDisplayName).where(SyncClientDisplayName.node_id == node_id)
|
||||
).scalar_one_or_none()
|
||||
if not display_name:
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "message": "Имя клиента сброшено"})
|
||||
|
||||
if not row:
|
||||
row = SyncClientDisplayName(
|
||||
node_id=node_id,
|
||||
display_name=display_name,
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.display_name = display_name
|
||||
row.updated_at = datetime.now()
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "display_name": display_name})
|
||||
|
||||
|
||||
@bp.delete("/clients/<string:node_id>")
|
||||
@require_auth
|
||||
def sync_client_delete(node_id: str):
|
||||
client = db.session.execute(
|
||||
select(SyncClient).where(
|
||||
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not client:
|
||||
return error_response("Клиент не найден", 404)
|
||||
|
||||
client.is_deleted = True
|
||||
client.status = "disabled"
|
||||
client.is_enabled = False
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "message": "Клиент удалён"})
|
||||
|
||||
|
||||
@bp.get("/metadata")
|
||||
@require_auth
|
||||
def sync_metadata():
|
||||
row = db.session.execute(
|
||||
select(SyncMetadata)
|
||||
.where(SyncMetadata.is_deleted.is_(False))
|
||||
.order_by(SyncMetadata.created_at.asc())
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
return error_response("Метаданные синхронизации не найдены", 404)
|
||||
return jsonify(
|
||||
{
|
||||
"id": row.id,
|
||||
"node_id": row.node_id,
|
||||
"node_type": row.node_type,
|
||||
"node_name": row.node_name,
|
||||
"node_status": row.node_status,
|
||||
"last_heartbeat": row.last_heartbeat.isoformat() if row.last_heartbeat else None,
|
||||
"last_sync": row.last_sync.isoformat() if row.last_sync else None,
|
||||
"sync_status": row.sync_status,
|
||||
"is_enabled": row.is_enabled,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/clients/<string:node_id>")
|
||||
@require_auth
|
||||
def sync_client_details(node_id: str):
|
||||
client = db.session.execute(
|
||||
select(SyncClient).where(
|
||||
SyncClient.node_id == node_id, SyncClient.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not client:
|
||||
return error_response("Клиент не найден", 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,
|
||||
"sync_error": sync_error_text_for_display(client.last_error),
|
||||
"is_enabled": client.is_enabled,
|
||||
"created_at": client.created_at.isoformat() if client.created_at else None,
|
||||
"updated_at": client.updated_at.isoformat() if client.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/queue")
|
||||
@require_auth
|
||||
def sync_queue_list():
|
||||
status = (request.args.get("status") or "").strip()
|
||||
priority_raw = (request.args.get("priority") or "").strip()
|
||||
try:
|
||||
limit = int(request.args.get("limit", 100))
|
||||
except ValueError:
|
||||
return error_response("limit должен быть числом", 400)
|
||||
|
||||
query = select(SyncQueue).where(SyncQueue.is_deleted.is_(False))
|
||||
if status:
|
||||
query = query.where(SyncQueue.status == status)
|
||||
if priority_raw:
|
||||
try:
|
||||
priority = int(priority_raw)
|
||||
except ValueError:
|
||||
return error_response("priority должен быть числом", 400)
|
||||
query = query.where(SyncQueue.priority == priority)
|
||||
|
||||
rows = db.session.execute(
|
||||
query.order_by(SyncQueue.priority.asc(), SyncQueue.created_at.asc()).limit(
|
||||
max(1, min(limit, 1000))
|
||||
)
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": r.id,
|
||||
"table_name": r.table_name,
|
||||
"record_id": r.record_id,
|
||||
"action": r.action,
|
||||
"status": r.status,
|
||||
"target_node_id": r.target_node_id,
|
||||
"source_node_id": r.source_node_id,
|
||||
"priority": r.priority,
|
||||
"retry_count": r.retry_count,
|
||||
"max_retries": r.max_retries,
|
||||
"error_message": r.error_message,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"processed_at": r.processed_at.isoformat() if r.processed_at else None,
|
||||
"completed_at": r.completed_at.isoformat() if r.completed_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/queue/stats")
|
||||
@require_auth
|
||||
def sync_queue_stats():
|
||||
base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery()
|
||||
stats = {
|
||||
"total": db.session.scalar(select(func.count()).select_from(base)) or 0,
|
||||
"pending": db.session.scalar(
|
||||
select(func.count()).select_from(base).where(base.c.status == "pending")
|
||||
)
|
||||
or 0,
|
||||
"processing": db.session.scalar(
|
||||
select(func.count()).select_from(base).where(base.c.status == "processing")
|
||||
)
|
||||
or 0,
|
||||
"completed": db.session.scalar(
|
||||
select(func.count()).select_from(base).where(base.c.status == "completed")
|
||||
)
|
||||
or 0,
|
||||
"failed": db.session.scalar(
|
||||
select(func.count()).select_from(base).where(base.c.status == "failed")
|
||||
)
|
||||
or 0,
|
||||
"by_priority": {},
|
||||
}
|
||||
for p in range(1, 6):
|
||||
stats["by_priority"][f"priority_{p}"] = (
|
||||
db.session.scalar(
|
||||
select(func.count()).select_from(base).where(base.c.priority == p)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return jsonify(stats)
|
||||
|
||||
|
||||
@bp.post("/bootstrap")
|
||||
@require_auth
|
||||
def sync_bootstrap():
|
||||
created = bootstrap_universal_sync()
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "created": created})
|
||||
|
||||
|
||||
@bp.get("/conflicts")
|
||||
@require_auth
|
||||
def sync_conflicts():
|
||||
resolution = (request.args.get("resolution") or "").strip()
|
||||
conflict_type = (request.args.get("conflict_type") or "").strip()
|
||||
try:
|
||||
limit = int(request.args.get("limit", 50))
|
||||
except ValueError:
|
||||
return error_response("limit должен быть числом", 400)
|
||||
|
||||
query = select(SyncConflict).where(SyncConflict.is_deleted.is_(False))
|
||||
if resolution:
|
||||
query = query.where(SyncConflict.resolution == resolution)
|
||||
if conflict_type:
|
||||
query = query.where(SyncConflict.conflict_type == conflict_type)
|
||||
rows = db.session.execute(
|
||||
query.order_by(SyncConflict.created_at.desc()).limit(max(1, min(limit, 1000)))
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": c.id,
|
||||
"table_name": c.table_name,
|
||||
"record_id": c.record_id,
|
||||
"conflict_type": c.conflict_type,
|
||||
"local_data": c.local_data,
|
||||
"remote_data": c.remote_data,
|
||||
"resolution": c.resolution,
|
||||
"resolved_by": c.resolved_by,
|
||||
"resolved_at": c.resolved_at.isoformat() if c.resolved_at else None,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
for c in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/conflicts/<string:conflict_id>/resolve")
|
||||
@require_auth
|
||||
def sync_conflict_resolve(conflict_id: str):
|
||||
row = db.session.execute(
|
||||
select(SyncConflict).where(
|
||||
SyncConflict.id == conflict_id, SyncConflict.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
return error_response("Конфликт не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
resolution = (data.get("resolution") or "").strip()
|
||||
if resolution not in {"local", "remote", "merged"}:
|
||||
return error_response("Неверное разрешение конфликта", 400)
|
||||
|
||||
local_data = json.loads(row.local_data) if row.local_data else None
|
||||
remote_data = json.loads(row.remote_data) if row.remote_data else None
|
||||
|
||||
is_server_master = row.table_name in SERVER_MASTER_TABLES
|
||||
is_client_master = row.table_name in REPORT_TABLES
|
||||
|
||||
applied_payload: Any = None
|
||||
if resolution == "local":
|
||||
if local_data:
|
||||
applied_payload = local_data
|
||||
elif resolution == "remote":
|
||||
if remote_data:
|
||||
applied_payload = remote_data
|
||||
elif resolution == "merged":
|
||||
if is_server_master and remote_data:
|
||||
applied_payload = remote_data
|
||||
elif is_client_master and local_data:
|
||||
applied_payload = local_data
|
||||
elif remote_data:
|
||||
applied_payload = remote_data
|
||||
|
||||
if applied_payload:
|
||||
result = apply_sync_change(row.table_name, row.record_id, "update", applied_payload)
|
||||
if not result["success"]:
|
||||
return error_response(
|
||||
f"Ошибка применения изменений: {result.get('error', 'unknown')}",
|
||||
500,
|
||||
)
|
||||
|
||||
row.resolution = resolution
|
||||
row.resolved_by = (data.get("resolved_by") or "admin").strip() or "admin"
|
||||
row.resolved_at = datetime.now()
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Конфликт разрешен: {resolution}",
|
||||
"conflict_id": row.id,
|
||||
"resolved_by": row.resolved_by,
|
||||
"resolved_at": row.resolved_at.isoformat() if row.resolved_at else None,
|
||||
"applied_data": applied_payload is not None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/status")
|
||||
@require_auth
|
||||
def sync_status():
|
||||
metadata = db.session.execute(
|
||||
select(SyncMetadata).where(SyncMetadata.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
|
||||
clients_base = select(SyncClient).where(SyncClient.is_deleted.is_(False)).subquery()
|
||||
clients_stats = {
|
||||
"total": db.session.scalar(select(func.count()).select_from(clients_base)) or 0,
|
||||
"active": db.session.scalar(
|
||||
select(func.count()).select_from(clients_base).where(clients_base.c.status == "active")
|
||||
)
|
||||
or 0,
|
||||
"offline": db.session.scalar(
|
||||
select(func.count()).select_from(clients_base).where(clients_base.c.status == "offline")
|
||||
)
|
||||
or 0,
|
||||
"disabled": db.session.scalar(
|
||||
select(func.count()).select_from(clients_base).where(clients_base.c.status == "disabled")
|
||||
)
|
||||
or 0,
|
||||
}
|
||||
|
||||
queue_base = select(SyncQueue).where(SyncQueue.is_deleted.is_(False)).subquery()
|
||||
queue_stats = {
|
||||
"total": db.session.scalar(select(func.count()).select_from(queue_base)) or 0,
|
||||
"pending": db.session.scalar(
|
||||
select(func.count()).select_from(queue_base).where(queue_base.c.status == "pending")
|
||||
)
|
||||
or 0,
|
||||
"processing": db.session.scalar(
|
||||
select(func.count()).select_from(queue_base).where(queue_base.c.status == "processing")
|
||||
)
|
||||
or 0,
|
||||
"completed": db.session.scalar(
|
||||
select(func.count()).select_from(queue_base).where(queue_base.c.status == "completed")
|
||||
)
|
||||
or 0,
|
||||
"failed": db.session.scalar(
|
||||
select(func.count()).select_from(queue_base).where(queue_base.c.status == "failed")
|
||||
)
|
||||
or 0,
|
||||
}
|
||||
|
||||
conflicts_base = select(SyncConflict).where(SyncConflict.is_deleted.is_(False)).subquery()
|
||||
conflicts_stats = {
|
||||
"total": db.session.scalar(select(func.count()).select_from(conflicts_base)) or 0,
|
||||
"pending": db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(conflicts_base)
|
||||
.where(
|
||||
(conflicts_base.c.resolution == "pending")
|
||||
| (conflicts_base.c.resolution.is_(None))
|
||||
)
|
||||
)
|
||||
or 0,
|
||||
"resolved": db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(conflicts_base)
|
||||
.where(
|
||||
(conflicts_base.c.resolution != "pending")
|
||||
& (conflicts_base.c.resolution.is_not(None))
|
||||
)
|
||||
)
|
||||
or 0,
|
||||
"by_type": {
|
||||
"version_mismatch": db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(conflicts_base)
|
||||
.where(conflicts_base.c.conflict_type == "version_mismatch")
|
||||
)
|
||||
or 0,
|
||||
"application_error": db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(conflicts_base)
|
||||
.where(conflicts_base.c.conflict_type == "application_error")
|
||||
)
|
||||
or 0,
|
||||
"deletion": db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(conflicts_base)
|
||||
.where(conflicts_base.c.conflict_type == "deletion")
|
||||
)
|
||||
or 0,
|
||||
"hash": db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(conflicts_base)
|
||||
.where(conflicts_base.c.conflict_type == "hash")
|
||||
)
|
||||
or 0,
|
||||
},
|
||||
}
|
||||
|
||||
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": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/consistency")
|
||||
@require_auth
|
||||
def sync_consistency():
|
||||
"""Read-only checksum summary for master-data consistency checks."""
|
||||
return jsonify({"success": True, "snapshot": build_consistency_snapshot()})
|
||||
|
||||
|
||||
@bp.post("/init")
|
||||
@require_auth
|
||||
def sync_init_metadata():
|
||||
row = db.session.execute(
|
||||
select(SyncMetadata).where(SyncMetadata.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
row = SyncMetadata(
|
||||
node_id="server-local",
|
||||
node_type="server",
|
||||
node_name="WESP Server",
|
||||
node_status="active",
|
||||
sync_status="idle",
|
||||
is_enabled=True,
|
||||
)
|
||||
db.session.add(row)
|
||||
row.last_heartbeat = datetime.now()
|
||||
db.session.commit()
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Метаданные синхронизации инициализированы",
|
||||
"node_id": row.node_id,
|
||||
"node_type": row.node_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/register")
|
||||
def sync_register_client():
|
||||
data, parse_error = parse_json_request(request)
|
||||
if parse_error:
|
||||
return error_response(parse_error, 400)
|
||||
assert data is not None
|
||||
|
||||
client_id = (data.get("client_id") or "").strip()
|
||||
if not client_id:
|
||||
return error_response("client_id обязателен", 400)
|
||||
|
||||
client = db.session.execute(
|
||||
select(SyncClient).where(SyncClient.node_id == client_id)
|
||||
).scalar_one_or_none()
|
||||
now = datetime.now()
|
||||
if client:
|
||||
client.client_name = (data.get("client_name") or client.client_name or "Unknown").strip()
|
||||
client.ip_address = request.remote_addr
|
||||
client.port = int(data.get("port", client.port or 80))
|
||||
client.status = "active"
|
||||
client.last_seen = now
|
||||
client.is_enabled = True
|
||||
client.is_deleted = False
|
||||
else:
|
||||
client = SyncClient(
|
||||
node_id=client_id,
|
||||
client_name=(data.get("client_name") or "Unknown").strip() or "Unknown",
|
||||
ip_address=request.remote_addr,
|
||||
port=int(data.get("port", 80)),
|
||||
status="active",
|
||||
last_seen=now,
|
||||
total_syncs=0,
|
||||
is_enabled=True,
|
||||
)
|
||||
db.session.add(client)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "message": "Клиент зарегистрирован", "client_id": client_id})
|
||||
|
||||
|
||||
@bp.post("/check-db")
|
||||
@require_auth
|
||||
def sync_check_db():
|
||||
payload = {
|
||||
"success": True,
|
||||
"checks": {
|
||||
"sync_clients": db.session.scalar(
|
||||
select(func.count()).select_from(SyncClient).where(SyncClient.is_deleted.is_(False))
|
||||
)
|
||||
or 0,
|
||||
"sync_queue": db.session.scalar(
|
||||
select(func.count()).select_from(SyncQueue).where(SyncQueue.is_deleted.is_(False))
|
||||
)
|
||||
or 0,
|
||||
"sync_conflicts": db.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(SyncConflict)
|
||||
.where(SyncConflict.is_deleted.is_(False))
|
||||
)
|
||||
or 0,
|
||||
},
|
||||
}
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.post("/check-db-client")
|
||||
@require_paired_terminal
|
||||
def sync_check_db_client():
|
||||
data = request.get_json(silent=True) or {}
|
||||
client_id = (data.get("client_id") or "").strip()
|
||||
if not client_id:
|
||||
return error_response("client_id обязателен", 400)
|
||||
client = db.session.execute(
|
||||
select(SyncClient).where(
|
||||
SyncClient.node_id == client_id, SyncClient.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not client:
|
||||
return error_response("Клиент не найден", 404)
|
||||
tasks = db.session.scalar(
|
||||
select(func.count()).select_from(SyncQueue).where(
|
||||
SyncQueue.is_deleted.is_(False),
|
||||
(SyncQueue.target_node_id == client_id) | (SyncQueue.target_node_id.is_(None)),
|
||||
)
|
||||
) or 0
|
||||
return jsonify({"success": True, "client_id": client_id, "pending_tasks": tasks})
|
||||
|
||||
|
||||
@bp.post("/restore-db")
|
||||
@require_paired_terminal
|
||||
def sync_restore_db():
|
||||
# Legacy endpoint compatibility: explicit restore action is intentionally disabled
|
||||
# in refactored architecture to avoid unsafe ad-hoc DB replacement.
|
||||
return jsonify({"success": False, "message": "Операция restore-db отключена"}), 501
|
||||
|
||||
|
||||
@bp.post("/pull")
|
||||
def sync_pull():
|
||||
start_time = time.time()
|
||||
client_id_for_lock = ""
|
||||
pull_lock_acquired = False
|
||||
slot_acquired = False
|
||||
|
||||
try:
|
||||
data, parse_error = parse_json_request(request)
|
||||
if parse_error:
|
||||
return error_response(parse_error, 400)
|
||||
assert data is not None
|
||||
|
||||
client_id, limit, pull_client_name, payload_error = parse_pull_payload(data)
|
||||
if payload_error:
|
||||
return error_response(payload_error, 400)
|
||||
assert client_id is not None
|
||||
client_id_for_lock = client_id
|
||||
|
||||
init_hdr = (request.headers.get("X-WESP-Initial-Sync") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
steady_wait = float(getattr(Config, "SYNC_PULL_CLIENT_LOCK_WAIT_SEC", 180.0))
|
||||
initial_wait = float(getattr(Config, "SYNC_PULL_INITIAL_LOCK_WAIT_SEC", 600.0))
|
||||
lock_wait = initial_wait if init_hdr else steady_wait
|
||||
if not sync_runtime.acquire_pull_client(client_id, wait_sec=lock_wait):
|
||||
logger.warning(
|
||||
"[SYNC-PULL-CLIENT-TIMEOUT] Не получен lock pull за %ss (%s) client_id=%s…",
|
||||
lock_wait,
|
||||
"initial" if init_hdr else "steady",
|
||||
(client_id or "")[:12],
|
||||
)
|
||||
return error_response(
|
||||
"Предыдущая синхронизация этого узла ещё выполняется (превышено время ожидания). Повторите позже.",
|
||||
503,
|
||||
)
|
||||
pull_lock_acquired = True
|
||||
|
||||
used_slots, available_slots = sync_runtime.concurrency_status()
|
||||
if not sync_runtime.acquire_slot():
|
||||
logger.warning(
|
||||
"[SYNC-PULL-BLOCKED] Все слоты заняты (%s/%s)",
|
||||
used_slots,
|
||||
Config.SYNC_MAX_CONCURRENT,
|
||||
)
|
||||
return error_response(
|
||||
"Слишком много одновременных синхронизаций. Попробуйте позже.",
|
||||
429,
|
||||
)
|
||||
slot_acquired = True
|
||||
|
||||
logger.info(
|
||||
"[SYNC-PULL-START] client_id=%s… доступно слотов: %s/%s",
|
||||
(client_id or "")[:12],
|
||||
available_slots - 1,
|
||||
Config.SYNC_MAX_CONCURRENT,
|
||||
)
|
||||
|
||||
result = SyncManager.process_pull(
|
||||
client_id=client_id,
|
||||
limit=limit,
|
||||
client_name=pull_client_name,
|
||||
client_ip=request.remote_addr,
|
||||
initial_sync_header=init_hdr,
|
||||
)
|
||||
_log_sync_manager_result("SYNC-PULL", client_id, result)
|
||||
status_code = int(result["status_code"])
|
||||
payload = result["payload"]
|
||||
if isinstance(payload, dict) and status_code in (200, 202):
|
||||
logger.info(format_sync_pull_out_line(client_id, status_code, payload))
|
||||
elapsed = time.time() - start_time
|
||||
sync_runtime.record_pull_duration(elapsed)
|
||||
p95 = sync_runtime.pull_duration_p95()
|
||||
dur_ms = str(int(max(0.0, elapsed) * 1000))
|
||||
p95_ms = str(int(max(0.0, p95) * 1000))
|
||||
if status_code == 202:
|
||||
resp = jsonify(payload)
|
||||
retry_after = int((payload or {}).get("retry_after_sec") or 2)
|
||||
resp.headers["Retry-After"] = str(max(1, retry_after))
|
||||
resp.headers["X-WESP-Pull-Duration-Ms"] = dur_ms
|
||||
resp.headers["X-WESP-Pull-P95-Ms"] = p95_ms
|
||||
return resp, 202
|
||||
resp = jsonify(payload)
|
||||
resp.headers["X-WESP-Pull-Duration-Ms"] = dur_ms
|
||||
resp.headers["X-WESP-Pull-P95-Ms"] = p95_ms
|
||||
return resp, status_code
|
||||
except Exception as e:
|
||||
logger.error("[SYNC-PULL-ERROR] Ошибка sync_pull: %s", e, exc_info=True)
|
||||
return error_response(str(e), 500)
|
||||
finally:
|
||||
logger.debug("[SYNC-PULL-END] client_id=%s…", (client_id_for_lock or "")[:12])
|
||||
if slot_acquired:
|
||||
sync_runtime.release_slot()
|
||||
if pull_lock_acquired and client_id_for_lock:
|
||||
sync_runtime.release_pull_client(client_id_for_lock)
|
||||
|
||||
|
||||
@bp.post("/confirm")
|
||||
def sync_confirm():
|
||||
start_time = time.time()
|
||||
try:
|
||||
data, parse_error = parse_json_request(request)
|
||||
if parse_error:
|
||||
return error_response(parse_error, 400)
|
||||
assert data is not None
|
||||
|
||||
client_id, task_ids, payload_error = parse_confirm_payload(data)
|
||||
if payload_error:
|
||||
return error_response(payload_error, 400)
|
||||
assert client_id is not None and task_ids is not None
|
||||
|
||||
result = SyncManager.process_confirm(client_id=client_id, task_ids=task_ids)
|
||||
_log_sync_manager_result("SYNC-CONFIRM", client_id, result)
|
||||
return jsonify(result["payload"]), result["status_code"]
|
||||
except Exception as e:
|
||||
logger.error("[SYNC-CONFIRM-ERROR] Ошибка sync_confirm: %s", e, exc_info=True)
|
||||
return error_response(str(e), 500)
|
||||
finally:
|
||||
logger.debug("[SYNC-CONFIRM-END] duration=%.3fs", time.time() - start_time)
|
||||
|
||||
|
||||
@bp.post("/client-log")
|
||||
def sync_client_log_upload():
|
||||
"""Приём файла лога с полевого клиента (отдельно от sync БД)."""
|
||||
if not current_app.config.get("WESP_CLIENT_LOG_UPLOAD_ENABLED", True):
|
||||
return error_response("Выгрузка логов с клиентов отключена", 403)
|
||||
|
||||
secret = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_SECRET") or "").strip()
|
||||
if secret and request.headers.get("X-WESP-Client-Log-Secret", "").strip() != secret:
|
||||
return error_response("Неверный секрет выгрузки логов", 401)
|
||||
|
||||
max_b = int(current_app.config.get("WESP_CLIENT_LOG_UPLOAD_MAX_BYTES") or 8_388_608)
|
||||
client_id = (request.headers.get("X-WESP-Client-Id") or "").strip()
|
||||
log_name = (request.headers.get("X-WESP-Log-Name") or "wesp.log").strip()
|
||||
|
||||
raw: bytes
|
||||
if request.is_json:
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
return error_response("Ожидается JSON-объект", 400)
|
||||
if not client_id:
|
||||
client_id = str(data.get("client_id") or "").strip()
|
||||
ln = data.get("log_name") or data.get("name")
|
||||
if ln:
|
||||
log_name = str(ln).strip()
|
||||
b64 = data.get("content_base64")
|
||||
text = data.get("content") if "content" in data else data.get("text")
|
||||
if b64 is not None:
|
||||
import base64
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(str(b64), validate=False)
|
||||
except Exception:
|
||||
return error_response("Некорректный content_base64", 400)
|
||||
elif text is not None:
|
||||
raw = str(text).encode("utf-8")
|
||||
else:
|
||||
return error_response("Укажите content (или text) либо content_base64", 400)
|
||||
else:
|
||||
if not client_id:
|
||||
return error_response("Заголовок X-WESP-Client-Id обязателен", 400)
|
||||
raw = request.get_data(cache=False) or b""
|
||||
if request.headers.get("Content-Encoding", "").lower() == "gzip":
|
||||
try:
|
||||
raw = gzip.decompress(raw)
|
||||
except Exception:
|
||||
return error_response("Ошибка распаковки gzip", 400)
|
||||
|
||||
if not client_id:
|
||||
return error_response("client_id обязателен", 400)
|
||||
if len(raw) > max_b:
|
||||
return error_response(f"Превышен лимит размера лога ({max_b} байт)", 413)
|
||||
|
||||
if not sync_runtime.check_rate_limit(client_id):
|
||||
return error_response("Превышен лимит запросов", 429)
|
||||
|
||||
upload_dir = (current_app.config.get("WESP_CLIENT_LOG_UPLOAD_DIR") or "").strip()
|
||||
if not upload_dir:
|
||||
return error_response("Сервер не настроен на приём логов", 500)
|
||||
|
||||
try:
|
||||
meta = store_client_log(
|
||||
upload_dir=upload_dir,
|
||||
client_id=client_id,
|
||||
log_basename=log_name,
|
||||
raw=raw,
|
||||
)
|
||||
except ValueError as e:
|
||||
return error_response(str(e), 400)
|
||||
|
||||
logger.info(
|
||||
"[CLIENT-LOG] client=%s size=%s -> %s",
|
||||
client_id[:16],
|
||||
meta.get("bytes_written"),
|
||||
meta.get("relative"),
|
||||
)
|
||||
return jsonify({"success": True, **meta}), 200
|
||||
|
||||
|
||||
@bp.post("/push")
|
||||
def sync_push():
|
||||
start_time = time.time()
|
||||
used_slots, available_slots = sync_runtime.concurrency_status()
|
||||
if not sync_runtime.acquire_slot():
|
||||
logger.warning(
|
||||
"[SYNC-PUSH-BLOCKED] Запрос заблокирован: все слоты заняты (%s/%s)",
|
||||
used_slots,
|
||||
Config.SYNC_MAX_CONCURRENT,
|
||||
)
|
||||
return error_response(
|
||||
"Слишком много одновременных синхронизаций. Попробуйте позже.",
|
||||
429,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[SYNC-PUSH-START] Начало обработки, доступно слотов: %s/%s",
|
||||
available_slots - 1,
|
||||
Config.SYNC_MAX_CONCURRENT,
|
||||
)
|
||||
|
||||
try:
|
||||
data, parse_error = parse_json_request(request, allow_gzip=True)
|
||||
if parse_error:
|
||||
return error_response(parse_error, 400)
|
||||
assert data is not None
|
||||
|
||||
client_id, changes, payload_error = parse_push_payload(data)
|
||||
if payload_error:
|
||||
return error_response(payload_error, 400)
|
||||
assert client_id is not None
|
||||
|
||||
if not sync_runtime.check_rate_limit(client_id):
|
||||
return error_response("Превышен лимит запросов. Попробуйте позже.", 429)
|
||||
|
||||
result = SyncManager.process_push(client_id=client_id, changes=changes)
|
||||
_log_sync_manager_result("SYNC-PUSH", client_id, result)
|
||||
return jsonify(result["payload"]), result["status_code"]
|
||||
except Exception as e:
|
||||
logger.error("[SYNC-PUSH-ERROR] Ошибка sync_push: %s", e, exc_info=True)
|
||||
return error_response(str(e), 500)
|
||||
finally:
|
||||
logger.debug("[SYNC-PUSH-END] duration=%.3fs", time.time() - start_time)
|
||||
sync_runtime.release_slot()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from flask import Request
|
||||
|
||||
|
||||
def parse_json_request(req: Request, allow_gzip: bool = False) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Парсинг JSON-тела запроса с опциональной поддержкой gzip."""
|
||||
if allow_gzip and req.headers.get("Content-Encoding", "") == "gzip":
|
||||
import gzip
|
||||
|
||||
try:
|
||||
payload = gzip.decompress(req.data)
|
||||
data = json.loads(payload.decode("utf-8"))
|
||||
except (gzip.BadGzipFile, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None, "Ошибка распаковки сжатых данных"
|
||||
if not isinstance(data, dict):
|
||||
return None, "Тело запроса должно быть JSON-объектом"
|
||||
return data, None
|
||||
|
||||
if not req.is_json:
|
||||
return None, "Content-Type должен быть application/json"
|
||||
|
||||
data = req.get_json() or {}
|
||||
if not isinstance(data, dict):
|
||||
return None, "Тело запроса должно быть JSON-объектом"
|
||||
return data, None
|
||||
|
||||
|
||||
def parse_pull_payload(
|
||||
data: Dict[str, Any],
|
||||
) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[str]]:
|
||||
"""(client_id, limit, client_name, error). client_name — опционально для реестра на сервере."""
|
||||
client_id = data.get("client_id")
|
||||
if not client_id:
|
||||
return None, None, None, "client_id обязателен"
|
||||
|
||||
limit = data.get("limit")
|
||||
lim: Optional[int] = None
|
||||
if limit is not None:
|
||||
try:
|
||||
lim = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
return None, None, None, "limit должен быть числом"
|
||||
|
||||
cn_raw = data.get("client_name")
|
||||
client_name: Optional[str] = None
|
||||
if cn_raw is not None:
|
||||
s = str(cn_raw).strip()
|
||||
if s:
|
||||
client_name = s[:100]
|
||||
|
||||
return str(client_id).strip(), lim, client_name, None
|
||||
|
||||
|
||||
def parse_confirm_payload(data: Dict[str, Any]) -> Tuple[Optional[str], Optional[list], Optional[str]]:
|
||||
client_id = data.get("client_id")
|
||||
task_ids = data.get("task_ids") or []
|
||||
if not client_id:
|
||||
return None, None, "client_id обязателен"
|
||||
if not isinstance(task_ids, list) or not task_ids:
|
||||
return None, None, "task_ids обязателен и должен быть списком"
|
||||
return client_id, task_ids, None
|
||||
|
||||
|
||||
def parse_push_payload(data: Dict[str, Any]) -> Tuple[Optional[str], Any, Optional[str]]:
|
||||
client_id = data.get("client_id")
|
||||
if not client_id:
|
||||
return None, None, "client_id обязателен"
|
||||
changes = data.get("changes", [])
|
||||
return client_id, changes, None
|
||||
|
||||
Reference in New Issue
Block a user