Files
site/apps/api/app/modules/zootech/daily_plan/skips.py
T
2026-07-17 12:57:18 +03:00

549 lines
18 KiB
Python

"""Исключение рейсов и частей рейса из плана на день."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Dict, List, Optional, Set, TypeVar
from flask import request, session
from sqlalchemy import func, select
from app import db
from app.models import (
DailyIngredientSkip,
DailyTripSkip,
DailyUnloadingGroupSkip,
Ingredient,
Recipe,
UnloadingGroup,
)
from app.timeutil import utc_now_naive
TRecipe = TypeVar("TRecipe")
def is_kiosk_recipe_list_request() -> bool:
"""Запрос списка рейсов с терминала/киоска (не редактор зоотехника)."""
if request.args.get("for") in ("terminal", "kiosk"):
return True
if request.headers.get("X-Wesp-Kiosk") == "1":
return True
return False
def is_zootech_recipe_list_view() -> bool:
"""Редактор зоотехника: все рейсы + skippedToday; терминал — без skip."""
if is_kiosk_recipe_list_request():
return False
return bool(session.get("authenticated", False))
def filter_recipes_for_list_view(
recipes: List[TRecipe],
*,
plan_date: Optional[str] = None,
) -> tuple[List[TRecipe], Set[str]]:
"""Список рейсов для ответа API с учётом skip на дату."""
skipped = get_skipped_recipe_ids(plan_date)
if is_zootech_recipe_list_view():
return recipes, skipped
visible = [r for r in recipes if getattr(r, "id", None) not in skipped]
return visible, skipped
def _parse_plan_date(value: Optional[str]) -> date:
if value:
try:
return date.fromisoformat(str(value).strip()[:10])
except ValueError:
pass
return date.today()
def _skip_end_expr(model):
return func.coalesce(model.valid_until, model.plan_date)
def _skip_active_filters(model, target: date):
end = _skip_end_expr(model)
return (
model.is_deleted.is_(False),
model.plan_date <= target,
end >= target,
)
def resolve_skip_range(
start: date,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
) -> tuple[date, date]:
"""Диапазон skip: today | week | date (until_date)."""
dur = (duration or "today").strip().lower()
if dur == "week":
days_to_sunday = 6 - start.weekday()
return start, start + timedelta(days=days_to_sunday)
if dur == "date" and until_date:
try:
end = date.fromisoformat(str(until_date).strip()[:10])
except ValueError:
end = start
return start, max(start, end)
return start, start
def _serialize_skip_dates(row, *, fallback: date) -> Dict[str, str]:
start = row.plan_date.isoformat() if row.plan_date else fallback.isoformat()
end_val = row.valid_until or row.plan_date
end = end_val.isoformat() if end_val else start
return {"date": start, "validUntil": end}
def get_skipped_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
"""Активные skip рейсов на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyTripSkip.recipe_id).where(*_skip_active_filters(DailyTripSkip, d))
).scalars().all()
return set(rows)
def get_recipe_ids_with_any_skip(plan_date: Optional[str] = None) -> Set[str]:
"""Рейсы с любым активным skip или заменой на дату."""
from app.services.daily_plan.replacements import get_replaced_recipe_ids
d = _parse_plan_date(plan_date)
ids: Set[str] = set()
for model in (DailyTripSkip, DailyIngredientSkip, DailyUnloadingGroupSkip):
rows = db.session.execute(
select(model.recipe_id).where(*_skip_active_filters(model, d))
).scalars().all()
ids.update(rows)
ids.update(get_replaced_recipe_ids(plan_date))
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
ids.update(get_adjusted_recipe_ids(plan_date))
return ids
def list_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
"""Список исключённых рейсов на дату (для UI)."""
d = _parse_plan_date(plan_date)
skips = db.session.execute(
select(DailyTripSkip, Recipe.name)
.join(Recipe, Recipe.id == DailyTripSkip.recipe_id)
.where(
*_skip_active_filters(DailyTripSkip, d),
Recipe.is_deleted.is_(False),
)
.order_by(Recipe.name.asc())
).all()
return [
{
"id": skip.id,
"recipeId": skip.recipe_id,
"recipeName": name,
**_serialize_skip_dates(skip, fallback=d),
}
for skip, name in skips
]
def get_skipped_ingredient_ids(plan_date: Optional[str] = None) -> Dict[str, Set[str]]:
"""recipe_id -> ingredient_id для активных skip на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientSkip.recipe_id, DailyIngredientSkip.ingredient_id).where(
*_skip_active_filters(DailyIngredientSkip, d)
)
).all()
out: Dict[str, Set[str]] = {}
for recipe_id, ingredient_id in rows:
out.setdefault(recipe_id, set()).add(ingredient_id)
return out
def get_skipped_unloading_group_ids(plan_date: Optional[str] = None) -> Dict[str, Set[str]]:
"""recipe_id -> unloading_group_id для активных skip на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(
DailyUnloadingGroupSkip.recipe_id,
DailyUnloadingGroupSkip.unloading_group_id,
).where(
*_skip_active_filters(DailyUnloadingGroupSkip, d)
)
).all()
out: Dict[str, Set[str]] = {}
for recipe_id, group_id in rows:
out.setdefault(recipe_id, set()).add(group_id)
return out
def list_ingredient_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
"""Список исключённых компонентов на дату (для UI)."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientSkip, Recipe.name, Ingredient.name)
.join(Recipe, Recipe.id == DailyIngredientSkip.recipe_id)
.join(Ingredient, Ingredient.id == DailyIngredientSkip.ingredient_id)
.where(
*_skip_active_filters(DailyIngredientSkip, d),
Recipe.is_deleted.is_(False),
Ingredient.is_deleted.is_(False),
)
.order_by(Recipe.name.asc(), Ingredient.order.asc())
).all()
return [
{
"id": skip.id,
"recipeId": skip.recipe_id,
"recipeName": recipe_name,
"ingredientId": skip.ingredient_id,
"ingredientName": ing_name or "—",
**_serialize_skip_dates(skip, fallback=d),
}
for skip, recipe_name, ing_name in rows
]
def list_unloading_group_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
"""Список исключённых групп выгрузки на дату (для UI)."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyUnloadingGroupSkip, Recipe.name, UnloadingGroup.name)
.join(Recipe, Recipe.id == DailyUnloadingGroupSkip.recipe_id)
.join(UnloadingGroup, UnloadingGroup.id == DailyUnloadingGroupSkip.unloading_group_id)
.where(
*_skip_active_filters(DailyUnloadingGroupSkip, d),
Recipe.is_deleted.is_(False),
UnloadingGroup.is_deleted.is_(False),
)
.order_by(Recipe.name.asc(), UnloadingGroup.order.asc())
).all()
return [
{
"id": skip.id,
"recipeId": skip.recipe_id,
"recipeName": recipe_name,
"unloadingGroupId": skip.unloading_group_id,
"groupName": group_name or "—",
**_serialize_skip_dates(skip, fallback=d),
}
for skip, recipe_name, group_name in rows
]
def list_all_skips(plan_date: Optional[str] = None) -> Dict[str, Any]:
"""Все исключения на дату для UI."""
return {
"trips": list_skips(plan_date),
"ingredients": list_ingredient_skips(plan_date),
"unloadingGroups": list_unloading_group_skips(plan_date),
}
def skip_trip(
recipe_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyTripSkip:
"""Исключить рейс из плана (upsert / restore)."""
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
existing = db.session.execute(
select(DailyTripSkip).where(
DailyTripSkip.recipe_id == recipe_id,
DailyTripSkip.is_deleted.is_(False),
)
).scalar_one_or_none()
now = utc_now_naive()
if existing is not None:
existing.plan_date = start
existing.valid_until = valid_until if valid_until != start else None
if existing.is_deleted:
existing.is_deleted = False
existing.deleted_at = None
existing.deleted_by = None
existing.updated_by = user
existing.updated_at = now
existing.version = int(existing.version or 1) + 1
db.session.commit()
return existing
row = DailyTripSkip(
recipe_id=recipe_id,
plan_date=start,
valid_until=valid_until if valid_until != start else None,
created_by=user,
updated_by=user,
created_at=now,
updated_at=now,
)
db.session.add(row)
db.session.commit()
return row
def unskip_trip(
recipe_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
"""Вернуть рейс в план на дату. True если skip был активен."""
d = _parse_plan_date(plan_date)
existing = db.session.execute(
select(DailyTripSkip).where(
DailyTripSkip.recipe_id == recipe_id,
*_skip_active_filters(DailyTripSkip, d),
)
).scalar_one_or_none()
if existing is None:
return False
return _soft_delete_skip_row(existing, user=user, table_name="daily_trip_skip")
def _soft_delete_skip_row(row, *, user: str, table_name: str) -> bool:
from app.services.sync_manager import enqueue_sync_queue_task
row.soft_delete(deleted_by_user=user)
row.version = int(row.version or 1) + 1
row.updated_by = user
row.updated_at = utc_now_naive()
db.session.commit()
enqueue_sync_queue_task(table_name, row.id, "delete", priority=1)
return True
def _upsert_part_skip(model, *, lookup_filters, create_fields, user: str):
existing = db.session.execute(
select(model).where(*lookup_filters, model.is_deleted.is_(False))
).scalar_one_or_none()
now = utc_now_naive()
if existing is not None:
for key, value in create_fields.items():
setattr(existing, key, value)
existing.updated_by = user
existing.updated_at = now
existing.version = int(existing.version or 1) + 1
db.session.commit()
return existing
deleted = db.session.execute(
select(model).where(*lookup_filters, model.is_deleted.is_(True))
).scalar_one_or_none()
if deleted is not None:
for key, value in create_fields.items():
setattr(deleted, key, value)
deleted.is_deleted = False
deleted.deleted_at = None
deleted.deleted_by = None
deleted.updated_by = user
deleted.updated_at = now
deleted.version = int(deleted.version or 1) + 1
db.session.commit()
return deleted
row = model(**create_fields, created_by=user, updated_by=user, created_at=now, updated_at=now)
db.session.add(row)
db.session.commit()
return row
def _soft_unskip_part(model, *, lookup_filters, user: str, table_name: str) -> bool:
existing = db.session.execute(
select(model).where(*lookup_filters, model.is_deleted.is_(False))
).scalar_one_or_none()
if existing is None:
return False
return _soft_delete_skip_row(existing, user=user, table_name=table_name)
def skip_ingredient(
recipe_id: str,
ingredient_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyIngredientSkip:
"""Исключить компонент рейса из плана."""
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
ingredient = db.session.execute(
select(Ingredient).where(
Ingredient.id == ingredient_id,
Ingredient.recipe_id == recipe_id,
Ingredient.is_deleted.is_(False),
)
).scalar_one_or_none()
if ingredient is None:
raise LookupError("Компонент не найден")
return _upsert_part_skip(
DailyIngredientSkip,
lookup_filters=(
DailyIngredientSkip.recipe_id == recipe_id,
DailyIngredientSkip.ingredient_id == ingredient_id,
),
create_fields={
"recipe_id": recipe_id,
"ingredient_id": ingredient_id,
"plan_date": start,
"valid_until": valid_until if valid_until != start else None,
},
user=user,
)
def unskip_ingredient(
recipe_id: str,
ingredient_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
"""Вернуть компонент в план на дату."""
d = _parse_plan_date(plan_date)
return _soft_unskip_part(
DailyIngredientSkip,
lookup_filters=(
DailyIngredientSkip.recipe_id == recipe_id,
DailyIngredientSkip.ingredient_id == ingredient_id,
*_skip_active_filters(DailyIngredientSkip, d),
),
user=user,
table_name="daily_ingredient_skip",
)
def unskip_all_ingredient_parts(
recipe_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> int:
"""Снять все skip компонентов рейса, активные на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientSkip).where(
DailyIngredientSkip.recipe_id == recipe_id,
*_skip_active_filters(DailyIngredientSkip, d),
)
).scalars().all()
count = 0
for row in rows:
if _soft_delete_skip_row(row, user=user, table_name="daily_ingredient_skip"):
count += 1
return count
def skip_unloading_group(
recipe_id: str,
unloading_group_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyUnloadingGroupSkip:
"""Исключить группу выгрузки из плана."""
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
group = db.session.execute(
select(UnloadingGroup).where(
UnloadingGroup.id == unloading_group_id,
UnloadingGroup.recipe_id == recipe_id,
UnloadingGroup.is_deleted.is_(False),
)
).scalar_one_or_none()
if group is None:
raise LookupError("Группа выгрузки не найдена")
return _upsert_part_skip(
DailyUnloadingGroupSkip,
lookup_filters=(
DailyUnloadingGroupSkip.recipe_id == recipe_id,
DailyUnloadingGroupSkip.unloading_group_id == unloading_group_id,
),
create_fields={
"recipe_id": recipe_id,
"unloading_group_id": unloading_group_id,
"plan_date": start,
"valid_until": valid_until if valid_until != start else None,
},
user=user,
)
def unskip_unloading_group(
recipe_id: str,
unloading_group_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
"""Вернуть группу выгрузки в план на дату."""
d = _parse_plan_date(plan_date)
return _soft_unskip_part(
DailyUnloadingGroupSkip,
lookup_filters=(
DailyUnloadingGroupSkip.recipe_id == recipe_id,
DailyUnloadingGroupSkip.unloading_group_id == unloading_group_id,
*_skip_active_filters(DailyUnloadingGroupSkip, d),
),
user=user,
table_name="daily_unloading_group_skip",
)
def unskip_all_unloading_group_parts(
recipe_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> int:
"""Снять все skip групп выгрузки рейса, активные на дату."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyUnloadingGroupSkip).where(
DailyUnloadingGroupSkip.recipe_id == recipe_id,
*_skip_active_filters(DailyUnloadingGroupSkip, d),
)
).scalars().all()
count = 0
for row in rows:
if _soft_delete_skip_row(row, user=user, table_name="daily_unloading_group_skip"):
count += 1
return count
def recipe_part_skip_flags(
recipe_id: str, plan_date: Optional[str] = None
) -> tuple[bool, bool]:
"""Есть ли skip ингредиента / группы выгрузки у рейса на дату."""
skipped_ings = get_skipped_ingredient_ids(plan_date)
skipped_grps = get_skipped_unloading_group_ids(plan_date)
return bool(skipped_ings.get(recipe_id)), bool(skipped_grps.get(recipe_id))