@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Склад компонентов (серверный учет остатков).
|
||||
|
||||
Идея:
|
||||
- Список склада = только строки component_stock (добавленные через «Добавить компонент»).
|
||||
- Данные (id, name) храним в component_stock; при удалении из component они сохраняются.
|
||||
- «Израсходовано» считаем из reports.db по LoadingReportComponent. Сопоставление по component_id
|
||||
(всегда задаётся, есть миграция старых таблиц).
|
||||
- baseline_consumed_kg = расход на момент последнего ввода «Всего».
|
||||
- израсходовано_с_инвентаризации = consumed_total - baseline_consumed_kg
|
||||
- осталось = total_kg - израсходовано_с_инвентаризации
|
||||
|
||||
Таблица server-only, не в синхронизации.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from flask import jsonify, redirect, request, url_for
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.models import ComponentStock
|
||||
from app.services.analytics.stock_forecast import enrich_stock_balance_items
|
||||
from app.services.sklad_metrics import (
|
||||
active_stock_query,
|
||||
consumed_by_component_id,
|
||||
consumed_for,
|
||||
effective_consumed_kg,
|
||||
list_stock_balance_items,
|
||||
stock_balance_row,
|
||||
)
|
||||
from app.timeutil import display_calendar_today, utc_now_iso, utc_now_naive
|
||||
|
||||
|
||||
def init_sklad(app, db, *, Component, LoadingReport, LoadingReportComponent, Recipe=None, Ingredient=None):
|
||||
"""
|
||||
Регистрирует модель и HTTP-эндпоинты склада.
|
||||
|
||||
Важно: вызывать после инициализации app/db и определения Component, LoadingReport, LoadingReportComponent.
|
||||
Recipe и Ingredient опциональны: если переданы, считаем плановый расход в сутки из рецептов (кг на рейс × 1 рейс/день).
|
||||
В recipes.db храним только остатки (component_stock.total_kg). Расход считаем из reports.db по
|
||||
LoadingReportComponent, не записывая его в БД — только для отображения.
|
||||
"""
|
||||
if getattr(app, "_sklad_routes_registered", False):
|
||||
return
|
||||
|
||||
def _active_stock():
|
||||
return active_stock_query(db)
|
||||
|
||||
def _ensure_stock_table_exists():
|
||||
return
|
||||
|
||||
def _consumed_base(date_from: Optional[str] = None, date_to: Optional[str] = None):
|
||||
return consumed_by_component_id(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
|
||||
)
|
||||
|
||||
def _consumed_for(component_id: str, by_id: Dict[str, float]) -> float:
|
||||
return consumed_for(component_id, by_id)
|
||||
|
||||
def _row_to_item(row, by_id_display, by_id_remaining):
|
||||
return stock_balance_row(
|
||||
row, by_id_display, by_id_remaining, db=db, Ingredient=Ingredient, Recipe=Recipe
|
||||
)
|
||||
|
||||
# --- Routes ---
|
||||
@app.route("/sklad")
|
||||
def sklad_page():
|
||||
# Страница склада удалена, учёт остатков — на «Учёт кормов»
|
||||
return redirect(url_for("pages.feed_consumption_page"))
|
||||
|
||||
@app.route("/api/sklad", methods=["GET"])
|
||||
def api_sklad_get():
|
||||
_ensure_stock_table_exists()
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
items = list_stock_balance_items(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
include_report_only=True,
|
||||
Component=Component,
|
||||
)
|
||||
items = enrich_stock_balance_items(items)
|
||||
return jsonify({"items": items, "generated_at": utc_now_iso()})
|
||||
|
||||
@app.route("/api/sklad/add", methods=["POST"])
|
||||
def api_sklad_add():
|
||||
_ensure_stock_table_exists()
|
||||
data = request.get_json(silent=True) or {}
|
||||
cid = data.get("component_id")
|
||||
if not cid:
|
||||
return jsonify({"success": False, "error": "component_id обязателен"}), 400
|
||||
cid = str(cid).strip()
|
||||
active = _active_stock().filter_by(component_id=cid).first()
|
||||
if active:
|
||||
return jsonify({"success": False, "error": "Компонент уже добавлен на склад"}), 400
|
||||
c = Component.query.get(cid)
|
||||
if not c:
|
||||
return jsonify({"success": False, "error": "Компонент не найден"}), 404
|
||||
by_id = _consumed_base(None, None)
|
||||
baseline = _consumed_for(cid, by_id)
|
||||
row = ComponentStock.query.filter_by(component_id=cid).first()
|
||||
if row and getattr(row, "is_deleted", None):
|
||||
row.component_name = str(c.name or "")
|
||||
row.total_kg = 0.0
|
||||
row.baseline_consumed_kg = baseline
|
||||
row.is_deleted = False
|
||||
row.deleted_at = None
|
||||
row.deleted_by = None
|
||||
row.stocktake_at = utc_now_naive()
|
||||
row.updated_at = utc_now_naive()
|
||||
row.updated_by = "system"
|
||||
max_order = (db.session.query(db.func.coalesce(db.func.max(ComponentStock.sort_order), -1)).scalar() or -1) or 0
|
||||
row.sort_order = int(max_order) + 1
|
||||
else:
|
||||
max_order = (db.session.query(db.func.coalesce(db.func.max(ComponentStock.sort_order), -1)).scalar() or -1) or 0
|
||||
row = ComponentStock(
|
||||
component_id=cid,
|
||||
component_name=str(c.name or ""),
|
||||
total_kg=0.0,
|
||||
baseline_consumed_kg=baseline,
|
||||
updated_by="system",
|
||||
sort_order=int(max_order) + 1,
|
||||
)
|
||||
row.stocktake_at = utc_now_naive()
|
||||
row.updated_at = utc_now_naive()
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
by_id = _consumed_base(None, None)
|
||||
item = _row_to_item(row, by_id, by_id)
|
||||
return jsonify({"success": True, "item": item})
|
||||
|
||||
@app.route("/api/sklad/<string:component_id>", methods=["POST"])
|
||||
def api_sklad_set_total(component_id: str):
|
||||
_ensure_stock_table_exists()
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
total_kg = float(data.get("total_kg"))
|
||||
except Exception:
|
||||
return jsonify({"success": False, "error": "total_kg должен быть числом"}), 400
|
||||
if total_kg < 0:
|
||||
return jsonify({"success": False, "error": "total_kg не может быть отрицательным"}), 400
|
||||
try:
|
||||
inflow_kg = max(0.0, float(data.get("inflow_kg", 0) or 0))
|
||||
except Exception:
|
||||
inflow_kg = 0.0
|
||||
row = _active_stock().filter_by(component_id=str(component_id).strip()).first()
|
||||
if not row:
|
||||
return jsonify({"success": False, "error": "Компонент не найден на складе"}), 404
|
||||
by_id = _consumed_base(None, None)
|
||||
consumed = _consumed_for(str(row.component_id), by_id)
|
||||
row.total_kg = total_kg
|
||||
row.inflow_kg = inflow_kg
|
||||
row.baseline_consumed_kg = consumed
|
||||
row.stocktake_at = utc_now_naive()
|
||||
row.updated_at = utc_now_naive()
|
||||
row.updated_by = "system"
|
||||
db.session.commit()
|
||||
by_id = _consumed_base(None, None)
|
||||
item = _row_to_item(row, by_id, by_id)
|
||||
return jsonify({"success": True, "item": item})
|
||||
|
||||
@app.route("/api/sklad/<string:component_id>", methods=["DELETE"])
|
||||
def api_sklad_clear_component(component_id: str):
|
||||
_ensure_stock_table_exists()
|
||||
row = _active_stock().filter_by(component_id=str(component_id).strip()).first()
|
||||
if not row:
|
||||
return jsonify({"success": False, "error": "Компонент не найден на складе"}), 404
|
||||
row.soft_delete(deleted_by_user="system")
|
||||
db.session.commit()
|
||||
return jsonify({"success": True})
|
||||
|
||||
@app.route("/api/sklad/<string:component_id>/move", methods=["PUT"])
|
||||
def api_sklad_move(component_id: str):
|
||||
_ensure_stock_table_exists()
|
||||
cid = str(component_id).strip()
|
||||
data = request.get_json(silent=True) or {}
|
||||
rows = (
|
||||
_active_stock()
|
||||
.order_by(ComponentStock.sort_order.asc(), ComponentStock.component_name.asc())
|
||||
.all()
|
||||
)
|
||||
actual_from = next((i for i, r in enumerate(rows) if r.component_id == cid), None)
|
||||
if actual_from is None:
|
||||
return jsonify({"success": False, "error": "Компонент не найден на складе"}), 404
|
||||
to_index = data.get("to_index")
|
||||
if to_index is None:
|
||||
return jsonify({"success": False, "error": "Укажите to_index"}), 400
|
||||
try:
|
||||
to_index = int(to_index)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"success": False, "error": "to_index должен быть числом"}), 400
|
||||
to_index = max(0, min(to_index, len(rows) - 1))
|
||||
if actual_from == to_index:
|
||||
return jsonify({"success": True})
|
||||
moved = rows.pop(actual_from)
|
||||
rows.insert(to_index, moved)
|
||||
for idx, r in enumerate(rows):
|
||||
r.sort_order = idx
|
||||
r.updated_at = utc_now_naive()
|
||||
r.updated_by = "system"
|
||||
db.session.commit()
|
||||
return jsonify({"success": True})
|
||||
|
||||
@app.route("/api/sklad/<string:component_id>/chart", methods=["GET"])
|
||||
def api_sklad_chart(component_id: str):
|
||||
"""Остаток по дням для графика: remaining_kg = total_kg - кумулятивное потребление по отчётам до конца дня."""
|
||||
_ensure_stock_table_exists()
|
||||
cid = str(component_id).strip()
|
||||
row = _active_stock().filter_by(component_id=cid).first()
|
||||
if not row:
|
||||
return jsonify({"error": "Компонент не найден на складе"}), 404
|
||||
total_kg = max(0.0, float(row.total_kg)) + max(0.0, float(row.inflow_kg or 0))
|
||||
baseline = abs(float(row.baseline_consumed_kg or 0))
|
||||
date_from_s = request.args.get("date_from")
|
||||
date_to_s = request.args.get("date_to")
|
||||
try:
|
||||
if date_from_s and date_to_s:
|
||||
from_dt = datetime.strptime(date_from_s, "%Y-%m-%d").date()
|
||||
to_dt = datetime.strptime(date_to_s, "%Y-%m-%d").date()
|
||||
else:
|
||||
to_dt = display_calendar_today()
|
||||
from_dt = to_dt - timedelta(days=30)
|
||||
if from_dt > to_dt:
|
||||
from_dt, to_dt = to_dt, from_dt
|
||||
except ValueError:
|
||||
to_dt = display_calendar_today()
|
||||
from_dt = to_dt - timedelta(days=30)
|
||||
to_dt_end = datetime.combine(to_dt, datetime.max.time())
|
||||
comp_name = (row.component_name or "").strip()
|
||||
comp_name_lo = comp_name.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 <= to_dt_end)
|
||||
)
|
||||
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))
|
||||
rows_report = q.all()
|
||||
daily_consumption: Dict[date, float] = defaultdict(float)
|
||||
|
||||
def _to_date(val):
|
||||
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
|
||||
|
||||
for start_time, actual_weight, overload, target_weight in rows_report:
|
||||
d = _to_date(start_time)
|
||||
if d is None:
|
||||
continue
|
||||
consumed = effective_consumed_kg(actual_weight, overload, target_weight)
|
||||
if consumed > 0:
|
||||
daily_consumption[d] += consumed
|
||||
all_dates = sorted(daily_consumption.keys())
|
||||
points: List[Dict[str, Any]] = []
|
||||
current = from_dt
|
||||
while current <= to_dt:
|
||||
cum = sum(daily_consumption[d] for d in all_dates if d <= current)
|
||||
consumed_since = max(0.0, cum - baseline)
|
||||
remaining_kg = round(max(0.0, total_kg - consumed_since), 2)
|
||||
points.append({"date": current.isoformat(), "remaining_kg": remaining_kg})
|
||||
current += timedelta(days=1)
|
||||
return jsonify({
|
||||
"component_id": cid,
|
||||
"name": row.component_name or "",
|
||||
"total_kg": round(total_kg, 2),
|
||||
"points": points,
|
||||
})
|
||||
|
||||
_ensure_stock_table_exists()
|
||||
app._sklad_routes_registered = True
|
||||
|
||||
Reference in New Issue
Block a user