""" Склад компонентов (серверный учет остатков). Идея: - Список склада = только строки 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/", 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/", 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//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//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