@@ -0,0 +1,575 @@
|
||||
"""Расчёты склада и расхода кормов (общие для sklad и feed_accounting)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.models import ComponentStock
|
||||
|
||||
|
||||
def _norm_name(name: Optional[str]) -> str:
|
||||
return (name or "").strip().lower()
|
||||
|
||||
|
||||
def effective_consumed_kg(
|
||||
actual_weight: Optional[float],
|
||||
overload: Optional[float] = None,
|
||||
target_weight: Optional[float] = None,
|
||||
) -> float:
|
||||
"""Расход для склада и СП-20: факт + перерасход; при факте 0 — план терминала."""
|
||||
actual = float(actual_weight or 0)
|
||||
if actual > 0:
|
||||
return actual + max(0.0, float(overload or 0))
|
||||
target = float(target_weight or 0)
|
||||
if target > 0:
|
||||
return target
|
||||
return 0.0
|
||||
|
||||
|
||||
def _resolve_component_id(db, component_id: Optional[str], component_name: Optional[str]) -> Optional[str]:
|
||||
if component_id:
|
||||
return str(component_id)
|
||||
name = (component_name or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
row = (
|
||||
active_stock_query(db)
|
||||
.filter(func.lower(func.trim(ComponentStock.component_name)) == name)
|
||||
.first()
|
||||
)
|
||||
return str(row.component_id) if row else None
|
||||
|
||||
|
||||
def _parse_date_range(
|
||||
date_from: Optional[str], date_to: Optional[str]
|
||||
) -> Tuple[Optional[datetime], Optional[datetime]]:
|
||||
if not date_from or not date_to:
|
||||
return None, None
|
||||
try:
|
||||
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
to_dt = datetime.strptime(date_to, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59, microsecond=999999
|
||||
)
|
||||
return from_dt, to_dt
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
|
||||
def _loading_component_rows_query(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
):
|
||||
from_dt, to_dt = _parse_date_range(date_from, date_to)
|
||||
q = (
|
||||
db.session.query(
|
||||
LoadingReportComponent.component_id,
|
||||
LoadingReportComponent.component_name,
|
||||
LoadingReportComponent.actual_weight,
|
||||
LoadingReportComponent.overload,
|
||||
LoadingReportComponent.target_weight,
|
||||
)
|
||||
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
|
||||
)
|
||||
if hasattr(LoadingReport, "is_deleted"):
|
||||
q = q.filter(LoadingReport.is_deleted.is_(False))
|
||||
if hasattr(LoadingReportComponent, "is_deleted"):
|
||||
q = q.filter(LoadingReportComponent.is_deleted.is_(False))
|
||||
if from_dt and to_dt:
|
||||
q = q.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt)
|
||||
return q
|
||||
|
||||
|
||||
def consumed_totals(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
) -> Tuple[Dict[str, float], Dict[str, float], Dict[str, str]]:
|
||||
"""Расход по component_id, по нормализованному имени и карта имён для отображения."""
|
||||
by_id: Dict[str, float] = defaultdict(float)
|
||||
by_name: Dict[str, float] = defaultdict(float)
|
||||
display_names: Dict[str, str] = {}
|
||||
for cid, cname, actual, overload, target in _loading_component_rows_query(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
|
||||
).all():
|
||||
kg = effective_consumed_kg(actual, overload, target)
|
||||
if kg <= 0:
|
||||
continue
|
||||
if cid:
|
||||
by_id[str(cid)] += kg
|
||||
name_lo = _norm_name(cname)
|
||||
if name_lo:
|
||||
by_name[name_lo] += kg
|
||||
if name_lo not in display_names and (cname or "").strip():
|
||||
display_names[name_lo] = str(cname).strip()
|
||||
return dict(by_id), dict(by_name), display_names
|
||||
|
||||
|
||||
def consumed_by_component_id(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
) -> Dict[str, float]:
|
||||
by_id, by_name, _ = consumed_totals(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
|
||||
)
|
||||
out = dict(by_id)
|
||||
for row in active_stock_query(db).all():
|
||||
cid = str(row.component_id)
|
||||
if cid in out:
|
||||
continue
|
||||
name_lo = _norm_name(row.component_name)
|
||||
if name_lo and name_lo in by_name:
|
||||
out[cid] = by_name[name_lo]
|
||||
return out
|
||||
|
||||
|
||||
def consumed_for_stock(
|
||||
component_id: str,
|
||||
component_name: str,
|
||||
by_id: Dict[str, float],
|
||||
by_name: Dict[str, float],
|
||||
) -> float:
|
||||
name_lo = _norm_name(component_name)
|
||||
if name_lo and name_lo in by_name:
|
||||
return by_name[name_lo]
|
||||
cid = str(component_id or "").strip()
|
||||
if cid and cid in by_id:
|
||||
return by_id[cid]
|
||||
return 0.0
|
||||
|
||||
|
||||
def consumed_for(component_id: str, by_id: Dict[str, float]) -> float:
|
||||
if component_id and component_id in by_id:
|
||||
return by_id[component_id]
|
||||
return 0.0
|
||||
|
||||
|
||||
def planned_kg_per_day(db, Ingredient, Recipe, component_id: str) -> float:
|
||||
if not Ingredient or not Recipe or not component_id:
|
||||
return 0.0
|
||||
try:
|
||||
q = (
|
||||
db.session.query(Ingredient, Recipe)
|
||||
.join(Recipe, Recipe.id == Ingredient.recipe_id)
|
||||
.filter(Ingredient.component_id == component_id)
|
||||
)
|
||||
if hasattr(Ingredient, "is_deleted"):
|
||||
q = q.filter(Ingredient.is_deleted.is_(False))
|
||||
if hasattr(Recipe, "is_deleted"):
|
||||
q = q.filter(Recipe.is_deleted.is_(False))
|
||||
rows = q.all()
|
||||
except Exception:
|
||||
return 0.0
|
||||
total_kg_per_day = 0.0
|
||||
for ing, recipe in rows:
|
||||
wph = float(ing.weight_per_head or 0)
|
||||
hpt = int(recipe.heads_per_trip or 0)
|
||||
tp = float(recipe.trip_percent or 100) / 100.0
|
||||
total_kg_per_day += wph * hpt * tp
|
||||
return round(total_kg_per_day, 2)
|
||||
|
||||
|
||||
def recipe_heads_for_component(db, Ingredient, Recipe, component_id: str) -> int:
|
||||
if not Ingredient or not Recipe or not component_id:
|
||||
return 0
|
||||
try:
|
||||
q = (
|
||||
db.session.query(Recipe.heads_per_trip)
|
||||
.join(Ingredient, Ingredient.recipe_id == Recipe.id)
|
||||
.filter(Ingredient.component_id == component_id)
|
||||
)
|
||||
if hasattr(Ingredient, "is_deleted"):
|
||||
q = q.filter(Ingredient.is_deleted.is_(False))
|
||||
if hasattr(Recipe, "is_deleted"):
|
||||
q = q.filter(Recipe.is_deleted.is_(False))
|
||||
total = 0
|
||||
for (hpt,) in q.all():
|
||||
total += int(hpt or 0)
|
||||
return total
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def norm_per_head_for_component(db, Ingredient, Recipe, component_id: str) -> Optional[float]:
|
||||
if not Ingredient or not Recipe or not component_id:
|
||||
return None
|
||||
try:
|
||||
q = (
|
||||
db.session.query(Ingredient.weight_per_head)
|
||||
.join(Recipe, Recipe.id == Ingredient.recipe_id)
|
||||
.filter(Ingredient.component_id == component_id)
|
||||
)
|
||||
if hasattr(Ingredient, "is_deleted"):
|
||||
q = q.filter(Ingredient.is_deleted.is_(False))
|
||||
if hasattr(Recipe, "is_deleted"):
|
||||
q = q.filter(Recipe.is_deleted.is_(False))
|
||||
vals = [float(v or 0) for (v,) in q.all() if v]
|
||||
if not vals:
|
||||
return None
|
||||
return round(sum(vals) / len(vals), 3)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def stock_balance_row(
|
||||
row: ComponentStock,
|
||||
by_id_display: Dict[str, float],
|
||||
by_id_remaining: Dict[str, float],
|
||||
*,
|
||||
by_name_display: Optional[Dict[str, float]] = None,
|
||||
by_name_remaining: Optional[Dict[str, float]] = None,
|
||||
db=None,
|
||||
Ingredient=None,
|
||||
Recipe=None,
|
||||
) -> Dict[str, Any]:
|
||||
cid = str(row.component_id)
|
||||
cname = str(row.component_name or "")
|
||||
total_kg = max(0.0, float(row.total_kg))
|
||||
inflow_kg = max(0.0, float(row.inflow_kg or 0))
|
||||
if by_name_display is not None:
|
||||
consumed_period_raw = consumed_for_stock(cid, cname, by_id_display, by_name_display)
|
||||
consumed_alltime_raw = consumed_for_stock(
|
||||
cid, cname, by_id_remaining, by_name_remaining or {}
|
||||
)
|
||||
else:
|
||||
consumed_period_raw = consumed_for(cid, by_id_display)
|
||||
consumed_alltime_raw = consumed_for(cid, by_id_remaining)
|
||||
consumed_total_kg = round(max(0.0, consumed_period_raw), 2)
|
||||
baseline = abs(float(row.baseline_consumed_kg or 0))
|
||||
consumed_since_stocktake = max(0.0, consumed_alltime_raw - baseline)
|
||||
available_kg = total_kg + inflow_kg
|
||||
remaining_raw = available_kg - consumed_since_stocktake
|
||||
remaining_kg = round(max(0.0, min(available_kg, remaining_raw)), 2)
|
||||
planned = planned_kg_per_day(db, Ingredient, Recipe, cid)
|
||||
days_left_plan = None
|
||||
if planned > 0 and remaining_kg > 0:
|
||||
d = remaining_kg / planned
|
||||
days_left_plan = round(min(d, 9999.9), 1) if d < 9999.9 else 9999.9
|
||||
return {
|
||||
"component_id": cid,
|
||||
"name": str(row.component_name or ""),
|
||||
"name_key": _norm_name(row.component_name),
|
||||
"type": "",
|
||||
"total_kg": round(total_kg, 2),
|
||||
"inflow_kg": round(inflow_kg, 2),
|
||||
"consumed_kg": consumed_total_kg,
|
||||
"consumed_total_kg": consumed_total_kg,
|
||||
"remaining_kg": remaining_kg,
|
||||
"planned_consumption_per_day_kg": planned,
|
||||
"days_left_plan": days_left_plan,
|
||||
"stocktake_at": row.stocktake_at.isoformat() if row.stocktake_at else None,
|
||||
"has_stock": True,
|
||||
}
|
||||
|
||||
|
||||
def active_stock_query(db):
|
||||
return ComponentStock.query.filter(
|
||||
db.or_(ComponentStock.is_deleted.is_(None), ComponentStock.is_deleted == False)
|
||||
)
|
||||
|
||||
|
||||
def resolve_component_id_by_name(db, Component, name: Optional[str]) -> Optional[str]:
|
||||
"""Сопоставление имени из отчёта с компонентом справочника."""
|
||||
if not Component or not name:
|
||||
return None
|
||||
name_lo = _norm_name(name)
|
||||
if not name_lo:
|
||||
return None
|
||||
q = db.session.query(Component.id).filter(
|
||||
func.lower(func.trim(Component.name)) == name_lo
|
||||
)
|
||||
if hasattr(Component, "is_deleted"):
|
||||
q = q.filter(Component.is_deleted.is_(False))
|
||||
row = q.first()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
|
||||
def stock_item_key(item: Dict[str, Any]) -> str:
|
||||
cid = str(item.get("component_id") or "").strip()
|
||||
if cid:
|
||||
return cid
|
||||
return str(item.get("name_key") or "").strip()
|
||||
|
||||
|
||||
def _to_date(val) -> Optional[date]:
|
||||
if val is None:
|
||||
return None
|
||||
if hasattr(val, "date") and callable(getattr(val, "date", None)):
|
||||
return val.date()
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return datetime.strptime(val[:10], "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def month_date_bounds(year: int, month: int) -> Tuple[date, date]:
|
||||
last = calendar.monthrange(year, month)[1]
|
||||
return date(year, month, 1), date(year, month, last)
|
||||
|
||||
|
||||
def daily_consumption_by_component(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
component_id: str,
|
||||
component_name: str,
|
||||
year: int,
|
||||
month: int,
|
||||
) -> Dict[int, float]:
|
||||
start_d, end_d = month_date_bounds(year, month)
|
||||
from_dt = datetime.combine(start_d, datetime.min.time())
|
||||
to_dt = datetime.combine(end_d, datetime.max.time())
|
||||
cid = str(component_id).strip()
|
||||
comp_name_lo = (component_name or "").strip().lower()
|
||||
id_or_name = (
|
||||
or_(
|
||||
LoadingReportComponent.component_id == cid,
|
||||
and_(
|
||||
LoadingReportComponent.component_id.is_(None),
|
||||
func.lower(func.trim(LoadingReportComponent.component_name)) == comp_name_lo,
|
||||
),
|
||||
)
|
||||
if comp_name_lo
|
||||
else (LoadingReportComponent.component_id == cid)
|
||||
)
|
||||
q = (
|
||||
db.session.query(
|
||||
LoadingReport.start_time,
|
||||
LoadingReportComponent.actual_weight,
|
||||
LoadingReportComponent.overload,
|
||||
LoadingReportComponent.target_weight,
|
||||
)
|
||||
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
|
||||
.filter(id_or_name)
|
||||
.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt)
|
||||
)
|
||||
if hasattr(LoadingReport, "is_deleted"):
|
||||
q = q.filter(LoadingReport.is_deleted.is_(False))
|
||||
if hasattr(LoadingReportComponent, "is_deleted"):
|
||||
q = q.filter(LoadingReportComponent.is_deleted.is_(False))
|
||||
daily: Dict[int, float] = defaultdict(float)
|
||||
for start_time, actual_weight, overload, target_weight in q.all():
|
||||
d = _to_date(start_time)
|
||||
if d is None or d.year != year or d.month != month:
|
||||
continue
|
||||
consumed = effective_consumed_kg(actual_weight, overload, target_weight)
|
||||
if consumed > 0:
|
||||
daily[d.day] += consumed
|
||||
return dict(daily)
|
||||
|
||||
|
||||
def month_consumption_by_component(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
year: int,
|
||||
month: int,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Все компоненты с расходом за месяц (для СП-20), не только со склада."""
|
||||
start_d, end_d = month_date_bounds(year, month)
|
||||
from_dt = datetime.combine(start_d, datetime.min.time())
|
||||
to_dt = datetime.combine(end_d, datetime.max.time())
|
||||
q = (
|
||||
db.session.query(
|
||||
LoadingReportComponent.component_id,
|
||||
LoadingReportComponent.component_name,
|
||||
LoadingReport.start_time,
|
||||
LoadingReportComponent.actual_weight,
|
||||
LoadingReportComponent.overload,
|
||||
LoadingReportComponent.target_weight,
|
||||
)
|
||||
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
|
||||
.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt)
|
||||
)
|
||||
if hasattr(LoadingReport, "is_deleted"):
|
||||
q = q.filter(LoadingReport.is_deleted.is_(False))
|
||||
if hasattr(LoadingReportComponent, "is_deleted"):
|
||||
q = q.filter(LoadingReportComponent.is_deleted.is_(False))
|
||||
agg: Dict[str, Dict[str, Any]] = {}
|
||||
for cid, cname, start_time, actual, overload, target in q.all():
|
||||
kg = effective_consumed_kg(actual, overload, target)
|
||||
if kg <= 0:
|
||||
continue
|
||||
resolved = _resolve_component_id(db, cid, cname) or (
|
||||
str(cid).strip() if cid else f"name:{(cname or '').strip().lower()}"
|
||||
)
|
||||
name = str(cname or "").strip() or resolved
|
||||
bucket = agg.setdefault(
|
||||
resolved,
|
||||
{"component_id": str(cid) if cid else "", "name": name, "daily": defaultdict(float)},
|
||||
)
|
||||
if not bucket["name"] and name:
|
||||
bucket["name"] = name
|
||||
d = _to_date(start_time)
|
||||
if d and d.year == year and d.month == month:
|
||||
bucket["daily"][d.day] += kg
|
||||
for item in agg.values():
|
||||
item["daily"] = dict(item["daily"])
|
||||
return agg
|
||||
|
||||
|
||||
def daily_unloading_by_group(
|
||||
db,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
year: int,
|
||||
month: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_d, end_d = month_date_bounds(year, month)
|
||||
from_dt = datetime.combine(start_d, datetime.min.time())
|
||||
to_dt = datetime.combine(end_d, datetime.max.time())
|
||||
q = (
|
||||
db.session.query(
|
||||
UnloadingReportGroup.name,
|
||||
UnloadingReport.recipe_name,
|
||||
UnloadingReportGroup.distribution_type,
|
||||
UnloadingReportGroup.distribution_value,
|
||||
UnloadingReport.start_time,
|
||||
UnloadingReportGroup.unloaded_weight,
|
||||
)
|
||||
.join(UnloadingReport, UnloadingReport.id == UnloadingReportGroup.report_id)
|
||||
.filter(UnloadingReport.start_time >= from_dt, UnloadingReport.start_time <= to_dt)
|
||||
)
|
||||
if hasattr(UnloadingReport, "is_deleted"):
|
||||
q = q.filter(UnloadingReport.is_deleted.is_(False))
|
||||
if hasattr(UnloadingReportGroup, "is_deleted"):
|
||||
q = q.filter(UnloadingReportGroup.is_deleted.is_(False))
|
||||
grouped: Dict[str, Dict[str, Any]] = {}
|
||||
for gname, recipe_name, dist_type, dist_val, start_time, unloaded in q.all():
|
||||
key = str(gname or "")
|
||||
if key not in grouped:
|
||||
grouped[key] = {
|
||||
"group_name": key,
|
||||
"recipe_name": str(recipe_name or ""),
|
||||
"distribution_type": str(dist_type or ""),
|
||||
"distribution_value": float(dist_val or 0),
|
||||
"daily": defaultdict(float),
|
||||
}
|
||||
d = _to_date(start_time)
|
||||
if d and d.year == year and d.month == month:
|
||||
grouped[key]["daily"][d.day] += float(unloaded or 0)
|
||||
rows = []
|
||||
for item in grouped.values():
|
||||
daily = dict(item["daily"])
|
||||
item["daily"] = daily
|
||||
item["month_total"] = round(sum(daily.values()), 2)
|
||||
rows.append(item)
|
||||
rows.sort(key=lambda r: r["group_name"].lower())
|
||||
return rows
|
||||
|
||||
|
||||
def period_consumption_rows(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Строки расхода по компонентам за период (из отчётов загрузки)."""
|
||||
_, by_name, display_names = consumed_totals(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
|
||||
)
|
||||
rows = []
|
||||
for name_lo, kg in by_name.items():
|
||||
if kg <= 0:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": display_names.get(name_lo) or name_lo,
|
||||
"name_key": name_lo,
|
||||
"consumed_kg": round(kg, 2),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: r["name"].lower())
|
||||
return rows
|
||||
|
||||
|
||||
def list_stock_balance_items(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
include_report_only: bool = True,
|
||||
Component=None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
by_id_display, by_name_display, display_names = consumed_totals(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
|
||||
)
|
||||
if date_from and date_to:
|
||||
by_id_remaining, by_name_remaining, _ = consumed_totals(
|
||||
db, LoadingReport, LoadingReportComponent
|
||||
)
|
||||
else:
|
||||
by_id_remaining, by_name_remaining = by_id_display, by_name_display
|
||||
rows = (
|
||||
active_stock_query(db)
|
||||
.order_by(ComponentStock.sort_order.asc(), ComponentStock.component_name.asc())
|
||||
.all()
|
||||
)
|
||||
stock_names: set[str] = set()
|
||||
items = [
|
||||
stock_balance_row(
|
||||
r,
|
||||
by_id_display,
|
||||
by_id_remaining,
|
||||
by_name_display=by_name_display,
|
||||
by_name_remaining=by_name_remaining,
|
||||
db=db,
|
||||
Ingredient=Ingredient,
|
||||
Recipe=Recipe,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
for item in items:
|
||||
stock_names.add(_norm_name(item.get("name")))
|
||||
if include_report_only:
|
||||
for name_lo, kg in sorted(by_name_display.items(), key=lambda x: x[0]):
|
||||
if name_lo in stock_names:
|
||||
continue
|
||||
display_name = display_names.get(name_lo) or name_lo
|
||||
resolved_cid = resolve_component_id_by_name(db, Component, display_name) or ""
|
||||
planned = (
|
||||
planned_kg_per_day(db, Ingredient, Recipe, resolved_cid)
|
||||
if resolved_cid
|
||||
else 0.0
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"component_id": resolved_cid,
|
||||
"name": display_name,
|
||||
"name_key": name_lo,
|
||||
"type": "",
|
||||
"total_kg": 0.0,
|
||||
"inflow_kg": 0.0,
|
||||
"consumed_kg": round(kg, 2),
|
||||
"consumed_total_kg": round(kg, 2),
|
||||
"remaining_kg": 0.0,
|
||||
"planned_consumption_per_day_kg": planned,
|
||||
"days_left_plan": None,
|
||||
"stocktake_at": None,
|
||||
"has_stock": False,
|
||||
}
|
||||
)
|
||||
return items
|
||||
Reference in New Issue
Block a user