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