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