Интегрирован 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
+306
View File
@@ -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