Files
2026-07-17 12:57:18 +03:00

293 lines
10 KiB
Python

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": "Компонент удалён"})