@@ -0,0 +1,519 @@
|
||||
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})
|
||||
Reference in New Issue
Block a user