@@ -0,0 +1 @@
|
||||
"""План на день — агрегат периодов и рейсов."""
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Временная правка норм по компонентам в плане на день."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, DailyComponentNormAdjustment, Ingredient, Recipe
|
||||
from app.services.daily_plan.ingredient_weights import (
|
||||
ingredient_dry_matter_per_head,
|
||||
ingredient_dry_matter_percent,
|
||||
)
|
||||
from app.services.daily_plan.skips import (
|
||||
_parse_plan_date,
|
||||
_serialize_skip_dates,
|
||||
_skip_active_filters,
|
||||
_soft_unskip_part,
|
||||
_upsert_part_skip,
|
||||
get_skipped_ingredient_ids,
|
||||
resolve_skip_range,
|
||||
)
|
||||
|
||||
|
||||
def get_component_adjustment_map(
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, Dict[str, Optional[float]]]:
|
||||
"""component_id -> {dry_matter, dry_matter_locked, weight_per_head, dry_matter_per_head}."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
DailyComponentNormAdjustment.component_id,
|
||||
DailyComponentNormAdjustment.dry_matter,
|
||||
DailyComponentNormAdjustment.dry_matter_locked,
|
||||
DailyComponentNormAdjustment.weight_per_head,
|
||||
DailyComponentNormAdjustment.dry_matter_per_head,
|
||||
).where(*_skip_active_filters(DailyComponentNormAdjustment, d))
|
||||
).all()
|
||||
out: Dict[str, Dict[str, Optional[float]]] = {}
|
||||
for cid, dm, locked, wph, dm_ph in rows:
|
||||
out[str(cid)] = {
|
||||
"dry_matter": float(dm) if dm is not None else None,
|
||||
"dry_matter_locked": bool(locked),
|
||||
"weight_per_head": float(wph) if wph is not None else None,
|
||||
"dry_matter_per_head": float(dm_ph) if dm_ph is not None else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def get_adjusted_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
|
||||
"""Рейсы с хотя бы одним ингредиентом с активной правкой нормы."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
adj_map = get_component_adjustment_map(plan_date)
|
||||
if not adj_map:
|
||||
return set()
|
||||
skip_map = get_skipped_ingredient_ids(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(Ingredient.recipe_id, Ingredient.id, Ingredient.component_id).where(
|
||||
Ingredient.is_deleted.is_(False),
|
||||
Ingredient.component_id.isnot(None),
|
||||
)
|
||||
).all()
|
||||
out: Set[str] = set()
|
||||
for recipe_id, ing_id, component_id in rows:
|
||||
if str(component_id) not in adj_map:
|
||||
continue
|
||||
if ing_id in skip_map.get(recipe_id, set()):
|
||||
continue
|
||||
out.add(recipe_id)
|
||||
return out
|
||||
|
||||
|
||||
def list_component_norm_adjustments(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyComponentNormAdjustment, Component.name)
|
||||
.join(Component, Component.id == DailyComponentNormAdjustment.component_id)
|
||||
.where(
|
||||
*_skip_active_filters(DailyComponentNormAdjustment, d),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Component.name.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"componentId": row.component_id,
|
||||
"componentName": name or "—",
|
||||
"dryMatter": row.dry_matter,
|
||||
"dryMatterLocked": bool(row.dry_matter_locked),
|
||||
"weightPerHead": row.weight_per_head,
|
||||
"dryMatterPerHead": row.dry_matter_per_head,
|
||||
**_serialize_skip_dates(row, fallback=d),
|
||||
}
|
||||
for row, name in rows
|
||||
]
|
||||
|
||||
|
||||
def _recipe_usages_summary(usages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Уникальные рецепты, где компонент встречается в плане на день."""
|
||||
by_recipe: Dict[str, Dict[str, Any]] = {}
|
||||
for usage in usages:
|
||||
rid = str(usage.get("recipeId") or "")
|
||||
if not rid:
|
||||
continue
|
||||
if rid not in by_recipe:
|
||||
by_recipe[rid] = {
|
||||
"recipeId": rid,
|
||||
"recipeName": usage.get("recipeName") or "—",
|
||||
"masterWeightPerHead": usage.get("masterWeightPerHead"),
|
||||
"masterDryMatterPerHead": usage.get("masterDryMatterPerHead"),
|
||||
"masterDryMatterPct": usage.get("masterDryMatterPct"),
|
||||
"dryMatterLocked": bool(usage.get("dryMatterLocked")),
|
||||
"tripCount": 1,
|
||||
}
|
||||
else:
|
||||
by_recipe[rid]["tripCount"] = int(by_recipe[rid].get("tripCount") or 0) + 1
|
||||
return sorted(by_recipe.values(), key=lambda row: (row.get("recipeName") or "").lower())
|
||||
|
||||
|
||||
def _master_norm_snapshot(
|
||||
component_id: str,
|
||||
usages: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
wph_vals = [float(u["masterWeightPerHead"]) for u in usages if u.get("masterWeightPerHead") is not None]
|
||||
dm_vals = [float(u["masterDryMatterPerHead"]) for u in usages if u.get("masterDryMatterPerHead") is not None]
|
||||
dm_pct_vals = [float(u["masterDryMatterPct"]) for u in usages if u.get("masterDryMatterPct") is not None]
|
||||
return {
|
||||
"masterWeightPerHeadMin": min(wph_vals) if wph_vals else None,
|
||||
"masterWeightPerHeadMax": max(wph_vals) if wph_vals else None,
|
||||
"masterDryMatterPerHeadMin": min(dm_vals) if dm_vals else None,
|
||||
"masterDryMatterPerHeadMax": max(dm_vals) if dm_vals else None,
|
||||
"masterDryMatterPctMin": min(dm_pct_vals) if dm_pct_vals else None,
|
||||
"masterDryMatterPctMax": max(dm_pct_vals) if dm_pct_vals else None,
|
||||
"masterWeightPerHead": round(sum(wph_vals) / len(wph_vals), 3) if wph_vals else None,
|
||||
"masterDryMatterPerHead": round(sum(dm_vals) / len(dm_vals), 4) if dm_vals else None,
|
||||
"masterDryMatterPct": round(sum(dm_pct_vals) / len(dm_pct_vals), 2) if dm_pct_vals else None,
|
||||
}
|
||||
|
||||
|
||||
def list_component_norms_for_plan(
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dispenser_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Уникальные компоненты плана с мастер-нормами и активными правками."""
|
||||
iso_date = _parse_plan_date(plan_date).isoformat()
|
||||
if not dispenser_id:
|
||||
return []
|
||||
from app.services.daily_plan.builder import build_daily_plan
|
||||
|
||||
try:
|
||||
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
|
||||
except LookupError:
|
||||
return []
|
||||
|
||||
skip_map = get_skipped_ingredient_ids(iso_date)
|
||||
adj_map = get_component_adjustment_map(iso_date)
|
||||
comp_ids: Set[str] = set()
|
||||
usages: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
comp_names: Dict[str, str] = {}
|
||||
comp_dm: Dict[str, float] = {}
|
||||
plan_effective: Dict[str, Dict[str, Optional[float]]] = {}
|
||||
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
recipe_id = trip.get("recipeId")
|
||||
dry_matter_locked = bool(trip.get("dryMatterLocked"))
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
cid = str(ing.get("componentId") or ing.get("originalComponentId") or "")
|
||||
if not cid:
|
||||
continue
|
||||
if cid not in plan_effective:
|
||||
plan_effective[cid] = {
|
||||
"weightPerHead": (
|
||||
float(ing["weightPerHead"])
|
||||
if ing.get("weightPerHead") is not None
|
||||
else None
|
||||
),
|
||||
"dryMatterPerHead": (
|
||||
float(ing["dryMatterPerHead"])
|
||||
if ing.get("dryMatterPerHead") is not None
|
||||
else None
|
||||
),
|
||||
"dryMatterPct": (
|
||||
float(ing["dryMatterPct"])
|
||||
if ing.get("dryMatterPct") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
comp_ids.add(cid)
|
||||
comp_names[cid] = str(ing.get("originalName") or ing.get("name") or "—")
|
||||
if ing.get("dryMatterPct") is not None:
|
||||
comp_dm[cid] = float(ing["dryMatterPct"])
|
||||
master_wph = ing.get("originalWeightPerHead")
|
||||
master_dm = ing.get("originalDryMatterPerHead")
|
||||
if master_wph is None:
|
||||
master_wph = ing.get("weightPerHead")
|
||||
if master_dm is None:
|
||||
master_dm = ing.get("dryMatterPerHead")
|
||||
master_dm_pct = ing.get("originalDryMatterPct")
|
||||
if master_dm_pct is None:
|
||||
master_dm_pct = ing.get("dryMatterPct")
|
||||
usages[cid].append(
|
||||
{
|
||||
"recipeId": recipe_id,
|
||||
"recipeName": trip.get("recipeName"),
|
||||
"ingredientId": ing.get("id"),
|
||||
"masterWeightPerHead": master_wph,
|
||||
"masterDryMatterPerHead": master_dm,
|
||||
"masterDryMatterPct": master_dm_pct,
|
||||
"dryMatterLocked": dry_matter_locked,
|
||||
}
|
||||
)
|
||||
|
||||
if comp_ids:
|
||||
comps = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(comp_ids),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
for comp in comps:
|
||||
comp_names[comp.id] = comp.name
|
||||
comp_dm[comp.id] = float(comp.dry_matter or 0)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for cid in sorted(comp_ids, key=lambda x: (comp_names.get(x) or x).lower()):
|
||||
usage_list = usages.get(cid, [])
|
||||
adj = adj_map.get(cid)
|
||||
snap = _master_norm_snapshot(cid, usage_list)
|
||||
eff = plan_effective.get(cid) or {}
|
||||
rows.append(
|
||||
{
|
||||
"componentId": cid,
|
||||
"componentName": comp_names.get(cid, "—"),
|
||||
"dryMatterPct": comp_dm.get(cid, 0),
|
||||
"usageCount": len(usage_list),
|
||||
"recipes": sorted({u.get("recipeName") or "" for u in usage_list if u.get("recipeName")}),
|
||||
"usages": _recipe_usages_summary(usage_list),
|
||||
**snap,
|
||||
"planDryMatterPct": adj.get("dry_matter") if adj else eff.get("dryMatterPct"),
|
||||
"planDryMatterLocked": bool(adj.get("dry_matter_locked")) if adj else None,
|
||||
"planWeightPerHead": eff.get("weightPerHead"),
|
||||
"planDryMatterPerHead": eff.get("dryMatterPerHead"),
|
||||
"adjustedToday": bool(adj),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def adjust_component_norm(
|
||||
component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dry_matter: Optional[float] = None,
|
||||
dry_matter_locked: bool = False,
|
||||
weight_per_head: Optional[float] = None,
|
||||
dry_matter_per_head: Optional[float] = None,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> DailyComponentNormAdjustment:
|
||||
if dry_matter is None and weight_per_head is None and dry_matter_per_head is None:
|
||||
raise ValueError("Укажите dryMatter (СВ%)")
|
||||
start = _parse_plan_date(plan_date)
|
||||
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
|
||||
component = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id == component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if component is None:
|
||||
raise LookupError("Компонент не найден")
|
||||
|
||||
if dry_matter is not None:
|
||||
weight_per_head = None
|
||||
dry_matter_per_head = None
|
||||
|
||||
return _upsert_part_skip(
|
||||
DailyComponentNormAdjustment,
|
||||
lookup_filters=(DailyComponentNormAdjustment.component_id == component_id,),
|
||||
create_fields={
|
||||
"component_id": component_id,
|
||||
"plan_date": start,
|
||||
"valid_until": valid_until if valid_until != start else None,
|
||||
"dry_matter": float(dry_matter) if dry_matter is not None else None,
|
||||
"dry_matter_locked": bool(dry_matter_locked),
|
||||
"weight_per_head": float(weight_per_head) if weight_per_head is not None else None,
|
||||
"dry_matter_per_head": float(dry_matter_per_head)
|
||||
if dry_matter_per_head is not None
|
||||
else None,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def undo_component_norm_adjustment(
|
||||
component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> bool:
|
||||
d = _parse_plan_date(plan_date)
|
||||
return _soft_unskip_part(
|
||||
DailyComponentNormAdjustment,
|
||||
lookup_filters=(
|
||||
DailyComponentNormAdjustment.component_id == component_id,
|
||||
*_skip_active_filters(DailyComponentNormAdjustment, d),
|
||||
),
|
||||
user=user,
|
||||
table_name="daily_component_norm_adjustment",
|
||||
)
|
||||
|
||||
|
||||
def master_ingredient_norms(ing: Ingredient, components_by_id: Dict[str, Component]) -> Dict[str, float]:
|
||||
dm_pct = ingredient_dry_matter_percent(ing, components_by_id)
|
||||
dm_ph = ingredient_dry_matter_per_head(ing, dry_matter_percent=dm_pct)
|
||||
return {
|
||||
"weightPerHead": float(ing.weight_per_head or 0),
|
||||
"dryMatterPerHead": round(dm_ph, 4),
|
||||
"dryMatterPct": dm_pct,
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
"""Сборка плана на день из периодов, рейсов и ингредиентов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import exists, select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
Component,
|
||||
FeedDispenser,
|
||||
FeedingPeriod,
|
||||
Ingredient,
|
||||
PeriodRecipe,
|
||||
Recipe,
|
||||
UnloadingGroup,
|
||||
)
|
||||
from app.services.daily_plan.adjustments import (
|
||||
get_component_adjustment_map,
|
||||
list_component_norm_adjustments,
|
||||
)
|
||||
from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights
|
||||
from app.services.daily_plan.replacements import (
|
||||
get_ingredient_replacement_map,
|
||||
list_ingredient_replacements,
|
||||
)
|
||||
from app.services.daily_plan.skips import (
|
||||
get_skipped_ingredient_ids,
|
||||
get_skipped_recipe_ids,
|
||||
get_skipped_unloading_group_ids,
|
||||
list_ingredient_skips,
|
||||
list_skips,
|
||||
list_unloading_group_skips,
|
||||
)
|
||||
from app.services.period_recipes_query import recipes_for_period_ordered
|
||||
from app.services.recipe_calculator import calculate_ingredients, calculate_recipe
|
||||
|
||||
ALL_DISPENSERS_ID = "__all_dispensers__"
|
||||
ALL_MILLS_ID = "__all_mills__"
|
||||
|
||||
|
||||
def _parse_plan_date(value: Optional[str]) -> str:
|
||||
if value:
|
||||
try:
|
||||
return date.fromisoformat(str(value).strip()[:10]).isoformat()
|
||||
except ValueError:
|
||||
pass
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def _ingredient_display_name(ing: Ingredient, comp_names: Dict[str, str]) -> str:
|
||||
raw = (ing.name or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
if ing.component_id and ing.component_id in comp_names:
|
||||
return comp_names[ing.component_id]
|
||||
return "—"
|
||||
|
||||
|
||||
def _distribution_label(dist_type: Optional[str], value: float) -> str:
|
||||
if (dist_type or "percent") == "heads":
|
||||
rounded = int(value) if value == int(value) else value
|
||||
return f"{rounded} гол."
|
||||
rounded = int(value) if value == int(value) else value
|
||||
return f"{rounded}%"
|
||||
|
||||
|
||||
def _load_components_by_id(comp_ids: List[str]) -> Dict[str, Component]:
|
||||
if not comp_ids:
|
||||
return {}
|
||||
comps = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(set(comp_ids)),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
return {c.id: c for c in comps}
|
||||
|
||||
|
||||
def _serialize_trip(
|
||||
recipe: Recipe,
|
||||
*,
|
||||
order: int,
|
||||
skipped_ingredient_ids: Optional[set[str]] = None,
|
||||
skipped_group_ids: Optional[set[str]] = None,
|
||||
replacement_by_ingredient: Optional[Dict[str, str]] = None,
|
||||
component_adjustment_map: Optional[Dict[str, Dict[str, Optional[float]]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
ingredients = db.session.execute(
|
||||
select(Ingredient)
|
||||
.where(Ingredient.recipe_id == recipe.id, Ingredient.is_deleted.is_(False))
|
||||
.order_by(Ingredient.order.asc())
|
||||
).scalars().all()
|
||||
comp_ids = [i.component_id for i in ingredients if i.component_id]
|
||||
repl_ids = list((replacement_by_ingredient or {}).values())
|
||||
comp_ids.extend(repl_ids)
|
||||
components_by_id = _load_components_by_id(comp_ids)
|
||||
comp_names = {cid: c.name for cid, c in components_by_id.items()}
|
||||
|
||||
heads = int(recipe.heads_per_trip or 0)
|
||||
skip_ings = skipped_ingredient_ids or set()
|
||||
skip_groups = skipped_group_ids or set()
|
||||
repl_map = replacement_by_ingredient or {}
|
||||
adj_map = component_adjustment_map or {}
|
||||
dm_map = {cid: float(c.dry_matter or 0) for cid, c in components_by_id.items()}
|
||||
|
||||
prepared: List[Dict[str, Any]] = []
|
||||
calc_inputs: List[Dict[str, Any]] = []
|
||||
calc_index_by_ing: Dict[str, int] = {}
|
||||
|
||||
for ing in ingredients:
|
||||
is_skipped = ing.id in skip_ings
|
||||
replacement_id = repl_map.get(ing.id) if not is_skipped else None
|
||||
component_adj = adj_map.get(str(ing.component_id)) if ing.component_id else None
|
||||
weights = resolve_plan_ingredient_weights(
|
||||
ing,
|
||||
recipe,
|
||||
heads=heads,
|
||||
components_by_id=components_by_id,
|
||||
replacement_component_id=replacement_id,
|
||||
component_adjustment=component_adj,
|
||||
)
|
||||
original_name = _ingredient_display_name(ing, comp_names)
|
||||
replaced_today = bool(replacement_id)
|
||||
adjusted_today = bool(weights.get("adjustedToday"))
|
||||
display_name = original_name
|
||||
replacement_name = None
|
||||
if replaced_today:
|
||||
replacement_name = comp_names.get(replacement_id, "—")
|
||||
display_name = f"{original_name} → {replacement_name}"
|
||||
|
||||
trip_kg = 0.0
|
||||
if not is_skipped:
|
||||
calc_index_by_ing[ing.id] = len(calc_inputs)
|
||||
calc_inputs.append(
|
||||
{
|
||||
"weightPerHead": weights["weightPerHead"],
|
||||
"dryMatter": weights["dryMatterPct"],
|
||||
"component_id": replacement_id or ing.component_id,
|
||||
}
|
||||
)
|
||||
|
||||
prepared.append(
|
||||
{
|
||||
"ing": ing,
|
||||
"is_skipped": is_skipped,
|
||||
"weights": weights,
|
||||
"original_name": original_name,
|
||||
"display_name": display_name,
|
||||
"replacement_id": replacement_id,
|
||||
"replacement_name": replacement_name,
|
||||
"replaced_today": replaced_today,
|
||||
"adjusted_today": adjusted_today,
|
||||
"trip_kg": trip_kg,
|
||||
}
|
||||
)
|
||||
|
||||
groups = db.session.execute(
|
||||
select(UnloadingGroup)
|
||||
.where(UnloadingGroup.recipe_id == recipe.id, UnloadingGroup.is_deleted.is_(False))
|
||||
.order_by(UnloadingGroup.order.asc())
|
||||
).scalars().all()
|
||||
|
||||
active_groups = [g for g in groups if g.id not in skip_groups]
|
||||
calc_groups_payload = [
|
||||
{
|
||||
"distributionType": g.distribution_type or "percent",
|
||||
"value": float(g.value or 0),
|
||||
}
|
||||
for g in active_groups
|
||||
]
|
||||
|
||||
calc_result = calculate_recipe(
|
||||
calc_inputs,
|
||||
heads_count=heads,
|
||||
trip_percent=float(recipe.trip_percent or 100),
|
||||
unloading_groups=calc_groups_payload,
|
||||
component_dry_matter_map=dm_map,
|
||||
)
|
||||
calc_ingredients = calc_result.get("ingredients") or []
|
||||
calc_unloading = calc_result.get("unloadingGroups") or []
|
||||
total_weight = float(calc_result.get("totals", {}).get("totalTripWeight") or 0)
|
||||
unloading_total = float(calc_result.get("unloadingTotals", {}).get("totalWeightKg") or 0)
|
||||
|
||||
baseline_inputs = [
|
||||
{
|
||||
"weightPerHead": item["weights"]["weightPerHead"],
|
||||
"dryMatter": item["weights"]["dryMatterPct"],
|
||||
"component_id": item["replacement_id"] or item["ing"].component_id,
|
||||
}
|
||||
for item in prepared
|
||||
]
|
||||
baseline_ingredients = calculate_ingredients(
|
||||
baseline_inputs,
|
||||
heads_count=heads,
|
||||
trip_percent=float(recipe.trip_percent or 100),
|
||||
component_dry_matter_map=dm_map,
|
||||
)
|
||||
|
||||
group_weight_by_id = {
|
||||
g.id: float(calc_unloading[idx].get("calculatedWeight") or 0)
|
||||
for idx, g in enumerate(active_groups)
|
||||
if idx < len(calc_unloading)
|
||||
}
|
||||
|
||||
ing_rows = []
|
||||
for idx, item in enumerate(prepared):
|
||||
ing = item["ing"]
|
||||
weights = item["weights"]
|
||||
if not item["is_skipped"]:
|
||||
calc_idx = calc_index_by_ing.get(ing.id)
|
||||
if calc_idx is not None and calc_idx < len(calc_ingredients):
|
||||
item["trip_kg"] = float(calc_ingredients[calc_idx].get("tripWeight") or 0)
|
||||
|
||||
row: Dict[str, Any] = {
|
||||
"id": ing.id,
|
||||
"name": item["display_name"],
|
||||
"originalName": item["original_name"],
|
||||
"weightPerHead": weights["weightPerHead"],
|
||||
"totalKg": item["trip_kg"],
|
||||
"dryMatterPct": weights["dryMatterPct"],
|
||||
"dryMatterPerHead": weights["dryMatterPerHead"],
|
||||
"componentId": ing.component_id,
|
||||
"originalComponentId": ing.component_id,
|
||||
"replacementComponentId": item["replacement_id"],
|
||||
"replacementName": item["replacement_name"],
|
||||
"skippedToday": item["is_skipped"],
|
||||
"replacedToday": item["replaced_today"],
|
||||
"adjustedToday": item["adjusted_today"],
|
||||
}
|
||||
if item["replaced_today"] or item["adjusted_today"]:
|
||||
row.update(
|
||||
{
|
||||
"originalWeightPerHead": weights.get("originalWeightPerHead"),
|
||||
"originalDryMatterPerHead": weights.get("originalDryMatterPerHead"),
|
||||
"originalDryMatterPct": weights.get("originalDryMatterPct"),
|
||||
}
|
||||
)
|
||||
if item["replaced_today"]:
|
||||
row["recalculationMode"] = weights.get("recalculationMode")
|
||||
if item["is_skipped"] and idx < len(baseline_ingredients):
|
||||
baseline = baseline_ingredients[idx]
|
||||
row["baselineWeightPerHead"] = float(baseline.get("weightPerHead") or 0)
|
||||
row["baselineTotalKg"] = float(baseline.get("tripWeight") or 0)
|
||||
|
||||
ing_rows.append(row)
|
||||
|
||||
return {
|
||||
"order": order,
|
||||
"recipeId": recipe.id,
|
||||
"recipeName": recipe.name,
|
||||
"headsPerTrip": heads,
|
||||
"mixingTimeSec": int(recipe.mixing_time or 0),
|
||||
"tripPercent": float(recipe.trip_percent or 100),
|
||||
"dryMatterLocked": bool(recipe.dry_matter_locked),
|
||||
"totalWeightKg": round(total_weight, 2),
|
||||
"unloadingTotalKg": round(unloading_total, 2),
|
||||
"ingredients": ing_rows,
|
||||
"unloadingGroups": [
|
||||
{
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"weightKg": 0.0
|
||||
if g.id in skip_groups
|
||||
else float(group_weight_by_id.get(g.id, 0)),
|
||||
"distributionType": g.distribution_type,
|
||||
"distributionLabel": _distribution_label(
|
||||
g.distribution_type, float(g.value or 0)
|
||||
),
|
||||
"value": float(g.value or 0),
|
||||
"skippedToday": g.id in skip_groups,
|
||||
}
|
||||
for g in groups
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _periods_for_dispenser(
|
||||
dispenser: FeedDispenser,
|
||||
*,
|
||||
skipped_ids: set[str],
|
||||
skipped_ingredients: Dict[str, set[str]],
|
||||
skipped_groups: Dict[str, set[str]],
|
||||
replacement_map: Dict[str, Dict[str, str]],
|
||||
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod)
|
||||
.where(
|
||||
FeedingPeriod.dispenser_id == dispenser.id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
.order_by(FeedingPeriod.created_at.asc())
|
||||
).unique().scalars().all()
|
||||
|
||||
payload = []
|
||||
for period in periods:
|
||||
recipes = [
|
||||
r for r in recipes_for_period_ordered(period.id) if r.id not in skipped_ids
|
||||
]
|
||||
trips = [
|
||||
_serialize_trip(
|
||||
r,
|
||||
order=idx + 1,
|
||||
skipped_ingredient_ids=skipped_ingredients.get(r.id, set()),
|
||||
skipped_group_ids=skipped_groups.get(r.id, set()),
|
||||
replacement_by_ingredient=replacement_map.get(r.id, {}),
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
for idx, r in enumerate(recipes)
|
||||
]
|
||||
payload.append(
|
||||
{
|
||||
"id": period.id,
|
||||
"name": period.name,
|
||||
"trips": trips,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _mill_trips_fallback(
|
||||
dispenser_id: str,
|
||||
*,
|
||||
skipped_ids: set[str],
|
||||
skipped_ingredients: Dict[str, set[str]],
|
||||
skipped_groups: Dict[str, set[str]],
|
||||
replacement_map: Dict[str, Dict[str, str]],
|
||||
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
recipes = db.session.execute(
|
||||
select(Recipe)
|
||||
.where(
|
||||
Recipe.is_deleted.is_(False),
|
||||
~exists(
|
||||
select(1).where(
|
||||
PeriodRecipe.recipe_id == Recipe.id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(Recipe.updated_at.desc())
|
||||
).scalars().all()
|
||||
recipes = [r for r in recipes if r.id not in skipped_ids]
|
||||
trips = [
|
||||
_serialize_trip(
|
||||
r,
|
||||
order=idx + 1,
|
||||
skipped_ingredient_ids=skipped_ingredients.get(r.id, set()),
|
||||
skipped_group_ids=skipped_groups.get(r.id, set()),
|
||||
replacement_by_ingredient=replacement_map.get(r.id, {}),
|
||||
)
|
||||
for idx, r in enumerate(recipes)
|
||||
]
|
||||
if not trips:
|
||||
return []
|
||||
return [{"id": None, "name": "Рейсы", "trips": trips}]
|
||||
|
||||
|
||||
def _aggregate_ingredient_totals(periods: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
totals: Dict[str, float] = {}
|
||||
grand_total = 0.0
|
||||
for period in periods:
|
||||
for trip in period.get("trips") or []:
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
if ing.get("replacedToday") and ing.get("replacementName"):
|
||||
name = str(ing.get("replacementName") or "—")
|
||||
else:
|
||||
name = str(ing.get("originalName") or ing.get("name") or "—")
|
||||
kg = float(ing.get("totalKg") or 0)
|
||||
totals[name] = totals.get(name, 0.0) + kg
|
||||
grand_total += kg
|
||||
rows = [
|
||||
{"name": name, "totalKg": round(kg, 2)}
|
||||
for name, kg in sorted(totals.items(), key=lambda x: x[0].lower())
|
||||
]
|
||||
return {
|
||||
"rows": rows,
|
||||
"grandTotalKg": round(grand_total, 2),
|
||||
}
|
||||
|
||||
|
||||
def _periods_for_dispenser_named(
|
||||
dispenser: FeedDispenser,
|
||||
*,
|
||||
prefix_name: bool = False,
|
||||
skipped_ids: set[str],
|
||||
skipped_ingredients: Dict[str, set[str]],
|
||||
skipped_groups: Dict[str, set[str]],
|
||||
replacement_map: Dict[str, Dict[str, str]],
|
||||
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
if dispenser.type == "mill":
|
||||
periods = _mill_trips_fallback(
|
||||
dispenser.id,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
skipped_groups=skipped_groups,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
else:
|
||||
periods = _periods_for_dispenser(
|
||||
dispenser,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
skipped_groups=skipped_groups,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
if not prefix_name:
|
||||
return periods
|
||||
prefixed: List[Dict[str, Any]] = []
|
||||
for period in periods:
|
||||
item = dict(period)
|
||||
item["name"] = f"{dispenser.name} · {period.get('name') or 'Период'}"
|
||||
item["dispenserId"] = dispenser.id
|
||||
prefixed.append(item)
|
||||
return prefixed
|
||||
|
||||
|
||||
def _plan_extras(
|
||||
periods: List[Dict[str, Any]],
|
||||
*,
|
||||
iso_date: str,
|
||||
) -> Dict[str, Any]:
|
||||
totals = _aggregate_ingredient_totals(periods)
|
||||
return {
|
||||
"ingredientTotals": totals["rows"],
|
||||
"ingredientGrandTotalKg": totals["grandTotalKg"],
|
||||
"ingredientReplacements": list_ingredient_replacements(iso_date),
|
||||
"componentNormAdjustments": list_component_norm_adjustments(iso_date),
|
||||
}
|
||||
|
||||
|
||||
def _plan_payload_base(plan_date: Optional[str]) -> tuple[str, set[str], List, List, List]:
|
||||
iso_date = _parse_plan_date(plan_date)
|
||||
skipped_ids = get_skipped_recipe_ids(iso_date)
|
||||
skipped_trips = list_skips(iso_date)
|
||||
skipped_ingredients = list_ingredient_skips(iso_date)
|
||||
skipped_groups = list_unloading_group_skips(iso_date)
|
||||
return iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups
|
||||
|
||||
|
||||
def _part_skip_maps(plan_date: Optional[str]) -> tuple[Dict[str, set[str]], Dict[str, set[str]]]:
|
||||
iso_date = _parse_plan_date(plan_date)
|
||||
return get_skipped_ingredient_ids(iso_date), get_skipped_unloading_group_ids(iso_date)
|
||||
|
||||
|
||||
def _build_all_dispensers_plan(plan_date: Optional[str]) -> Dict[str, Any]:
|
||||
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
|
||||
plan_date
|
||||
)
|
||||
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(iso_date)
|
||||
component_adjustment_map = get_component_adjustment_map(iso_date)
|
||||
dispensers = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
FeedDispenser.type == "dispenser",
|
||||
)
|
||||
.order_by(FeedDispenser.name.asc())
|
||||
).scalars().all()
|
||||
periods: List[Dict[str, Any]] = []
|
||||
for dispenser in dispensers:
|
||||
periods.extend(
|
||||
_periods_for_dispenser_named(
|
||||
dispenser,
|
||||
prefix_name=True,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skip_ing_map,
|
||||
skipped_groups=skip_grp_map,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"date": iso_date,
|
||||
"generatedAt": datetime.now().isoformat(timespec="seconds"),
|
||||
"dispenserId": ALL_DISPENSERS_ID,
|
||||
"dispenserName": "Все кормораздатчики",
|
||||
"farm": "",
|
||||
"dispenserType": "dispenser",
|
||||
"periods": periods,
|
||||
**_plan_extras(periods, iso_date=iso_date),
|
||||
"skippedTrips": skipped_trips,
|
||||
"skippedIngredients": skipped_ingredients,
|
||||
"skippedUnloadingGroups": skipped_groups,
|
||||
}
|
||||
|
||||
|
||||
def _build_all_mills_plan(plan_date: Optional[str]) -> Dict[str, Any]:
|
||||
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
|
||||
plan_date
|
||||
)
|
||||
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(iso_date)
|
||||
component_adjustment_map = get_component_adjustment_map(iso_date)
|
||||
mills = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
FeedDispenser.type == "mill",
|
||||
)
|
||||
.order_by(FeedDispenser.name.asc())
|
||||
).scalars().all()
|
||||
periods = _mill_trips_fallback(
|
||||
ALL_MILLS_ID,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skip_ing_map,
|
||||
skipped_groups=skip_grp_map,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
if mills:
|
||||
mill_names = ", ".join(m.name for m in mills)
|
||||
farm = mills[0].farm if len(mills) == 1 else ""
|
||||
else:
|
||||
mill_names = ""
|
||||
farm = ""
|
||||
return {
|
||||
"date": iso_date,
|
||||
"generatedAt": datetime.now().isoformat(timespec="seconds"),
|
||||
"dispenserId": ALL_MILLS_ID,
|
||||
"dispenserName": "Все кормоцеха",
|
||||
"farm": farm,
|
||||
"dispenserType": "mill",
|
||||
"scopeNote": mill_names,
|
||||
"periods": periods,
|
||||
**_plan_extras(periods, iso_date=iso_date),
|
||||
"skippedTrips": skipped_trips,
|
||||
"skippedIngredients": skipped_ingredients,
|
||||
"skippedUnloadingGroups": skipped_groups,
|
||||
}
|
||||
|
||||
|
||||
def build_daily_plan(
|
||||
*,
|
||||
dispenser_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""План на дату: текущая конфигурация периодов кормораздатчика."""
|
||||
if dispenser_id == ALL_DISPENSERS_ID:
|
||||
return _build_all_dispensers_plan(plan_date)
|
||||
if dispenser_id == ALL_MILLS_ID:
|
||||
return _build_all_mills_plan(plan_date)
|
||||
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id,
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dispenser is None:
|
||||
raise LookupError("Кормораздатчик не найден")
|
||||
|
||||
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
|
||||
plan_date
|
||||
)
|
||||
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(iso_date)
|
||||
component_adjustment_map = get_component_adjustment_map(iso_date)
|
||||
periods = _periods_for_dispenser_named(
|
||||
dispenser,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skip_ing_map,
|
||||
skipped_groups=skip_grp_map,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
|
||||
return {
|
||||
"date": iso_date,
|
||||
"generatedAt": datetime.now().isoformat(timespec="seconds"),
|
||||
"dispenserId": dispenser.id,
|
||||
"dispenserName": dispenser.name,
|
||||
"farm": dispenser.farm,
|
||||
"dispenserType": dispenser.type,
|
||||
"periods": periods,
|
||||
**_plan_extras(periods, iso_date=iso_date),
|
||||
"skippedTrips": skipped_trips,
|
||||
"skippedIngredients": skipped_ingredients,
|
||||
"skippedUnloadingGroups": skipped_groups,
|
||||
}
|
||||
|
||||
|
||||
def recipe_total_weights_by_id(
|
||||
*,
|
||||
dispenser_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""totalWeightKg по recipeId из плана на день (список рейсов на терминале)."""
|
||||
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=plan_date)
|
||||
weights: Dict[str, float] = {}
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
recipe_id = trip.get("recipeId")
|
||||
if recipe_id:
|
||||
weights[str(recipe_id)] = float(trip.get("totalWeightKg") or 0)
|
||||
return weights
|
||||
|
||||
|
||||
def trip_overlay_for_recipe(recipe: Recipe, plan_date: str) -> Dict[str, Any]:
|
||||
"""Рейс из плана на день для одного recipe — те же веса, что в build_daily_plan."""
|
||||
skipped_ing_by_recipe = get_skipped_ingredient_ids(plan_date)
|
||||
skipped_grp_by_recipe = get_skipped_unloading_group_ids(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(plan_date)
|
||||
component_adjustment_map = get_component_adjustment_map(plan_date)
|
||||
return _serialize_trip(
|
||||
recipe,
|
||||
order=1,
|
||||
skipped_ingredient_ids=skipped_ing_by_recipe.get(recipe.id, set()),
|
||||
skipped_group_ids=skipped_grp_by_recipe.get(recipe.id, set()),
|
||||
replacement_by_ingredient=replacement_map.get(recipe.id, {}),
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Веса строки рейса в плане на день с учётом замены компонента и правки нормы."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.models import Component, Ingredient, Recipe
|
||||
|
||||
_EPSILON = 1e-6
|
||||
|
||||
|
||||
def ingredient_dry_matter_percent(ing: Ingredient, components_by_id: Dict[str, Component]) -> float:
|
||||
dm = float(ing.dry_matter or 0)
|
||||
if dm > 0:
|
||||
return dm
|
||||
if ing.component_id and ing.component_id in components_by_id:
|
||||
return float(components_by_id[ing.component_id].dry_matter or 0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def ingredient_dry_matter_per_head(
|
||||
ing: Ingredient,
|
||||
*,
|
||||
dry_matter_percent: Optional[float] = None,
|
||||
) -> float:
|
||||
if ing.dry_matter_per_head is not None and float(ing.dry_matter_per_head) > 0:
|
||||
return float(ing.dry_matter_per_head)
|
||||
wph = float(ing.weight_per_head or 0)
|
||||
dm = dry_matter_percent if dry_matter_percent is not None else float(ing.dry_matter or 0)
|
||||
if wph > 0 and dm > 0:
|
||||
return wph * (dm / 100.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _round_wph(value: float) -> float:
|
||||
if 0 < value < 0.01:
|
||||
return round(value, 3)
|
||||
return round(value, 2)
|
||||
|
||||
|
||||
def _total_kg(weight_per_head: float, heads: int, fallback_amount: float) -> float:
|
||||
if weight_per_head > 0:
|
||||
return round(weight_per_head * max(heads, 0), 2)
|
||||
return round(float(fallback_amount or 0), 2)
|
||||
|
||||
|
||||
def _apply_component_adjustment(
|
||||
*,
|
||||
recipe: Recipe,
|
||||
component_id: Optional[str],
|
||||
components_by_id: Dict[str, Component],
|
||||
master_wph: float,
|
||||
master_dm_ph: float,
|
||||
master_dm_pct: float,
|
||||
component_adjustment: Optional[Dict[str, Optional[float]]],
|
||||
) -> tuple[float, float, float, bool]:
|
||||
if not component_adjustment or not component_id:
|
||||
return master_wph, master_dm_ph, master_dm_pct, False
|
||||
|
||||
adj_dm_pct = component_adjustment.get("dry_matter")
|
||||
if adj_dm_pct is not None:
|
||||
new_dm_pct = float(adj_dm_pct)
|
||||
locked = bool(getattr(recipe, "dry_matter_locked", False))
|
||||
if locked:
|
||||
new_dm_ph = master_dm_ph
|
||||
if new_dm_pct > _EPSILON:
|
||||
new_wph = (master_dm_ph * 100.0) / new_dm_pct
|
||||
if new_wph < 0.01 and master_dm_ph >= 0.001:
|
||||
new_wph = 0.01
|
||||
else:
|
||||
new_wph = 0.0
|
||||
return _round_wph(new_wph), round(new_dm_ph, 4), new_dm_pct, True
|
||||
new_dm_ph = master_wph * (new_dm_pct / 100.0) if master_wph > 0 else master_dm_ph
|
||||
return master_wph, round(new_dm_ph, 4), new_dm_pct, True
|
||||
|
||||
adj_wph = component_adjustment.get("weight_per_head")
|
||||
adj_dm_ph = component_adjustment.get("dry_matter_per_head")
|
||||
if adj_wph is None and adj_dm_ph is None:
|
||||
return master_wph, master_dm_ph, master_dm_pct, False
|
||||
|
||||
comp = components_by_id.get(component_id)
|
||||
dm_pct = float(comp.dry_matter or 0) if comp else master_dm_pct
|
||||
locked = bool(getattr(recipe, "dry_matter_locked", False))
|
||||
|
||||
if locked and adj_dm_ph is not None:
|
||||
new_dm_ph = float(adj_dm_ph)
|
||||
new_wph = (new_dm_ph * 100.0) / dm_pct if dm_pct > _EPSILON else 0.0
|
||||
return _round_wph(new_wph), new_dm_ph, dm_pct, True
|
||||
if not locked and adj_wph is not None:
|
||||
new_wph = float(adj_wph)
|
||||
new_dm_ph = new_wph * (dm_pct / 100.0) if dm_pct > 0 else 0.0
|
||||
return _round_wph(new_wph), round(new_dm_ph, 4), dm_pct, True
|
||||
if adj_dm_ph is not None:
|
||||
new_dm_ph = float(adj_dm_ph)
|
||||
new_wph = (new_dm_ph * 100.0) / dm_pct if dm_pct > _EPSILON else master_wph
|
||||
return _round_wph(new_wph), new_dm_ph, dm_pct, True
|
||||
if adj_wph is not None:
|
||||
new_wph = float(adj_wph)
|
||||
new_dm_ph = new_wph * (dm_pct / 100.0) if dm_pct > 0 else master_dm_ph
|
||||
return _round_wph(new_wph), round(new_dm_ph, 4), dm_pct, True
|
||||
return master_wph, master_dm_ph, master_dm_pct, False
|
||||
|
||||
|
||||
def resolve_plan_ingredient_weights(
|
||||
ing: Ingredient,
|
||||
recipe: Recipe,
|
||||
*,
|
||||
heads: int,
|
||||
components_by_id: Dict[str, Component],
|
||||
replacement_component_id: Optional[str] = None,
|
||||
component_adjustment: Optional[Dict[str, Optional[float]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Эффективные веса для плана.
|
||||
|
||||
Приоритет: adjustment (по component_id) → замена → базовый рецепт.
|
||||
Мастер-значения ingredient не изменяются.
|
||||
"""
|
||||
master_wph = float(ing.weight_per_head or 0)
|
||||
master_dm_pct = ingredient_dry_matter_percent(ing, components_by_id)
|
||||
master_dm_ph = ingredient_dry_matter_per_head(ing, dry_matter_percent=master_dm_pct)
|
||||
|
||||
effective_wph, effective_dm_ph, effective_dm_pct, adjusted_today = _apply_component_adjustment(
|
||||
recipe=recipe,
|
||||
component_id=ing.component_id,
|
||||
components_by_id=components_by_id,
|
||||
master_wph=master_wph,
|
||||
master_dm_ph=master_dm_ph,
|
||||
master_dm_pct=master_dm_pct,
|
||||
component_adjustment=component_adjustment,
|
||||
)
|
||||
|
||||
replacement = (
|
||||
components_by_id.get(replacement_component_id)
|
||||
if replacement_component_id
|
||||
else None
|
||||
)
|
||||
if replacement is None:
|
||||
wph = effective_wph
|
||||
return {
|
||||
"weightPerHead": wph,
|
||||
"totalKg": _total_kg(wph, heads, float(ing.amount or 0)),
|
||||
"dryMatterPct": effective_dm_pct,
|
||||
"dryMatterPerHead": round(effective_dm_ph, 4),
|
||||
"originalWeightPerHead": master_wph,
|
||||
"originalDryMatterPerHead": round(master_dm_ph, 4),
|
||||
"originalDryMatterPct": master_dm_pct,
|
||||
"adjustedToday": adjusted_today,
|
||||
"replacedToday": False,
|
||||
}
|
||||
|
||||
repl_dm_pct = float(replacement.dry_matter or 0)
|
||||
locked = bool(getattr(recipe, "dry_matter_locked", False))
|
||||
|
||||
if locked:
|
||||
new_dm_ph = effective_dm_ph
|
||||
if repl_dm_pct > _EPSILON:
|
||||
new_wph = (new_dm_ph * 100.0) / repl_dm_pct
|
||||
else:
|
||||
new_wph = 0.0
|
||||
mode = "dry_matter"
|
||||
else:
|
||||
new_wph = effective_wph
|
||||
new_dm_ph = new_wph * (repl_dm_pct / 100.0) if repl_dm_pct > 0 else 0.0
|
||||
mode = "weight"
|
||||
|
||||
new_wph = _round_wph(new_wph)
|
||||
return {
|
||||
"weightPerHead": new_wph,
|
||||
"totalKg": _total_kg(new_wph, heads, float(ing.amount or 0)),
|
||||
"dryMatterPct": repl_dm_pct,
|
||||
"dryMatterPerHead": round(new_dm_ph, 4),
|
||||
"originalWeightPerHead": master_wph,
|
||||
"originalDryMatterPerHead": round(master_dm_ph, 4),
|
||||
"originalDryMatterPct": master_dm_pct,
|
||||
"recalculationMode": mode,
|
||||
"adjustedToday": adjusted_today,
|
||||
"replacedToday": True,
|
||||
}
|
||||
|
||||
|
||||
# Backward-compatible aliases for internal callers
|
||||
_ingredient_dry_matter_percent = ingredient_dry_matter_percent
|
||||
_ingredient_dry_matter_per_head = ingredient_dry_matter_per_head
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Рецепт для экрана оператора загрузки (весы / дублёр) — с overlay «План на день»."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as date_cls
|
||||
from typing import Any
|
||||
|
||||
from app.models import Recipe
|
||||
|
||||
|
||||
def plan_ingredients_for_loading(
|
||||
recipe: Recipe, *, plan_date: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ингредиенты с весами плана на день, как на киоске тракториста (без пропущенных)."""
|
||||
from app.routes.recipes import _serialize_recipe
|
||||
|
||||
resolved = (plan_date or date_cls.today().isoformat())[:10]
|
||||
payload = _serialize_recipe(recipe, plan_date=resolved, exclude_skipped=True)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for ing in payload.get("ingredients") or []:
|
||||
rows.append(
|
||||
{
|
||||
"id": ing.get("id"),
|
||||
"name": (ing.get("name") or "—").strip() or "—",
|
||||
"amount": float(ing.get("amount") or 0),
|
||||
"order": int(ing.get("order") or 0),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (row["order"], str(row.get("id") or "")))
|
||||
return rows
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Уведомления центра «К» при изменениях плана на день."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, Ingredient, Recipe, UnloadingGroup
|
||||
from app.services.notification_center_service import create_notification, format_detail_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _q(name: Optional[str]) -> str:
|
||||
text = (name or "").strip()
|
||||
return f"«{text}»" if text else "«без названия»"
|
||||
|
||||
|
||||
def _recipe_name(recipe_id: str) -> str:
|
||||
recipe = db.session.execute(
|
||||
select(Recipe.name).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
return recipe or "без названия"
|
||||
|
||||
|
||||
def _ingredient_name(ingredient_id: str) -> str:
|
||||
row = db.session.execute(
|
||||
select(Ingredient.name).where(
|
||||
Ingredient.id == ingredient_id,
|
||||
Ingredient.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row or "без названия"
|
||||
|
||||
|
||||
def _group_name(group_id: str) -> str:
|
||||
row = db.session.execute(
|
||||
select(UnloadingGroup.name).where(
|
||||
UnloadingGroup.id == group_id,
|
||||
UnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row or "без названия"
|
||||
|
||||
|
||||
def _component_name(component_id: str) -> str:
|
||||
row = db.session.execute(
|
||||
select(Component.name).where(
|
||||
Component.id == component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row or "без названия"
|
||||
|
||||
|
||||
def _plan_date_label(plan_date: date) -> str:
|
||||
months = (
|
||||
"января",
|
||||
"февраля",
|
||||
"марта",
|
||||
"апреля",
|
||||
"мая",
|
||||
"июня",
|
||||
"июля",
|
||||
"августа",
|
||||
"сентября",
|
||||
"октября",
|
||||
"ноября",
|
||||
"декабря",
|
||||
)
|
||||
return f"{plan_date.day} {months[plan_date.month - 1]} {plan_date.year}"
|
||||
|
||||
|
||||
def _duration_hint(*, plan_date: date, valid_until: Optional[date]) -> str:
|
||||
if valid_until and valid_until > plan_date:
|
||||
return f", действует до {_plan_date_label(valid_until)}"
|
||||
return ""
|
||||
|
||||
|
||||
def _on_date(plan_date: date) -> str:
|
||||
return f"на {_plan_date_label(plan_date)}"
|
||||
|
||||
|
||||
def _actor(user: str) -> str:
|
||||
name = (user or "").strip()
|
||||
if not name or name == "system":
|
||||
return "система"
|
||||
return name
|
||||
|
||||
|
||||
def _detail_body(*parts: str, when: str, user: str) -> str:
|
||||
core = ". ".join(p for p in parts if p)
|
||||
return f"{core}. {when}, {_actor(user)}"
|
||||
|
||||
|
||||
def _notify(
|
||||
*,
|
||||
title: str,
|
||||
detail: str,
|
||||
kind: str,
|
||||
plan_date: date,
|
||||
user: str,
|
||||
) -> None:
|
||||
try:
|
||||
create_notification(
|
||||
title=title,
|
||||
detail=detail,
|
||||
kind=kind,
|
||||
category="daily_plan",
|
||||
page="daily_plan",
|
||||
link_kind="daily_plan",
|
||||
link_id=plan_date.isoformat(),
|
||||
user_login=user or None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[DAILY-PLAN-NOTIFY] %s", title)
|
||||
|
||||
|
||||
def notify_trip_skipped(
|
||||
recipe_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"Рейс {recipe} убран из плана",
|
||||
detail=_detail_body(
|
||||
f"Рейс {recipe} исключён из плана {_on_date(plan_date)}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="warning",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_trip_unskipped(recipe_id: str, plan_date: date, *, user: str = "system") -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
_notify(
|
||||
title=f"Рейс {recipe} снова в плане",
|
||||
detail=_detail_body(
|
||||
f"Рейс {recipe} снова включён в план {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_skipped(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
component = _q(_ingredient_name(ingredient_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"{component} убран из плана {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Компонент {component} убран из плана рейса {recipe} {_on_date(plan_date)}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="warning",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_unskipped(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
component = _q(_ingredient_name(ingredient_id))
|
||||
_notify(
|
||||
title=f"{component} снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Компонент {component} снова в плане рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredients_unskipped_all(recipe_id: str, plan_date: date, *, count: int, user: str) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
_notify(
|
||||
title=f"Компоненты снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Сняты все исключения компонентов ({count}) рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_unloading_group_skipped(
|
||||
recipe_id: str,
|
||||
group_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
group = _q(_group_name(group_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"Группа {group} убрана из плана {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Группа {group} убрана из плана рейса {recipe} {_on_date(plan_date)}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="warning",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_unloading_group_unskipped(
|
||||
recipe_id: str,
|
||||
group_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
group = _q(_group_name(group_id))
|
||||
_notify(
|
||||
title=f"Группа {group} снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Группа {group} снова в плане рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_unloading_groups_unskipped_all(recipe_id: str, plan_date: date, *, count: int, user: str) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
_notify(
|
||||
title=f"Группы снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Сняты все исключения групп ({count}) рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_replaced(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
original = _q(_ingredient_name(ingredient_id))
|
||||
replacement = _q(_component_name(replacement_component_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"{original} → {replacement} в {recipe}",
|
||||
detail=_detail_body(
|
||||
f"В рейсе {recipe} вместо {original} {_on_date(plan_date)} будет {replacement}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_component_replaced_in_plan(
|
||||
component_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
recipe_count: int,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
original = _q(_component_name(component_id))
|
||||
replacement = _q(_component_name(replacement_component_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
count = max(int(recipe_count or 0), 1)
|
||||
recipes_word = "рецепте" if count == 1 else "рецептах"
|
||||
_notify(
|
||||
title=f"{original} → {replacement} в {count} {recipes_word}",
|
||||
detail=_detail_body(
|
||||
f"Во всех рейсах плана вместо {original} {_on_date(plan_date)} будет {replacement}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_replacement_undone(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
original = _q(_ingredient_name(ingredient_id))
|
||||
_notify(
|
||||
title=f"{original} снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Замена отменена: {original} снова в плане рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""PDF плана на день (reportlab)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.services.feed_accounting.pdf_feed_accounting import _register_cyrillic_font
|
||||
|
||||
|
||||
def _styles():
|
||||
font = _register_cyrillic_font()
|
||||
styles = getSampleStyleSheet()
|
||||
styles["Title"].fontName = font
|
||||
styles["Normal"].fontName = font
|
||||
styles["Heading2"].fontName = font
|
||||
return styles, font
|
||||
|
||||
|
||||
def build_daily_plan_pdf(plan: Dict[str, Any]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=14 * mm, rightMargin=14 * mm)
|
||||
styles, font = _styles()
|
||||
story: List[Any] = []
|
||||
|
||||
story.append(Paragraph("План на день", styles["Title"]))
|
||||
story.append(
|
||||
Paragraph(
|
||||
f"Дата: {plan.get('date', '—')} · {plan.get('dispenserName', '—')} · {plan.get('farm', '')}",
|
||||
styles["Normal"],
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
periods = plan.get("periods") or []
|
||||
if not periods:
|
||||
story.append(Paragraph("Нет периодов или рейсов для выбранного кормораздатчика.", styles["Normal"]))
|
||||
else:
|
||||
for period in periods:
|
||||
story.append(Paragraph(str(period.get("name") or "Период"), styles["Heading2"]))
|
||||
for trip in period.get("trips") or []:
|
||||
story.append(
|
||||
Paragraph(
|
||||
f"Рейс {trip.get('order', '')}: {trip.get('recipeName', '—')} "
|
||||
f"({trip.get('headsPerTrip', 0)} гол., смеш. {trip.get('mixingTimeSec', 0)} с)",
|
||||
styles["Normal"],
|
||||
)
|
||||
)
|
||||
data = [["Компонент", "кг/гол", "Всего, кг"]]
|
||||
for ing in trip.get("ingredients") or []:
|
||||
data.append(
|
||||
[
|
||||
str(ing.get("name") or "—"),
|
||||
str(ing.get("weightPerHead") or ""),
|
||||
str(ing.get("totalKg") or ""),
|
||||
]
|
||||
)
|
||||
if len(data) == 1:
|
||||
data.append(["—", "", ""])
|
||||
tbl = Table(data, colWidths=[80 * mm, 35 * mm, 35 * mm])
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(tbl)
|
||||
groups = trip.get("unloadingGroups") or []
|
||||
if groups:
|
||||
gdata = [["Группа", "кг", "Распределение"]]
|
||||
for g in groups:
|
||||
gdata.append(
|
||||
[
|
||||
str(g.get("name") or "—"),
|
||||
str(g.get("weightKg") or ""),
|
||||
str(
|
||||
g.get("distributionLabel")
|
||||
or g.get("distributionType")
|
||||
or ""
|
||||
),
|
||||
]
|
||||
)
|
||||
gtbl = Table(gdata, colWidths=[55 * mm, 30 * mm, 40 * mm])
|
||||
gtbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(gtbl)
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
totals = plan.get("ingredientTotals") or []
|
||||
if totals:
|
||||
story.append(Paragraph("Итого по компонентам", styles["Heading2"]))
|
||||
tdata = [["Компонент", "Всего, кг"]]
|
||||
for row in totals:
|
||||
tdata.append([str(row.get("name") or "—"), str(row.get("totalKg") or "")])
|
||||
grand = plan.get("ingredientGrandTotalKg")
|
||||
if grand is not None:
|
||||
tdata.append(["Итого", str(grand)])
|
||||
ttbl = Table(tdata, colWidths=[100 * mm, 40 * mm])
|
||||
ttbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(ttbl)
|
||||
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Временная замена компонентов в плане на день."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, DailyIngredientReplacement, Ingredient, Recipe
|
||||
from app.services.daily_plan.skips import (
|
||||
_parse_plan_date,
|
||||
_serialize_skip_dates,
|
||||
_skip_active_filters,
|
||||
_soft_delete_skip_row,
|
||||
_soft_unskip_part,
|
||||
_upsert_part_skip,
|
||||
resolve_skip_range,
|
||||
)
|
||||
|
||||
|
||||
def _component_payload(component: Component, *, similar: bool = False) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": component.id,
|
||||
"name": component.name,
|
||||
"type": component.type or "",
|
||||
"dryMatter": float(component.dry_matter or 0),
|
||||
"protein": float(component.protein or 0),
|
||||
"energy": float(component.energy or 0),
|
||||
"similar": similar,
|
||||
}
|
||||
|
||||
|
||||
def _similarity_score(source: Component, candidate: Component) -> float:
|
||||
if source.id == candidate.id:
|
||||
return -1.0
|
||||
score = 0.0
|
||||
src_type = (source.type or "").strip().lower()
|
||||
cand_type = (candidate.type or "").strip().lower()
|
||||
if src_type and cand_type and src_type == cand_type:
|
||||
score += 100.0
|
||||
dm_diff = abs(float(source.dry_matter or 0) - float(candidate.dry_matter or 0))
|
||||
score += max(0.0, 50.0 - dm_diff * 2.0)
|
||||
prot_diff = abs(float(source.protein or 0) - float(candidate.protein or 0))
|
||||
score += max(0.0, 20.0 - prot_diff)
|
||||
return score
|
||||
|
||||
|
||||
def find_component_alternatives(
|
||||
component_id: str,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""Похожие компоненты и поиск по всем активным."""
|
||||
source = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id == component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if source is None:
|
||||
raise LookupError("Компонент не найден")
|
||||
|
||||
q = (query or "").strip().lower()
|
||||
try:
|
||||
limit_val = max(1, min(int(limit), 100))
|
||||
except (TypeError, ValueError):
|
||||
limit_val = 20
|
||||
|
||||
candidates = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.is_active.is_(True),
|
||||
Component.is_deleted.is_(False),
|
||||
Component.id != component_id,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
scored: List[Tuple[float, Component]] = []
|
||||
for candidate in candidates:
|
||||
if q and q not in (candidate.name or "").lower():
|
||||
continue
|
||||
score = _similarity_score(source, candidate)
|
||||
if score < 0:
|
||||
continue
|
||||
scored.append((score, candidate))
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], (item[1].name or "").lower()))
|
||||
similar_cutoff = 80.0 if (source.type or "").strip() else 40.0
|
||||
similar: List[Dict[str, Any]] = []
|
||||
all_items: List[Dict[str, Any]] = []
|
||||
for score, candidate in scored:
|
||||
is_similar = score >= similar_cutoff
|
||||
payload = _component_payload(candidate, similar=is_similar)
|
||||
all_items.append(payload)
|
||||
if is_similar and len(similar) < 8:
|
||||
similar.append(payload)
|
||||
if len(all_items) >= limit_val:
|
||||
break
|
||||
|
||||
return {
|
||||
"sourceComponent": _component_payload(source),
|
||||
"similar": similar,
|
||||
"results": all_items,
|
||||
}
|
||||
|
||||
|
||||
def get_ingredient_replacement_map(
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, Dict[str, str]]:
|
||||
"""recipe_id -> ingredient_id -> replacement_component_id."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
DailyIngredientReplacement.recipe_id,
|
||||
DailyIngredientReplacement.ingredient_id,
|
||||
DailyIngredientReplacement.replacement_component_id,
|
||||
).where(*_skip_active_filters(DailyIngredientReplacement, d))
|
||||
).all()
|
||||
out: Dict[str, Dict[str, str]] = {}
|
||||
for recipe_id, ingredient_id, replacement_id in rows:
|
||||
out.setdefault(recipe_id, {})[ingredient_id] = replacement_id
|
||||
return out
|
||||
|
||||
|
||||
def get_replaced_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyIngredientReplacement.recipe_id).where(
|
||||
*_skip_active_filters(DailyIngredientReplacement, d)
|
||||
)
|
||||
).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def list_ingredient_replacements(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
DailyIngredientReplacement,
|
||||
Recipe.name,
|
||||
Ingredient.name,
|
||||
Component.name,
|
||||
)
|
||||
.join(Recipe, Recipe.id == DailyIngredientReplacement.recipe_id)
|
||||
.join(Ingredient, Ingredient.id == DailyIngredientReplacement.ingredient_id)
|
||||
.join(Component, Component.id == DailyIngredientReplacement.replacement_component_id)
|
||||
.where(
|
||||
*_skip_active_filters(DailyIngredientReplacement, d),
|
||||
Recipe.is_deleted.is_(False),
|
||||
Ingredient.is_deleted.is_(False),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Recipe.name.asc(), Ingredient.order.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"recipeId": row.recipe_id,
|
||||
"recipeName": recipe_name,
|
||||
"ingredientId": row.ingredient_id,
|
||||
"ingredientName": ing_name or "—",
|
||||
"replacementComponentId": row.replacement_component_id,
|
||||
"replacementName": replacement_name or "—",
|
||||
**_serialize_skip_dates(row, fallback=d),
|
||||
}
|
||||
for row, recipe_name, ing_name, replacement_name in rows
|
||||
]
|
||||
|
||||
|
||||
def replace_ingredient(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> DailyIngredientReplacement:
|
||||
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("Компонент рейса не найден")
|
||||
replacement = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id == replacement_component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
Component.is_active.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if replacement is None:
|
||||
raise LookupError("Компонент-замена не найден")
|
||||
if ingredient.component_id and ingredient.component_id == replacement_component_id:
|
||||
raise LookupError("Выберите другой компонент")
|
||||
|
||||
return _upsert_part_skip(
|
||||
DailyIngredientReplacement,
|
||||
lookup_filters=(
|
||||
DailyIngredientReplacement.recipe_id == recipe_id,
|
||||
DailyIngredientReplacement.ingredient_id == ingredient_id,
|
||||
),
|
||||
create_fields={
|
||||
"recipe_id": recipe_id,
|
||||
"ingredient_id": ingredient_id,
|
||||
"replacement_component_id": replacement_component_id,
|
||||
"plan_date": start,
|
||||
"valid_until": valid_until if valid_until != start else None,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def collect_component_replacement_targets(
|
||||
component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dispenser_id: Optional[str] = None,
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""Уникальные пары (recipe_id, ingredient_id) в плане для мастер-компонента."""
|
||||
from app.services.daily_plan.builder import build_daily_plan
|
||||
|
||||
iso_date = _parse_plan_date(plan_date).isoformat()
|
||||
if not dispenser_id:
|
||||
return []
|
||||
try:
|
||||
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
|
||||
except LookupError:
|
||||
return []
|
||||
|
||||
seen: Set[Tuple[str, str]] = set()
|
||||
targets: List[Tuple[str, str]] = []
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
recipe_id = str(trip.get("recipeId") or "")
|
||||
if not recipe_id:
|
||||
continue
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
orig = str(ing.get("originalComponentId") or ing.get("componentId") or "")
|
||||
if orig != component_id:
|
||||
continue
|
||||
ingredient_id = str(ing.get("id") or "")
|
||||
if not ingredient_id:
|
||||
continue
|
||||
key = (recipe_id, ingredient_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
targets.append(key)
|
||||
return targets
|
||||
|
||||
|
||||
def replace_component_in_plan(
|
||||
component_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dispenser_id: Optional[str] = None,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> List[DailyIngredientReplacement]:
|
||||
"""Заменить компонент во всех рейсах плана, где он используется."""
|
||||
if component_id == replacement_component_id:
|
||||
raise LookupError("Выберите другой компонент")
|
||||
targets = collect_component_replacement_targets(
|
||||
component_id,
|
||||
plan_date,
|
||||
dispenser_id=dispenser_id,
|
||||
)
|
||||
if not targets:
|
||||
raise LookupError("Компонент не найден в плане на эту дату")
|
||||
rows: List[DailyIngredientReplacement] = []
|
||||
for recipe_id, ingredient_id in targets:
|
||||
rows.append(
|
||||
replace_ingredient(
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
replacement_component_id,
|
||||
plan_date,
|
||||
duration=duration,
|
||||
until_date=until_date,
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def undo_ingredient_replacement(
|
||||
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(
|
||||
DailyIngredientReplacement,
|
||||
lookup_filters=(
|
||||
DailyIngredientReplacement.recipe_id == recipe_id,
|
||||
DailyIngredientReplacement.ingredient_id == ingredient_id,
|
||||
*_skip_active_filters(DailyIngredientReplacement, d),
|
||||
),
|
||||
user=user,
|
||||
table_name="daily_ingredient_replacement",
|
||||
)
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Исключение рейсов и частей рейса из плана на день."""
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user