520 lines
18 KiB
Python
520 lines
18 KiB
Python
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})
|