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})