"""Исключение рейсов и частей рейса из плана на день.""" 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))