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

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