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