Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+519
View File
@@ -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})