@@ -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": "Компонент удалён"})
|
||||
Reference in New Issue
Block a user