Files
2026-07-17 12:57:18 +03:00

307 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Склад компонентов (серверный учет остатков).
Идея:
- Список склада = только строки 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